# 픽셀보다 계약: UIKit·Jetpack Compose 스도쿠 보드 접근성

> 하나의 9×9 커스텀 보드를 VoiceOver와 TalkBack이 탐색할 수 있는 81개 셀로 바꾸고, 위치·값·선택·오류·입력 동작을 두 플랫폼에 매핑하는 기준을 정리합니다.

- Canonical: https://blog.jaemyeong.com/posts/adaptive-accessible-sudoku-board/
- Published: 2026.08.06
- Category: 기술
- Tags: #Accessibility, #UIKit, #Jetpack Compose, #iOS, #Android

스도쿠 보드를 구현할 때 픽셀만 보면 문제는 단순하다. 정사각형 하나를 그리고 9×9로 나눈 뒤 숫자와 메모를 올리면 된다. 손가락 좌표도 행과 열로 환산할 수 있다. 그러나 화면을 보지 않고 사용하는 사람에게 이 보드는 정사각형 한 장일 뿐이다. 어느 셀인지, 값이 무엇인지, 선택됐는지, 어떻게 활성화하는지 알 수 없다.

DailySudoku의 iOS와 Android 보드는 각각 UIKit custom drawing과 Jetpack Compose를 사용한다. 두 구현을 2026년 8월 6일 소스 기준으로 비교해 보니, 공통으로 가져가야 할 것은 픽셀이 아니라 **셀의 의미 계약**이었다. 이 글은 현재 구현에서 잘 된 부분뿐 아니라 아직 자동 테스트와 실기기에서 증명하지 못한 부분도 함께 다룬다. 접근성 지원 완료 선언이 아니라, 완료 여부를 판단할 수 있는 설계와 검증 기준이다.

## 보드는 하나지만 접근성 트리는 81개 셀이어야 한다

시각적 렌더링 단위와 접근성 탐색 단위는 같을 필요가 없다. UIKit에서는 하나의 `UIView`가 보드 전체를 그릴 수 있고, Compose에서는 하나의 제스처 컨테이너가 보드 전체의 tap과 drag를 처리할 수 있다. 그래도 VoiceOver와 TalkBack에는 각 칸이 독립적인 요소로 보여야 한다.

셀 하나가 최소한 답할 수 있어야 하는 질문은 다음과 같다.

| 질문 | 의미 계약 |
| --- | --- |
| 어디인가 | 1부터 시작하는 행과 열 |
| 무엇이 들어 있는가 | 빈 셀, 주어진 숫자, 사용자 입력, 오류, 메모 목록 |
| 현재 상태는 무엇인가 | 선택 여부와 필요한 추가 상태 |
| 무엇을 할 수 있는가 | 활성화하면 해당 셀을 선택 |
| 어디에 있는가 | 화면에 그려진 셀과 일치하는 접근성 영역 |

`game.cell.r0c0` 같은 identifier나 test tag는 자동화에 유용하지만 사용자 설명이 아니다. 지역화되지 않은 selector를 읽어 주는 대신, “2행 7열, 값 4”처럼 위치와 내용을 짧게 조합해야 한다.

## 공유할 것은 UI가 아니라 셀의 의미다

DailySudoku의 두 플랫폼은 코어 상태를 `CellUi`에 가까운 동일한 렌더 모델로 투영한다.

```text
CellUi = value + given + error + notes + highlight
```

여기서 중요한 점은 UIKit과 Compose가 같은 뷰 계층을 만들도록 강제하지 않는 것이다. 같은 셀 모델에서 각 플랫폼에 맞는 세 가지 결과를 만든다.

1. 화면에 그릴 색과 숫자
2. 포인터 좌표를 셀 index로 바꾸는 geometry
3. 스크린리더에 전달할 설명·상태·동작

셀을 선택하는 입력은 마지막에 같은 `selectCell(index)` 동작으로 합쳐진다. 손가락 tap, 보드 위 drag, VoiceOver 활성화, TalkBack double-tap이 서로 다른 상태 변경 경로를 만들지 않게 하는 것이다. 접근성 전용 비즈니스 로직을 따로 두면 시각 UI와 상태가 어긋나기 쉽다.

## UIKit에서는 그리지 않은 81개 가상 셀을 만든다

iOS 보드는 81개의 `UIView`를 배치하지 않고 하나의 `BoardView`가 배경, 격자, 숫자, 메모를 직접 그린다. Apple은 이런 non-view 항목을 보조 기술에 노출할 때 컨테이너가 항목마다 `UIAccessibilityElement`를 만들 수 있다고 안내한다.

현재 구현은 셀 81개를 행 우선 순서로 만들고 다음 정보를 갱신한다.

- `accessibilityLabel`: 행·열과 empty/given/error/value/notes 중 하나
- `accessibilityTraits`: 모든 셀은 button, 현재 셀은 selected 추가
- `accessibilityFrameInContainerSpace`: 실제 셀 사각형
- `accessibilityActivate()`: 기존 `onSelect(index)` 호출

레이아웃이 바뀔 때 frame도 다시 계산한다. 화면 회전이나 Split View로 보드 크기가 달라졌는데 접근성 frame만 이전 위치에 남는 문제를 피하기 위해서다. `accessibilityFrameInContainerSpace`를 쓰면 컨테이너 좌표계의 셀 사각형을 그대로 전달할 수 있다.

여기서 순서는 구현 세부 사항이 아니라 탐색 계약이다. 배열을 0부터 80까지 생성하면 현재 구조에서는 행 우선 순서가 되지만, VoiceOver로 좌우 swipe와 전체 읽기를 직접 실행해 의도한 순서인지 확인해야 한다. 보드가 갱신된 뒤 현재 포커스의 값 변화가 즉시 읽히는지도 아직 실기기 검증이 필요하다.

## Compose에서는 보드 제스처와 셀 semantics를 분리한다

Android 보드는 한 컨테이너가 포인터 좌표를 9×9 index로 바꾼다. tap뿐 아니라 손가락을 움직이며 셀을 훑는 drag도 같은 hit test를 사용하고, 처리한 포인터 변화는 소비해 바깥 스크롤과 충돌하지 않게 한다.

하지만 raw `pointerInput`은 버튼이 제공하는 접근성 의미를 자동으로 만들지 않는다. Android 공식 문서도 `Button`, `clickable`, `pointerInput` 순으로 내려갈수록 기본 semantics와 focus 지원이 줄어든다고 설명한다. 그래서 각 `BoardCell`은 포인터 처리와 별개로 다음 semantics를 제공한다.

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

이 분리는 두 요구를 함께 만족시킨다. 일반 touch에서는 보드가 끊김 없는 drag 선택을 처리하고, TalkBack에서는 포커스된 셀의 double-tap이 같은 선택 동작을 실행한다. 셀마다 `clickable`과 보드 전체 `pointerInput`을 겹쳐 놓고 어느 쪽이 이벤트를 소유하는지 경쟁시키지 않는다.

다만 현재 Android 구현에는 `selected`나 `stateDescription`이 없다. 선택 배경색은 바뀌지만 TalkBack은 그 상태를 알 수 없다. `collectionInfo`와 `collectionItemInfo`도 없어 9행 9열 그리드라는 구조는 각 셀의 텍스트 설명으로만 전달된다. Compose 공식 문서가 custom grid에 collection semantics를 제공하는 이유도 보조 기술이 전체 크기와 현재 항목의 위치를 알 수 있게 하기 위해서다.

따라서 두 플랫폼의 현재 계약은 완전히 대칭이 아니다.

| 항목 | UIKit | Compose |
| --- | --- | --- |
| 독립 셀 81개 | 가상 `UIAccessibilityElement` | 셀별 semantics node |
| 위치·내용 설명 | 있음 | 있음 |
| 사용자 입력 출처 | 숫자 값만 읽음 | input으로 구분 |
| button 역할과 활성화 | 있음 | 있음 |
| 선택 상태 | `.selected` trait | 없음 |
| 9×9 collection 구조 | 없음 | 없음 |
| 자동화 selector | 별도 identifier | 별도 test tag |

첫 수정 후보는 새로운 추상화가 아니라 Android 셀에 선택 상태를 노출하고 두 플랫폼에서 실제 발화 결과를 확인하는 일이다. 9×9 collection semantics도 작은 후속 후보지만, 행·열을 이미 포함한 설명과 중복해서 어떻게 읽히는지 TalkBack에서 확인해야 한다. custom rotor 같은 별도 탐색 수단은 81개 선형 탐색이 실제 사용자에게 불편하다는 근거가 생길 때 추가하면 된다.

## 화면 크기가 달라도 유지할 것은 geometry 계약이다

adaptive layout은 모든 플랫폼에서 같은 숫자를 쓰는 일이 아니다. iOS 보드는 가용 영역 안에서 정사각형을 유지하고 최대 560pt로 제한한다. Android는 보드를 최대 520dp로 제한하며, 화면 너비 840dp 이상에서는 조작부와 나란히 놓고, 폴더블 tabletop 자세에서는 hinge를 기준으로 위아래 영역을 나눈다.

숫자는 달라도 다음 불변 조건은 같다.

- 화면에 그린 9×9 셀과 hit test의 행·열이 일치한다.
- 접근성 element 또는 semantics node의 행·열도 같은 index를 가리킨다.
- 회전, 창 크기 변경, 폴더블 자세 변경 뒤 geometry를 다시 계산한다.
- 일시정지나 완료 modal이 나타나면 뒤의 보드를 접근성 탐색에서 제외한다.

iOS의 modal overlay는 modal 접근성 영역으로 표시하고, Android는 뒤 컨텐츠의 semantics를 지운다. 화면에 보이는 overlay 뒤로 포커스가 빠져나가지 않게 하는 것도 보드 계약의 일부다.

## 9×9 보드와 권장 target 크기의 긴장을 숨기지 않는다

한 화면에 9개 셀을 나란히 놓으면 휴대폰에서 각 셀이 작아진다. iOS HIG의 현재 표는 iOS·iPadOS control의 기본 크기를 44×44pt, 최소 크기를 28×28pt로 제시한다. Android Compose 지침은 상호작용 요소에 48dp 최소 크기를 권장한다.

현재 iOS 보드는 폭이 396pt보다 작으면 셀 한 변이 44pt보다 작아진다. Android compact 360dp 화면에서는 코드 산술상 셀 한 변이 약 34dp다. 특히 Android 셀은 `clickable`이 아니라 수동 semantics를 쓰므로 Compose가 작은 clickable target을 자동 확장해 주는 경로에도 기대지 않는다.

그렇다고 81개 접근성 frame을 기계적으로 44pt나 48dp로 키우면 인접 target이 겹친다. 어느 셀이 선택될지 더 모호해질 수 있다. 먼저 실제 기기에서 VoiceOver와 TalkBack focus 영역, touch exploration, 일반 손가락 오입력을 함께 측정해야 한다. 권장 크기를 만족해야 한다면 보드 확대, 행·박스 단위 탐색, 대체 입력처럼 겹치지 않는 방식이 제품 선택지가 된다. 이 글의 코드 조사만으로 최적안을 확정할 수는 없다.

고정 비율로 그린 숫자와 메모가 Dynamic Type을 따르지 않는 iOS의 현재 상태도 별도 검증 항목이다. 보드 geometry를 유지하면서 확대된 텍스트를 어디에 어떻게 제공할지는 layout만의 문제가 아니라 정보 접근 방식의 문제다.

## 자동 검사는 semantics를, 실기기는 경험을 확인한다

현재 테스트는 셀 상태 투영, 좌표 hit test, drag 경계, 일부 색 대비와 폴더블 영역을 검사한다. 그러나 iOS의 81개 가상 요소와 Android 셀 semantics를 직접 고정하는 테스트는 없다. 스크린리더 계약을 회귀 방지하려면 다음 순서가 작다.

| 검증 | 기대 결과 |
| --- | --- |
| playing 상태의 노드 수 | 정확히 81개 |
| 첫 셀과 마지막 셀 | 1행 1열, 9행 9열 |
| 셀 내용 변형 | given/error/notes/empty와 일반 값 설명이 각각 맞음 |
| 선택 상태 | 선택 셀만 상태가 전달됨 |
| 활성화 | 정확히 한 번 `selectCell(index)` 호출 |
| resize 후 frame | 화면 셀과 접근성 영역이 다시 일치 |
| modal 표시 | 뒤의 보드가 탐색되지 않음 |
| Compose collection | 전체 9×9와 현재 행·열 정보가 전달됨 |

자동화 selector 존재만 확인해서는 부족하다. Compose UI test는 semantics property와 action을 검사하고, UIKit unit test는 element의 label·traits·frame·activation을 검사할 수 있다. 그 뒤 실제 iPhone과 Android 기기에서 다음을 수동으로 확인한다.

- 좌우 swipe 순서와 행 경계가 자연스러운가
- touch exploration으로 가리킨 칸과 읽는 칸이 같은가
- double-tap 뒤 포커스가 사라지거나 엉뚱한 셀로 이동하지 않는가
- 숫자, 메모, 오류가 바뀌었을 때 새 상태를 알 수 있는가
- 화면을 보지 않고 선택, 입력, 수정, 완료까지 수행할 수 있는가

Apple도 VoiceOver를 켜고 모든 요소의 접근 가능 여부, 탐색 순서, 시각 정보에 의존하는 작업을 직접 감사하라고 안내한다. Android의 Compose 접근성 검사도 작은 target, traversal order 같은 공통 결함을 찾지만, 문서상 자동 검사는 수동 경험 검증을 대체하지 않는다.

## 정리

접근 가능한 스도쿠 보드의 핵심은 81개의 뷰를 만드는 것이 아니다. 하나의 셀 상태와 geometry에서 화면, 포인터 입력, 스크린리더 설명, 활성화 동작을 일관되게 투영하는 것이다. UIKit의 가상 `UIAccessibilityElement`와 Compose의 셀 semantics는 서로 다른 API지만 같은 계약을 구현할 수 있다.

현재 DailySudoku는 두 플랫폼 모두 81개 셀의 위치·내용·활성화를 노출한다. iOS는 선택 상태까지 제공하지만 Android는 아직 그렇지 않고, 두 플랫폼 모두 grid 구조, target 크기, 상태 변화 발화, 실기기 순회를 더 검증해야 한다. 픽셀을 같게 만드는 일보다 이 차이를 측정하고 닫는 일이 접근성 parity에 가깝다.

## 출처

- [Apple Developer Documentation — UIAccessibilityElement](https://developer.apple.com/documentation/uikit/uiaccessibilityelement)
- [Apple Developer Documentation — accessibilityFrameInContainerSpace](https://developer.apple.com/documentation/uikit/uiaccessibilityelement/accessibilityframeincontainerspace)
- [Apple Developer Documentation — Supporting VoiceOver in your app](https://developer.apple.com/documentation/uikit/supporting-voiceover-in-your-app)
- [Apple Human Interface Guidelines — VoiceOver](https://developer.apple.com/design/human-interface-guidelines/voiceover)
- [Apple Human Interface Guidelines — Accessibility](https://developer.apple.com/design/human-interface-guidelines/accessibility)
- [Android Developers — Accessibility in Jetpack Compose](https://developer.android.com/develop/ui/compose/accessibility)
- [Android Developers — Semantics](https://developer.android.com/develop/ui/compose/accessibility/semantics)
- [Android Developers — Understand gestures](https://developer.android.com/develop/ui/compose/touch-input/pointer-input/understand-gestures)
- [Android Developers — API defaults](https://developer.android.com/develop/ui/compose/accessibility/api-defaults)
- [Android Developers — Testing accessibility in Compose](https://developer.android.com/develop/ui/compose/accessibility/testing)
