아티클

SwiftUI와 접근성 5부 - 초점 순서와 초점 감지

엔비전스 접근성 2024-01-15 10:03:30

안녕하세요 엔비전스입니다.

오랜만에 SwiftUI와 접근성으로 찾아뵙게 되었습니다. 접근성과 관련된 굵직한 API들을 꽤 많이 다룬 것 같습니다.

오늘은 지난 시간에 이어 초점에 관련된 아티클입니다. 지난 아티클에서는 FocusState 래퍼와 focused 수정자, AccessibilityFocusState 래퍼와acccessibilityFocused 수정자, 그리고 초점을 하나로 합치는 방법, 커스텀 액션에 관해 배웠습니다. 이번에는 SwiftUI에서 VoiceOver의 초점 흐름을 어떻게 바꾸는지 다뤄보겠습니다.

accessibilitySortPriority(_:), 너 그런 녀석이였어?

SwiftUI로 접근성을 구현하다 보면 ESC 키를 통해 사용할 수 있는 수정자들을 보고 고르는 습관이 생깁니다. 간혹 accessibility까지 수정자 이름을 쳤는데, 무슨 수정자를 쓸지 갑자기 기억이 안 나거나 스펠링이 생각 안 날 때가 있지요? Xcode에서 ESC 키를 누르는 것이 기본 자동완성 단축키입니다.

평소에 접근성 관련 아티클을 써오면서 이런 수정자 이름들을 보다 보면, 예상이 가는 이름도 있고, 그렇지 않은 것들도 있습니다. 대표적으로 View.accessibilitySortPriority()가 그렇습니다. Sort라는 단어를 보면 정렬, Priority는 우선순위라는 뜻인데, 처음에는 이게 초점과 관련된 것임을 쉽게 생각하지 못했습니다. 말 그대로, 접근성 초점의 정렬 순서 우선순위를 지정하는 수정자입니다.

이래서 머리가 나쁘면 몸이 고생합니다! 영어와 그들의 문화를 모르니 바로 이해가 안 됐던 것이지요.

사실 저는 SwiftUI에 접근성 순서 재정렬이 필요한가 싶었습니다. 왜냐하면, 대체로 선언형 UI는 명령형 UI보다 초점 순서를 예측하기 쉽기 때문입니다. 순서대로만 올바르게 작성하더라도 초점 순서 문제를 피할 수 있는 것이지요.

그래서 어떨 때 사용해야 하는데요?

그렇지만 의도치 않은 상황은 항상 존재합니다. 큰 프로젝트를 만들다가, 초점 순서가 내가 예상했던 것과 다르거나, 눈에 보이는 순서와 다르게 초점이 가게끔 만들어야 할 때가 있습니다. 특히 Android에서 많이 쓰이는 컴포넌트 형태인 부동 단추(이하, 플로팅 버튼)가 대표적입니다. iOS로 치면 대표적으로 Assestive Touch의 떠다니는 동그란 버튼을 생각하시면 이해가 빠르시겠습니다.

이렇게 헬륨 풍선처럼 둥둥 떠다니는 버튼은 스크롤과 관계없이 특정 화면 위치에 둥둥 떠 있기 순서를 어떻게 할 지 난감할 때가 많습니다. 보통은 맨 끝에 배치되어 있지만, 이 플로팅 버튼이 주요 기능을 실행하는 버튼이면 스크린 리더 사용자 입장에서는 쓰기 썩 좋진 않습니다.

예를 들어, 메모나 이메일 앱에서 메모 항목이나 저에게 온 이메일이 수백 개 있다고 생각해 봅시다. 그걸 다 보고서야 플로팅 버튼에 접근할 수 있게 되지요. 만약에, 메일이 수백 통 쌓인 이메일 앱에서 새로 작성하기 버튼이 맨 끝에 플로팅 버튼 형태로 있다면 참으로 끔찍할 것입니다.

물론, ⌞임의 탐색은 장식입니까?⌝라고 주장을 할 수 있습니다. 눈이 보이는 사람 입장에서는 임의 탐색이 당연히 빠르고 편하고 효율적입니다. 그런데, 눈이 아예 보이지 않는다면, 공간 감각이 상대적으로 덜 발달합니다. 이런 사람에게는 임의 탐색이 상대적으로 매우 불편하게 느껴질 겁니다. 이런 사람들을 위해 주요 플로팅 버튼을 영역 처음에 배치하는 것은 충분히 고려해 볼만한 일이라고 봅니다.

또는 만에 하나, 이런 사례가 있을 수 있습니다. 각 항목을 가로로 배치하고 있지만, 읽는 순서는 세로로 읽어야 하는 사례가 있을 수 있죠. 1부터 9까지 숫자 아홉 개가 있다고 생각해 봅시다. 눈으로 보기에는 세로로 읽어야 하는 항목인데, 레이아웃으로는 HStack으로 선언되어 있다면 어떨까요? 3열 3행의 그리드 형태로 숫자가 배치되어 있는데, 숫자를 세로로 나열했다면요?

아마 스크린리더 사용자는, 1, 4, 7, 2, 5, 8, 3, 6, 9 순으로 탐색하게 될 것입니다. 이럴 때 우리는 accessibilitySortPriority() 수정자를 사용하여 순서를 올바르게 고칠 수 있습니다. 물론, 현명한 사람이라면 이런 일을 만들지 않고 VStack을 가로로 3개를 배치해서 순서대로 읽게 만들거나, LazyVGrid, LazyHGrid등을 사용하겠지요?

Android의 같은 선언형 UI인 Jetpack과는 다르게 SwiftUI는 기본적으로 HStack이나 VStack으로 감싸진 항목을 다 읽고 난 뒤에 다음 항목을 읽게 됩니다. 단순히 눈에 보이는 순서가 아니라 접근성 순서에 레이아웃이 매우 크게 관여합니다. Jetpack Compose의 접근성을 배우신 분이라면, VStack과 HStack은 기본적으로 isTraversalGroup이 걸려있는 상태라고 보시면 이해가 빠르실 겁니다.

이 수정자는 웹 접근성에서의 tabindex 속성이나 Jetpack의 isTraversalIndex와 유사하거나 거의 같은 기능이라고 보시면 됩니다. 어느 플랫폼이든 마찬가지로 가급적 이 콘텐츠는 흐름에 맞게 작성하고 이런 기술을 쓰는 상황을 만들지 않는 것이 최선입니다.

어떻게 사용하나요?

accessibilitySortPriority는 이름에서 알 수 있듯, tabindex나 isTraversalIndex와 달리 순서 자체를 정하는 게 아니라 초점 순서의 우선순위를 정하는 겁니다. 이 수정자가 요구하는 값은 Double이며, 높을수록 앞에 옵니다. sortPriority가 지정된 모든 요소는 지정하지 않은 요소보다 앞서 탐색됩니다.

import SwiftUI
struct FocusFlowPracticeView: View {
    
    var body : some View {
        VStack {
            Text("C").accessibilitySortPriority(0.1)
            Text("B").accessibilitySortPriority(0.2)
            Text("A").accessibilitySortPriority(0.3)
        }
    }
}

VStack에 C B A순으로 텍스트를 나열했습니다. 그리고, accessibilitySortPriority는 A가 가장 높고, C가 가장 낮게 설정했습니다. 보이는 순서와는 관계없이 스크린리더로는 아래서부터 위로 A, B, C 순으로 탐색하게 됩니다.

또한 HStack이나 VStack같은 레이아웃 요소도 이 accessibilitySortPriority를 지정하여 컨테이너의 순서를 통째로 지정할 수도 있습니다. 본문과 도구막대, 탭막대 순서가 꼬여었거나 할 때, 이 수정자를 사용하여 바로 대응할 수 있습니다.

계산하기 귀찮고 Android나 웹처럼 사용할 방법은 없나요?

네, 기본적으로는 없습니다. 대신에 커스텀 Modifier를 만들고 모디파이어를 extension으로 추가하여 비슷한 효과를 낼 수 있습니다 아래처럼요.

struct AccessibilityFlowIndexModifier: ViewModifier {
    var index:Double
    var of:Double
    init(_ index: Double, _ of: Double) {
        self.index = index
        self.of = of
    }
    func body(content: Content) -> some View {
        content.accessibilitySortPriority(abs(index-of))
    }
}

extension View {
    func accessibilityFocusFlowIndex(_ index:Double,_ of:Double)->some View {
        self.modifier(AccessibilityFlowIndexModifier(index,of))
    }
}

accessibilityElement와의 관계

index에 내가 원하는 순서를 넣고, 초점 개수를 of에 넣은 다음, of에서 index를 뺀 값을 abs로 절대값으로 변환하여 accessibilitySortPriority 수정자에서는 거꾸로 우선순위가 높게 지정합니다. 항목 개수값도 넣지 않으면 편하곘지만 이곳 저곳에 쓰이기에는 유연성이 떨어집니다.

아래는 아까 설명한 상황을 시나리오로 만든 예제입니다. 맨 위에는 내가 몇 번째 view에 있는지 표시되고, 아래에는 아홉개의 숫자가 세로 오름차로 나열되어 있습니다.

struct Page_FocusFlow: View {
    @AccessibilityFocusState var numberFocused:Int?
    var body: some View {
        VStack {
            Text("현재 초점의 기본 초점 순서: \(numberFocused ?? -1)")
            VStack {
                HStack {
                    Text("1").accessibilityFocusFlowIndex(0.1, 1).accessibilityFocused($numberFocused,equals: 1)
                    Text("4").accessibilityFocusFlowIndex(0.4, 1).accessibilityFocused($numberFocused,equals: 2)
                    Text("7").accessibilityFocusFlowIndex(0.7, 1).accessibilityFocused($numberFocused,equals: 3)
                }
                HStack {
                    Text("2").accessibilityFocusFlowIndex(0.2, 1).accessibilityFocused($numberFocused,equals: 4)
                    Text("5").accessibilityFocusFlowIndex(0.5, 1).accessibilityFocused($numberFocused,equals: 5)
                    Text("8").accessibilityFocusFlowIndex(0.8, 1).accessibilityFocused($numberFocused,equals: 6)
                }
                HStack {
                    Text("3").accessibilityFocusFlowIndex(0.3, 1).accessibilityFocused($numberFocused,equals: 7)
                    Text("6").accessibilityFocusFlowIndex(0.6, 1).accessibilityFocused($numberFocused,equals: 8)
                    Text("9").accessibilityFocusFlowIndex(0.9, 1).accessibilityFocused($numberFocused,equals: 9)
                }
            }.accessibilityElement(children: .contain)
        }
        
    }
}

위 예제는 VStack 안에 한 개의 텍스트와 또 다른 하위 VStack이 있는 구조입니다. 하위 VStack에는 .accessibilityElement(children:.contain)이 걸려있는걸 볼 수 있습니다. accessibilitySortPriority는 접근성 컨테이너마다 다른 컨텍스트를 가집니다. 즉, 해당 요소 바깥 순서에 영향을 받지 않고 안에 있는 요소끼리 순서를 지정할 수 있습니다. VStack같은 레이아웃 컨테이너에서 accessibilityElement 수정자의 AccessibilityChildBehavior를 contain으로 지정하면 UIKit의 accessibilityContainer와 같은 역할을 합니다. 따라서, 바깥에서 초점 순서를 간섭받지 않고, 바깥에 있는 요소의 초점 순서에 관여하지 않게 됩니다.

만약에 저 하위 VStack이 접근성 컨테이너가 아니라면 각 텍스트에 걸린 정렬 우선순위로 인해 VStack 전에 있는 Text의 초점 우선순위가 밀려서 시각적으로 보이는 것과 다르게 숫자를 모두 탐색한 뒤에 맨 마지막에 초점이 이동하게 될 겁니다. 또 초점이 꼬이는 것이죠.

기본적으로 accessibilitySortPriority는 단어 뜻 그대로 초점이 가는 우선순위를 정하는 것이기 때문에 우선순위를 정하지 않은 요소보다 항상 먼저 탐색됩니다. 그래서, 접근성 컨테이너로 분리하지 않으면 위에 있는 Text에도 영향을 끼치게 되는겁니다. 이 점을 명심해야 합니다.

번외: AccessibilityFocusState의 또 다른 쓰임세

위 예제를 보면 AccessibilityFocusState를 사용한 것을 볼 수 있습니다. 그리고, VStack 전에 있는 Text 뷰에 해당 상태값을 표시하고 있습니다. 위 예제에서는 개발자가 초점 상태를 재할당하지 않고 VoiceOver 사용자가 초점을 지정된 뷰로 보낸 것 만으로도 해당 State가 변경되는 걸 관찰할 수 있습니다. 즉, 초점 변경을 감지할 수도 있다는 얘기가 됩니다. 또한, accessibilityFocusState는 초점을 당장 받을 수 없는 VStack과 같은 컨테이너에 적용 시, 해당 요소 안에 VoicOver 초점이 있는지 확인할 수도 있습니다.

저는 초점이 변경될 때마다, 각 뷰의 원래 순서를 위에 표시하고자 AccessibilityFocusState를 사용했습니다. 해당 영역에 초점이 이동되었을 때 onChange 수정자를 사용하여 웹에서의 focusIn 이벤트와 같은 효과도 낼 수 있을 것으로 기대됩니다. 또한 텍스트 필드의 키보드 초점을 관리하는 FocusState도 같은 특성을 가질 것으로 예샹됩니다.

이번 아티클은 여기까지입니다. 읽어주셔서 감사합니다. 내년에도 더 좋은 아티클로 찾아뵐 수 있도록 노력하겠습니다.

댓글 0
댓글을 작성하려면 해주세요.