스도쿠 81칸을 VoiceOver와 TalkBack에 알리는 방법

제가 만드는 스도쿠 앱 DailySudoku의 게임판은 숫자와 메모를 보여 주는 9×9 정사각형이에요. 화면을 보면서 손가락으로 누르는 사람에게는 터치 좌표만 칸 번호로 바꿔 주면 돼요. 하지만 VoiceOver나 TalkBack으로 쓰는 사람은 초점이 어느 칸에 있는지, 그 칸에 무엇이 들어 있는지, 선택된 칸인지, 어떻게 활성화하는지를 소리로 알아야 해요.

이 글은 2026-08-06 소스를 기준으로 iOS와 Android 구현을 나란히 비교한 내용이에요. 접근성을 다 맞췄다는 보고가 아니에요. 어떤 기준으로 설계했고 무엇이 아직 검증되지 않았는지를 적어요.

칸 하나가 알려야 하는 정보

화면은 iOS에서 UIView 하나, Android에서 제스처 컨테이너 하나로 구현했어요. 그래도 보조 기술에는 칸 81개를 하나씩 내보내요. 칸마다 필요한 정보는 이렇게 잡았어요.

  • 위치: 1부터 세는 행 번호와 열 번호
  • 내용: 빈칸, 처음부터 주어진 숫자, 사용자가 넣은 값, 오류, 메모
  • 상태: 선택 여부
  • 동작과 영역: 칸 선택, 그리고 화면의 실제 칸과 겹치는 사각형

game.cell.r0c0 같은 identifier나 test tag는 자동화 테스트가 칸을 찾을 때만 써요. 사용자가 듣는 설명은 이 표지와 분리해서, 위치와 내용을 담은 지역화된 문장으로 제공해야 해요. 예를 들면 2행 7열에 숫자 4가 있다는 정보를 담은 문장이에요.

상태는 한 모델에서, 입력은 한 함수로

코어 데이터는 CellUi 계열 모델로 옮겨서 써요. 모델이 담는 항목은 다섯 가지예요.

CellUi = value + given + error + notes + highlight

화면의 색과 숫자, 좌표를 index로 바꾸는 계산, 보조 기술이 읽을 설명·상태·동작이 모두 이 모델에서 나와요. 두 플랫폼의 뷰 계층을 똑같이 만들 필요는 없어요. 같은 모델에서 같은 정보가 나오면 돼요.

입력도 한곳으로 모았어요. tap, drag, VoiceOver 활성화, TalkBack double-tap은 모두 selectCell(index)에서 끝나요. 접근성 전용 로직을 따로 두면 화면 상태와 어긋날 수 있어서 피했어요.

iOS: 뷰 하나에 접근성 요소 81개

iOS에서는 BoardView 하나가 배경, 격자, 숫자, 메모를 직접 그려요. 칸마다 UIView를 두는 대신 UIAccessibilityElement 81개를 만들어요. Apple 문서가 뷰가 아닌 항목을 보조 기술에 노출하는 방법으로 안내하는 방식이에요.

요소마다 넣는 값은 다음과 같아요.

  • 배열 순서: 행 우선
  • accessibilityLabel: 좌표와, empty·given·error·value·notes 가운데 해당하는 설명
  • accessibilityTraits: button, 선택된 칸에는 selected 추가
  • accessibilityFrameInContainerSpace: 칸의 사각형
  • accessibilityActivate(): onSelect(index) 호출

화면을 회전하거나 Split View로 크기가 바뀌면 frame을 다시 계산해요. 값은 컨테이너 기준 좌표로 직접 넣어요.

확인하지 못한 것이 두 가지 있어요. index 0부터 80까지 만든 순서가 실제 VoiceOver의 swipe 순서나 전체 읽기 순서와 같은지는 따로 봐야 해요. 초점이 있는 칸의 값이 바뀌었을 때 VoiceOver가 곧바로 읽어 주는지도 검증하지 않았어요.

Android: pointerInput에는 버튼 의미가 없어요

Android에서는 컨테이너 좌표를 9×9 index로 환산하고, tap과 drag에 같은 hit test를 적용해요. 처리한 포인터 변경은 소비해서 바깥 스크롤과 간섭하지 않게 했어요.

문제는 pointerInput만으로는 TalkBack이 칸을 버튼으로 다루지 않는다는 점이에요. Compose의 제스처 처리 단계 문서는 Button, clickable, pointerInput 순서로 내려갈수록 기본으로 제공되는 semantics와 focus 지원이 줄어든다고 설명해요. 그래서 BoardCell마다 설명과 역할, 클릭 동작을 직접 붙였어요.

각 BoardCell에 붙이는 semantics예요.

Modifier.semantics {
    contentDescription = cellDescription
    role = Role.Button
    onClick {
        onSelect(index)
        true
    }
}

일반 터치의 연속 drag는 보드가 맡고, TalkBack에서 초점이 잡힌 칸의 활성화는 semantics가 맡아요. 셀에 clickable을 달면 보드의 pointerInput과 같은 이벤트를 두고 경쟁하게 되는데, 이 구성은 그 경쟁을 피해요.

두 플랫폼이 아직 다른 곳

소스를 비교한 결과를 표로 옮기면 이래요.

항목 iOS Android
칸별 독립 노출 있음 있음
위치·내용 설명 있음 있음
버튼 활성화 있음 있음
자동화 표지 분리 있음 있음
사용자 입력 값 숫자로만 읽음 input으로 구별
선택 상태 .selected 없음
collection 구조 없음 없음

Android에는 selected나 stateDescription이 없어요. 선택한 칸의 배경색은 바뀌지만 TalkBack에는 선택 상태가 전달되지 않아요. collectionInfo와 collectionItemInfo도 쓰지 않아요. Compose semantics 문서가 설명하는 collection 정보는 전체 크기와 현재 위치를 알려 주는 용도인데, 지금은 양쪽 모두 9행 9열 구조를 칸의 설명 문장으로만 전달해요.

다음 순서는 이렇게 정했어요. Android에 선택 상태를 넣고 양쪽 발화를 직접 들어 보는 일이 먼저예요. 9×9 collection 정보는 지금의 행·열 설명과 겹쳐서 두 번 읽히지 않는지 확인한 뒤에 검토해요. custom rotor는 81칸을 한 줄로 넘기는 탐색이 실제로 불편하다는 근거가 생기면 고려해요.

칸이 작다는 문제

보드 크기에는 상한을 뒀어요. iOS는 560pt, Android는 520dp예요. Android는 너비가 840dp 이상이면 보드와 조작부를 가로로 배치하고, 폴더블 tabletop 자세에서는 hinge를 기준으로 위아래 영역을 나눠요.

어느 크기에서든 그림, hit test, 접근성 칸은 같은 index를 가리켜야 해요. 회전, 창 크기 변경, 폴더블 자세 변화가 있으면 다시 계산해요. 일시정지나 완료 modal이 떠 있는 동안에는 뒤의 보드를 탐색 대상에서 빼요. iOS는 overlay를 modal 접근성 영역으로 두고, Android는 배경의 semantics를 제거해요.

걸리는 부분은 칸 크기예요. 접근성 HIG는 iOS와 iPadOS의 control 크기를 기본 44×44pt, 최소 28×28pt로 제시해요. Compose의 기본 접근성 동작 문서는 상호작용 요소에 최소 48dp를 권장해요. 한 줄에 9칸이니 iOS에서는 보드 폭이 396pt보다 작으면 칸 폭이 44pt에 못 미쳐요. Android는 compact 360dp 조건에서 코드로 계산한 칸이 약 34dp예요. 이 값은 기기에서 잰 것이 아니라 계산값이에요.

Android 칸은 semantics를 직접 붙였기 때문에 clickable이 해 주는 자동 target 확장에 기대지 않아요. 그렇다고 81칸의 영역을 44pt나 48dp로 한꺼번에 키우면 서로 겹쳐서 어느 칸을 고른 것인지 모호해질 수 있어요. 실제 기기에서 focus 영역과 touch exploration을 확인하고, 손가락으로 잘못 선택하는 빈도를 측정해야 판단할 수 있어요. 대안으로는 보드 확대, 행이나 박스 단위 탐색, 별도 입력 방식이 있는데 어느 쪽이 나은지 정할 근거는 아직 없어요.

글자 크기도 남아 있어요. iOS의 숫자와 메모는 칸에 대한 고정 비율로 그려서 Dynamic Type을 따르지 않아요. 큰 글자를 어디에 어떻게 보여 줄지는 추가로 확인해야 해요.

무엇을 테스트로 고정할지

지금 있는 검사는 셀 상태 투영, 좌표 판정, drag 경계, 색 대비 일부, 폴더블 영역까지예요. 가상 접근성 요소나 셀 semantics 자체를 고정하는 테스트는 없어요. 자동으로 고정하려는 항목은 다음과 같아요.

  • playing 상태에서 칸 수 81
  • 처음과 끝 좌표 (1,1)과 (9,9)
  • given, error, notes, empty, 일반 값 각각의 설명
  • 선택된 칸에만 선택 상태 노출
  • 활성화 한 번에 selectCell(index) 한 번
  • resize 뒤 영역 일치
  • modal이 뜬 뒤 보드 제외
  • Compose의 9×9 정보와 현재 행·열 정보

Compose 접근성 테스트 문서가 안내하는 UI test로 semantics 속성과 action을 확인하고, UIKit에서는 unit test로 label, traits, frame, activation을 확인할 수 있어요. selector로 요소를 찾을 수 있다는 사실만으로는 접근성이 증명되지 않아요.

기기에서 직접 볼 것도 정했어요. iPhone과 Android에서 swipe로 넘기다가 행이 바뀌는 지점, 손가락 위치와 읽어 주는 칸의 일치, double-tap 뒤의 초점 유지, 숫자·메모·오류가 바뀌었을 때의 인지를 확인해요. 마지막으로 화면을 보지 않고 선택, 입력, 수정, 완료까지 해 봐요.

Apple의 VoiceOver 지원 안내와 VoiceOver HIG는 VoiceOver를 켠 상태로 요소에 접근하고 순회하면서 시각에 의존하는 작업을 점검하라고 권해요. Compose 접근성 개요가 소개하는 자동 검사는 작은 target이나 traversal order 문제를 찾는 데 도움이 되지만, 직접 써 보는 확인은 따로 필요해요.

지금 소스로 말할 수 있는 것은 두 플랫폼이 칸을 따로 노출하고 같은 선택 함수로 모은다는 데까지예요. Android의 선택 정보, 양쪽의 grid 정보, target 크기가 적절한지, 상태가 바뀔 때의 발화, 실기기 전체 순회는 검증하지 못했어요. 그래서 두 플랫폼의 접근성이 같은 수준이 됐다고는 쓰지 않아요.