스트리밍은 대부분의 브라우저와
Developer 앱에서 사용할 수 있습니다.
-
SwiftUI와 UIKit으로 접근성 높은 앱 구축하기
향상된 UI 프레임워크로 풍부하고 접근성이 뛰어난 경험을 구축할 방법을 발견하세요. VoiceOver와 같은 기술이 접근성의 특성과 움직임을 통해 앱의 인터페이스와의 상호작용을 향상하는 방법도 알아보세요. SwiftUI의 최신 업데이트로 UIKit 앱에서 접근성 경험을 개선하고 접근성 정보를 최신 상태로 유지하는 방법을 공유합니다.
챕터
- 0:00 - Welcome
- 1:30 - Explore the toggle trait
- 2:46 - Discover multi-platform accessibility announcements
- 3:58 - Assign priority to announcements
- 6:36 - Meet the zoom action
- 8:00 - Refine VoiceOver direct touch experiences
- 11:08 - Customize accessibility content shapes in SwiftUI
- 12:48 - Keep accessibility attributes up-to-date in UIKit using block-based setters
리소스
관련 비디오
WWDC23
-
다운로드
♪ ♪
반가워요, 여러분 저는 앨리슨이며 접근성 엔지니어입니다 오늘은 앱의 접근성을 더 키울 새롭고 흥미로운 방법을 얘기하려고 해요 Apple에게 접근성은 구축에 필수적인 부분인데요 모두가 기술의 혜택을 누릴 자격이 있다고 믿기 때문이죠 저희는 여러분이 앱의 접근성을 매우 쉽게 개선하길 바랍니다 지난 1년 동안 모두가 앱에서 최상의 경험을 누릴 수 있도록 저희가 여러 가지를 개선해 왔습니다 이번 세션에서 우리는 새롭고 흥미로운 방식으로 앱과 상호작용할 수 있게 하는 API를 탐구할 겁니다 그런 다음 SwiftUI 앱에서 콘텐츠의 접근성 시각 요소를 개선하는 방법을 의논할 거고요 마지막으로 UIKit에서 접근성 속성을 최신 상태로 유지하는 방법을 알아보겠습니다 그럼 접근성 향상에 관해 얘기해 보죠 이건 제가 미리 개발해 둔 사진 편집 앱인데요 보관함이나 카메라에서 가져온 사진에 멋진 이미지 수정을 추가할 수 있는 앱이죠 앱에서 다양한 필터를 적용하거나 틴트 색깔을 변경할 수 있으며 사용자 지정 소리를 생성하도록 건반을 사용할 수도 있죠 이제 앱에 통합할 수 있는 접근성 향상 몇 가지를 논의해 보겠습니다 제 사진 앱의 필터 페이지에는 상태를 켜거나 끌 수 있는 사용자 지정 버튼이 있는데요
'필터' 스위치 버튼은 이미지 필터를 토글하죠 시스템이 이 사용자 지정 UI에 맞는 올바른 접근성 단서와 제목을 모르기 때문에 다른 시스템 토글과 일치하는 접근성 경험을 제공하려고 합니다 이때 새로운 접근성 특성인 isToggle이 유용하죠 필터 버튼을 나타낼 구조체는 다음과 같습니다 구조체의 본문에서 누를 때마다 필터를 토글하는 버튼을 생성합니다 필터 상태 변수에 따라 버튼의 색깔이 업데이트되죠 accessibilityAddTraits 수정자에서 필터 버튼에 isToggle 특성을 추가합니다 isToggle은 적절한 접근성 단서와 '스위치 버튼' 설명을 제공합니다 '필터, 스위치 버튼' '더블 탭하여 설정을 토글'
새로운 토글 특성은 UIKit에서도 사용이 가능한데요 viewDidLoad 메서드에서 버튼 뷰를 설정합니다 그리고 버튼에서 accessibilityTraits 속성에 .toggleButton을 포함시킵니다 사진 필터 앱에서 사진 뷰가 로드 중임을 알리기 위해 사진 내비게이션 바 버튼에 새로운 안내를 추가하려고 하는데요 접근성 알림은 이를 지원하는 새로운 API입니다 접근성 알림은 통합된 멀티 플랫폼 방식을 제공해 앱에서 보조 기술을 사용할 때 정보를 전달하는 안내를 생성하죠 SwiftUI나 UIKit, AppKit으로 실행되는 앱에서 접근성 알림을 생성할 수 있습니다 AccessibilityNotification을 사용하면 안내 레이아웃 변경, 화면 변경 페이지 스크롤 알림을 Swift에 네이티브 방식으로 전송할 수 있습니다 사진 도구 막대 버튼을 누르면 안내가 게시되도록 하려는데요 '사진, 버튼' '사진, 사진 뷰 로드 중' 도구 막대 버튼의 행위에 안내를 게시할 수 있습니다 안내의 생성을 위해 Accessibility Notification.Announcement와 '사진 뷰 로드 중' 문자열 매개 변수를 사용할 수 있죠 앱에서 카메라 내비게이션 바를 누를 때 나오는 안내를 3종류 더 생성하려고 하는데요 첫 번째 안내인 '카메라 열기'와 세 번째 안내인 '카메라 작동'이 제일 중요하죠 VoiceOver의 현재 음성 패턴이 안내를 어떻게 하는지 확인해 보죠 두 번째 안내인 '카메라 로드 중'이 '카메라 여는 중'을 끊는 것에 주목하세요 '카메라, 버튼' '완료, 카메라 여… 카메라… 카메라 작동' SwiftUI와 UIKit에서는 안내의 우선순위를 설정할 수 있으며 이를 통해 보조 기술이 읽어야 할 안내의 중요도를 설정할 수 있습니다 제 시간에 발음되지 않으면 무시해도 무방한 안내와 꼭 들어야 할 안내와 구분하는 등 더 많은 제어가 가능하죠 이 정보의 중요성을 지정할 때 3가지의 안내 우선순위를 지정할 수 있는데요 바로 '높은', '기본', '낮은'이죠 높은 우선순위 안내는 다른 음성을 끊을 수 있으며 이 안내는 시작되면 끊길 수 없습니다 기본 우선순위 안내는 기존 음성을 끊을 수 있지만 새로 발화되는 음성에 끊길 수 있습니다 낮은 우선순위 안내는 대기열에 추가되며 다른 음성의 발화가 완료되고 다른 새로운 안내가 시작되지 않을 경우 발화됩니다 사진 앱에서 안내 우선순위를 사용해 문자열이 끊기는 것을 해결할 수 있습니다 속성 문자열에서 만든 안내가 3종류 있는데요 SwiftUI에서 설정한 우선순위를 accessibilitySpeechAnnouncement Priority 문자열 속성에 둡니다 두 번째 안내인 '카메라 로드 중'은 가장 덜 중요하므로 낮은 우선순위를 주고요 마지막 안내인 '카메라 작동'은 제일 중요하므로 높은 우선순위를 줍니다 AccessibilityNotification에는 속성 문자열을 전달합니다 먼저 기본 우선순위 안내를 배정하고 그 다음은 낮은 우선순위 그 뒤는 높은 우선순위로 합니다 낮은 우선순위 안내는 기본 우선순위 안내를 끊지 않으며 높은 우선순위 안내는 기본 및 낮은 우선순위 안내를 끊는다는 것에 주목하세요 '카메라, 버튼' '완료, 카메라 여… 카메라 작동' 동일한 안내 순서를 UIKit에서 달성할 수 있는데요 NSAttributedString 키 값 쌍으로 안내 우선순위를 설정합니다 사용하는 키는 UIAccessibilitySpeechAttribute AnnouncementPriority이며 적절한 UIAccessibilityPriority로 해당 값을 설정하고요 속성 문자열 초기화 함수에 해당 속성을 전달합니다 앱으로 돌아와서 이 이미지 뷰에서는 물리적으로 터치하고 제스처로 줌할 수 있죠 보조 기술이 작동 중이면 이런 물리적 터치나 핀치 제스처를 제대로 사용하는 게 어려울 겁니다 하지만 줌 행위 접근성을 사용하면 보조 기술이 활성화 중이더라도 UI 요소를 줌할 수 있습니다 이 줌 행위를 이미지에 추가합시다 ZoomingImageView 구조체의 본문에 이 이미지가 있는데요 먼저 accessibilityZoomAction 수정자를 추가하고 줌 행위의 방향에 따라 콘텐츠를 확대하거나 축소하고 접근성 알림 안내를 게시합니다 이제 VoiceOver의 줌 기능이 이 변화로 어떻게 됐는지 봅시다 '이미지 뷰 줌하는 중, 이미지' '줌' '2배 줌, 3배 줌' '4배 줌, 3배 줌' 줌 특성 및 행위를 UIKit에서도 추가할 수 있는데요 우선 이미지 뷰를 포함한 줌 뷰를 생성합니다 그런 다음 supportsZoom 특성을 이미지 특성과 함께 줌 뷰에 추가하고요 그런 다음 accessibilityZoomInAtPoint와 accessibilityZoomOutAtPoint를 구현해 각각 불리언을 반환하게 해 줌의 성공 여부를 알리도록 합니다 각 메서드에서는 줌 비율을 업데이트하고 줌 변화를 알리는 안내를 게시하죠 이 이미지 필터 앱에는 건반을 연주해 생성한 짧은 소리를 이미지에 추가할 수도 있는데요 건반을 사용해 자기만의 음계를 이미지에 넣을 수 있죠 이 소리로 생성한 음계를 통해 현재 VoiceOver 경험을 살펴보겠습니다
각 요소를 터치할 때마다 VoiceOver가 음표를 읽은 다음 VoiceOver 작동 소리가 재생됩니다 이러면 건반을 연속해서 빠르게 누르기 어렵죠 일반적으로 VoiceOver는 안전한 탐색 경험을 제공합니다만 사용자가 앱을 적절히 사용할 수 있도록 직접 상호작용할 때도 필요하죠 우리 앱에서는 추가 음성과 소리 없이 건반을 직접 터치하는 편이 더 나을 수 있습니다 우리의 뷰에 allowsDirectInteraction이라는 직접 터치 특성을 적용하기 딱 좋은 순간이군요 접근성 직접 터치 영역을 사용하면 VoiceOver 동작이 앱으로 직접 전달되는 화면 구역을 지정할 수 있습니다 기본 상태에서 VoiceOver는 직접 터치 요소의 콘텐츠를 읽고 활성화하는데요 하지만 우리 앱에서는 건반을 터치할 때 건반 요소를 먼저 활성화하지 않고 바로 음을 들을 수 있도록 VoiceOver가 침묵하면 좋겠죠 allowsDirectInteraction 특성 외에도 새롭게 지원할 직접 터치 옵션이 두 종류 있습니다 첫 번째 옵션인 silentOnTouch는 직접 터치 영역을 터치할 때 VoiceOver를 침묵시키도록 지정할 수 있습니다 이러면 앱이 자체 소리 피드백을 제공할 수 있죠 두 번째 옵션인 requiresActivation을 지정하면 VoiceOver가 필요한 직접 터치 영역을 만들어 터치 패스스루가 발생하기 전에 요소를 활성화할 수 있고요 이것은 KeyboardKeyView의 코드 조각인데요 각 건반은 지정한 소리를 연주하는 직사각형 형태입니다 VoiceOver가 음계를 매번 읽는 문제를 해결하도록 터치한 버튼은 침묵하게끔 직접 터치 영역을 설정했습니다 이제 VoiceOver가 건반 버튼에 도달하면 VoiceOver 음성의 방해 없이 올바른 음계가 연주됩니다 새로운 직접 터치 영역은 UIKit에서도 추가할 수 있습니다 UIButton으로 건반 버튼을 생성할 수 있고요 그런 다음 allowsDirectInteraction 접근성 특성을 추가합니다 UIKit에서 접근성 직접 터치 옵션을 설정하려면 필요한 특성이죠 마지막으로 accessibility DirectTouchOptions를 위한 silentOnTouch 옵션을 추가합니다 이러한 접근성 토글 특성 안내 우선순위, 줌 특성 직접 터치 옵션으로 SwiftUI 및 UIKit 앱에서 보조 기술이 상호작용하는 방식을 더 많이 제어할 수 있습니다 이제 SwiftUI의 접근성 콘텐츠 형태 종류를 얘기해 보겠습니다 이 종류는 접근성 요소의 경로를 설정하고 접근성 요소의 외형을 화면에서 제어합니다 이전에는 상호작용 콘텐츠 형태 종류가 접근성 형태와 히트 테스트 형태를 변경했는데요 이제 히트 테스트 형태는 그대로 두고 접근성 콘텐츠 형태에만 영향을 주는 접근성 콘텐츠 형태 종류를 사용할 수 있습니다 요소가 원 같은 사용자 지정 형태가 필요할 때 계산된 접근성 커서 시각화가 화면의 다른 항목을 가릴 수 있죠 이 예시에서 정사각형인 접근성 경로는 빨간색 원형 콘텐츠랑 어울리지 않습니다 접근성 콘텐츠 형태 종류가 뷰에 적용되면 요소의 내장 접근성 지오메트리를 수정자가 제공한 모양에 맞춰 업데이트합니다 이를 통해 요소의 경로를 기존의 SwiftUI 형태로 빠르게 업데이트할 수 있죠 제가 원형 이미지를 사용해 원형 버튼을 생성했는데요 프레임과 접근성 레이블을 빨간색과 일치하도록 설정할 수 있습니다 마지막으로 접근성 유형과 원 형태를 사용해 뷰에 콘텐츠 형태 수정자를 추가할 수 있습니다
이제 접근성 경로가 빨간색 원형 버튼에 완벽하게 어울리네요 마지막으로 UIKit 접근성에 추가될 블록 기반 속성 설정자를 논해 보겠습니다 사진 편집 앱에서 이미지 뷰의 접근성 값이 사진의 필터 여부를 나타내도록 설정할 건데요 이제 뷰가 제시된 UI와 항상 일치하도록 내장 접근성 속성을 쉽게 유지하는 방법이 있습니다 접근성 블록 기반 설정자를 사용하면 되죠 새 접근성 블록 API를 사용하면 값을 직접 저장하는 대신 그 속성이 필요할 때마다 평가되는 클로저를 제공할 수 있습니다 이 클로저는 보조 기술이 뷰를 참조하거나 접근할 때마다 재평가되죠 viewDidLoad 메서드를 뷰 컨트롤러에서 생성하는 것으로 이들을 클로저로 간소화할 수 있습니다 이미지의 필터 여부에 따라 접근성 값이 업데이트되도록 zoomView의 accessibility ValueBlock 속성을 설정합니다 클로저는 반드시 선택적 문자열인 해당 속성에 맞는 올바른 유형을 반환해야 합니다 순환 참조를 피하도록 self에 약한 참조를 사용하는 것에 주목해 주세요 적절한 접근성 속성 정보로 클래스를 시작할 수 있도록 클래스의 생명 주기 시작점에 블록을 추가하는 게 가장 좋습니다 이제 접근성 속성을 유지하는 게 훨씬 쉬워졌네요 VoiceOver 커서를 새로운 요소로 이동할 때마다 클로저로 설정된 속성을 VoiceOver가 우선 살펴본 다음 클로저를 재평가합니다
사용자 지정 UI를 구축할 때는 직접 터치 상호작용 등의 기능과 토글 같은 접근성 특성을 통합해 사용성을 높이는 것을 고려하세요
두 번째로 SwiftUI에서 뷰에 사용자 지정 형태를 염두에 두세요 접근성 형태가 UI에 어울리지 않는다면 사용자 지정 접근성 형태의 도입을 고려하시기 바랍니다 마지막으로 접근성 속성을 어떻게 설정했는지 평가하시고 블록 기반 설정자가 잘 들어맞는지 확인하시는 것을 권장합니다 Apple은 접근성이 인권이라고 믿습니다 여러분의 도움으로 저희가 모두의 삶을 향상하고 증진할 기술을 생성할 수 있어요 새롭게 추가된 이 API들이 보조 기술에 의존하는 사람들에게 앱의 사용성을 높여 주므로 이걸 전부 사용해 놀랍고 접근성이 뛰어난 앱을 구축하길 권합니다 시청해 주셔서 감사합니다
-
-
1:54 - Add the accessibility toggle trait
import SwiftUI struct FilterButton: View { @State var filter: Bool = false var body: some View { Button(action: { filter.toggle() }) { Text("Filter") } .background(filter ? darkGreen : lightGreen) .accessibilityAddTraits(.isToggle) } }
-
2:31 - Add the accessibility toggle trait with UIKit
import UIKit class ViewController: UIViewController { override func viewDidLoad() { super.viewDidLoad() let filterButton = UIButton(type: .custom) setupButtonView() filterButton.accessibilityTraits = [.toggleButton] view.addSubview(filterButton) } }
-
3:43 - Post an accessibility notification
import SwiftUI struct ContentView: View { var body: some View { NavigationView { PhotoFilterView .toolbar { Button(action: { AccessibilityNotification.Announcement("Loading Photos View") .post() }) { Text("Photos") } } } } }
-
5:13 - Assign announcement priority
import SwiftUI struct ZoomingImageView: View { var defaultPriorityAnnouncement = AttributedString("Opening Camera") var lowPriorityAnnouncement: AttributedString { var lowPriorityString = AttributedString("Camera Loading") lowPriorityString.accessibilitySpeechAnnouncementPriority = .low return lowPriorityString } var highPriorityAnnouncement: AttributedString { var highPriorityString = AttributedString("Camera Active") highPriorityString.accessibilitySpeechAnnouncementPriority = .high return highPriorityString } // ... }
-
5:46 - Post announcements with priority set
import SwiftUI struct CameraButton: View { // ... var body: some View { Button(action: { // Open Camera Code AccessibilityNotification.Announcement(defaultPriorityAnnouncement).post() // Camera Loading Code AccessibilityNotification.Announcement(lowPriorityAnnouncement).post() // Camera Loaded Code AccessibilityNotification.Announcement(highPriorityAnnouncement).post() }) { Image("Camera") } } } }
-
6:15 - Assign announcement priority with UIKit
class ViewController: UIViewController { let defaultAnnouncement = NSAttributedString(string: "Opening Camera", attributes: [NSAttributedString.Key.UIAccessibilitySpeechAttributeAnnouncementPriority: UIAccessibilityPriority.default] ) let lowPriorityAnnouncement = NSAttributedString(string: "Camera Loading", attributes: [NSAttributedString.Key.UIAccessibilitySpeechAttributeAnnouncementPriority: UIAccessibilityPriority.low] ) let highPriorityAnnouncement = NSAttributedString(string: "Camera Active", attributes: [NSAttributedString.Key.UIAccessibilitySpeechAttributeAnnouncementPriority: UIAccessibilityPriority.high] ) // ... }
-
6:56 - Add the accessibility zoom action
struct ZoomingImageView: View { @State private var zoomValue = 1.0 @State var imageName: String? var body: some View { Image(imageName ?? "") .scaleEffect(zoomValue) .accessibilityZoomAction { action in let zoomQuantity = "\(Int(zoomValue)) x zoom" switch action.direction { case .zoomIn: zoomValue += 1.0 AccessibilityNotification.Announcement(zoomQuantity).post() case .zoomOut: zoomValue -= 1.0 AccessibilityNotification.Announcement(zoomQuantity).post() } } } }
-
7:18 - Add the accessibility zoom action with UIKit
import UIKit class ViewController: UIViewController { let zoomView = ZoomingImageView(frame: .zero) let imageView = UIImageView(image: UIImage(named: "tree")) override func viewDidLoad() { super.viewDidLoad() zoomView.isAccessibilityElement = true zoomView.accessibilityLabel = "Zooming Image View" zoomView.accessibilityTraits = [.image, .supportsZoom] zoomView.addSubview(imageView) view.addSubview(zoomView) } }
-
7:43 - Respond to accessibility zoom actions with UIKit
import UIKit class ZoomingImageView: UIScrollView { override func accessibilityZoomIn(at point: CGPoint) -> Bool { zoomScale += 1.0 let zoomQuantity = "\(Int(zoomValue)) x zoom" UIAccessibility.post(notification: .announcement, argument: zoomQuantity) return true } override func accessibilityZoomOut(at point: CGPoint) -> Bool { zoomScale -= 1.0 let zoomQuantity = "\(Int(zoomValue)) x zoom" UIAccessibility.post(notification: .announcement, argument: zoomQuantity) return true } }
-
10:10 - Use accessibility direct touch options
import SwiftUI struct KeyboardKeyView: View { var soundFile: String var body: some View { Rectangle() .fill(.white) .frame(width: 35, height: 80) .onTapGesture(count: 1) { playSound(sound: soundFile, type: "mp3") } .accessibilityDirectTouch(options: .silentOnTouch) } }
-
10:46 - Use accessibility direct touch options with UIKit
import UIKit class ViewController: UIViewController { let waveformButton = UIButton(type: .custom) override func viewDidLoad() { super.viewDidLoad() waveformButton.accessibilityTraits = .allowsDirectInteraction waveformButton.accessibilityDirectTouchOptions = .silentOnTouch waveformButton.addTarget(self, action: #selector(playTone), for: .touchUpInside) view.addSubview(waveformButton) } }
-
12:21 - Set the accessibility content shape
import SwiftUI struct ImageView: View { var body: some View { Image("circle-red") .resizable() .frame(width: 200, height: 200) .accessibilityLabel("Red") .contentShape(.accessibility, Circle()) } }
-
13:35 - Update accessibility values using block-based setters with UIKit
import UIKit class ViewController: UIViewController { var isFiltered = false override func viewDidLoad() { super.viewDidLoad() // Set up views zoomView.accessibilityValueBlock = { [weak self] in guard let self else { return nil } return isFiltered ? "Filtered" : "Not Filtered" } } }
-
-
찾고 계신 콘텐츠가 있나요? 위에 주제를 입력하고 원하는 내용을 바로 검색해 보세요.
쿼리를 제출하는 중에 오류가 발생했습니다. 인터넷 연결을 확인하고 다시 시도해 주세요.