필자가 회사에서 업무를 진행하다 보면, 하단 네비게이션이나 툴바의 작은 아이콘들이 디자인적으로 깔끔하긴 한데 시력이 약한 사용자에게는 잘 안 보일 수 있겠다는 생각이 들 때가 있다. 특히 아이콘은 시스템 폰트 크기 설정과 무관하게 그려지기 때문에, Dynamic Type만으로는 보완이 잘 안 되는 부분이다.
iOS에서는 이런 문제를 위해 Large Content Viewer라는 기능을 제공한다. 작은 아이콘이나 버튼을 길게 누르면 확대된 프리뷰가 화면에 표시되어, 어떤 아이콘인지 확인할 수 있도록 도와준다.
최근 안드로이드 관련 기술 블로그를 확인하다가 eevis.codes의 "Adding navigation support to Large Content Viewer with Compose" 글을 보게 되었고, 이 패턴이 단순히 시각 보조뿐 아니라 키보드·스크린 리더·Voice Access 사용자 모두를 위한 종합적인 접근성 패턴으로 확장될 수 있다는 것을 알게 되었다.
이번 글에서는 Jetpack Compose로 Large Content Viewer를 구현하고, 거기에 키보드 내비게이션과 스크린 리더(TalkBack) 지원까지 추가해 4가지 입력 방식을 모두 지원하는 방법을 알아볼 예정이다.
왜 이런 기능이 필요한가
먼저 이 기능이 왜 필요한지 짧게 정리해보자.
- 시력이 약한 사용자에게 작은 UI를 식별할 수 있는 보조 수단을 제공
- 탭 바·네비게이션 바의 아이콘은 시스템 폰트 크기 설정에 영향받지 않음
- Dynamic Type으로 크기 조정이 불가능한 아이콘에 대한 보완책
그리고 한 가지 중요한 점은, 같은 기능을 입력 방식에 따라 다르게 제공해야 한다는 것이다. 터치만 가능한 게 아니다. 키보드 사용자도 있고, 스크린 리더로 화면을 탐색하는 사용자도 있고, 음성 명령으로 앱을 조작하는 사용자도 있다. 그래서 단순히 "길게 누르면 보여주기"만으로는 부족하다.
1. Long-Press 프리뷰 (터치 사용자)
가장 먼저 가장 직관적인 long-press부터 구현해보자. detectTapGestures의 onLongPress로 처리한다.
var previewedItem by remember { mutableStateOf<NavItem?>(null) }
Column(
modifier = Modifier
.pointerInput(item.label) {
detectTapGestures(
onLongPress = {
previewedItem = item // 길게 누르면 프리뷰 표시
},
onPress = {
awaitRelease()
previewedItem = null // 손가락 떼면 프리뷰 해제
},
onTap = {
selectedIndex = index // 짧게 누르면 일반 클릭
}
)
}
)
여기서 주목할 부분은 onPress 안의 awaitRelease()이다. onLongPress는 트리거 시점만 알려주고 손가락이 언제 떨어졌는지는 모르기 때문에, onPress 블록에서 release를 기다린 뒤 프리뷰를 해제해야 한다. 처음에는 이 부분을 놓쳐서 프리뷰가 화면에 계속 떠 있는 문제가 있었다.
그리고 프리뷰 자체는 AnimatedVisibility + scaleIn + fadeIn으로 자연스럽게 등장시킨다.
AnimatedVisibility(
visible = previewedItem != null,
enter = scaleIn(spring(dampingRatio = Spring.DampingRatioMediumBouncy)) + fadeIn(),
exit = scaleOut() + fadeOut()
) {
previewedItem?.let { item ->
// 큰 카드 + 큰 아이콘 + 라벨
}
}
2. 키보드 내비게이션 (Tab 키 사용자)
키보드 사용자는 long-press를 할 수 없다. Tab 키로 포커스를 이동하기 때문에, "포커스가 일정 시간 머무르면 프리뷰를 표시"하는 방식으로 long-press를 대체해야 한다.
핵심 API는 onFocusChanged 모디파이어이다. 포커스 상태 변화를 받아서 코루틴으로 일정 시간 delay 후 프리뷰를 표시한다.
val scope = rememberCoroutineScope()
val viewConfiguration = LocalViewConfiguration.current
navItems.forEach { item ->
var focusJob by remember { mutableStateOf<Job?>(null) }
Column(
modifier = Modifier
.onFocusChanged { focusState ->
if (focusState.isFocused) {
focusJob = scope.launch {
// 시스템의 long-press 시간만큼 대기
delay(viewConfiguration.longPressTimeoutMillis)
previewedItem = item
}
} else {
// 포커스 떠나면 Job 취소 + 프리뷰 해제
focusJob?.cancel()
focusJob = null
previewedItem = null
}
}
.focusable()
)
}
여기서 두 가지 신경 써야 할 부분이 있다.
1. viewConfiguration.longPressTimeoutMillis 사용
딜레이 값을 임의로 500ms 같은 식으로 하드코딩하지 말고, LocalViewConfiguration.current.longPressTimeoutMillis를 사용해야 한다. 사용자가 시스템 설정에서 long-press 시간을 바꿔놓았을 수 있기 때문이다. 시스템 설정을 존중하는 것이 접근성의 기본이라는 점에서 매우 중요하다.
2. 포커스가 떠나면 Job을 반드시 취소
포커스가 빠르게 옮겨다닐 때, 이전 포커스의 코루틴이 살아 있으면 엉뚱한 프리뷰가 떠버린다. focusJob?.cancel()로 명시적으로 취소해 줘야 한다. 처음에는 이걸 놓쳐서 포커스가 다른 아이템으로 옮겨갔는데도 이전 아이템의 프리뷰가 뜨는 문제를 겪었다.
3. 스크린 리더 (TalkBack) 지원
스크린 리더 사용자에게는 long-press나 포커스 딜레이 같은 시간 기반 인터랙션이 적절하지 않다. 그래서 Custom Accessibility Action이라는 별도 메커니즘을 제공한다. TalkBack 메뉴에서 사용자가 의도적으로 액션을 트리거할 수 있는 방식이다.
Column(
modifier = Modifier
.semantics {
customActions = listOf(
CustomAccessibilityAction(
label = "Preview ${item.label}", // TalkBack 메뉴에 표시되는 이름
action = {
scope.launch {
previewedItem = item
}
true // 액션이 성공적으로 처리됨을 알림
}
)
)
}
.clickable { /* 일반 클릭 동작 */ }
)
여기서 한 가지 깨달은 점은, 스크린 리더 사용자가 모두 시각 장애인은 아니라는 사실이다. 시력은 스펙트럼이고, 일부만 시력이 약하거나 특정 상황에서만 스크린 리더를 사용하는 사람도 많다. 그래서 단순히 "안 보이니까 음성으로 읽어주면 끝"이 아니라, 프리뷰 같은 시각적 보조도 함께 제공하는 것이 좋은 접근법이라고 한다.
또한 스크린 리더 액션에는 delay를 주지 않는다는 점도 중요하다. 키보드는 "포커스가 머무는 시간"을 long-press의 대체로 사용하지만, 스크린 리더는 사용자가 메뉴에서 직접 액션을 선택하기 때문에 그 자체가 의도적 행위라서 즉시 트리거해야 한다.
4. Voice Access는 자동 지원
Voice Access(음성 명령으로 UI를 조작하는 보조 기술)는 별도 작업이 필요 없다. pointerInput으로 long-press를 이미 구현해 두었다면, 사용자가 "Long press Home" 같은 음성 명령을 사용했을 때 시스템이 자동으로 해당 제스처를 트리거해 준다.
다만 이게 잘 작동하려면 아이콘의 contentDescription이 정확히 설정되어 있어야 한다.
Icon(
imageVector = item.icon,
contentDescription = item.label, // Voice Access가 이 이름으로 식별
modifier = Modifier.size(24.dp)
)
접근성 작업의 좋은 점은, 한 종류를 잘 만들어두면 다른 종류까지 함께 개선되는 효과가 있다는 점인 것 같다.
4가지를 모두 통합한 최종 형태
마지막으로 위에서 다룬 4가지를 한 컴포저블에 모두 통합하면 다음과 같은 형태가 된다.
Column(
modifier = Modifier
// ① 키보드 사용자 — 포커스가 머무는 시간으로 트리거
.onFocusChanged { focusState ->
if (focusState.isFocused) {
focusJob = scope.launch {
delay(viewConfiguration.longPressTimeoutMillis)
previewedItem = item
}
} else {
focusJob?.cancel()
previewedItem = null
}
}
.focusable()
// ② 스크린 리더 사용자 — Custom Action
.semantics {
customActions = listOf(
CustomAccessibilityAction(
label = "Preview ${item.label}",
action = {
scope.launch { previewedItem = item }
true
}
)
)
}
// ③ 터치 사용자 (+ Voice Access 자동 지원) — Long-Press
.pointerInput(item.label) {
detectTapGestures(
onLongPress = { previewedItem = item },
onPress = {
awaitRelease()
previewedItem = null
},
onTap = { selectedIndex = index }
)
}
)
4가지 다른 Modifier가 한 컴포저블에 깔끔하게 합쳐진다는 점이 인상 깊다. 각 Modifier는 자기 영역만 책임지면서도 서로 충돌하지 않기 때문에, 패턴을 한 번 익혀두면 다른 컴포넌트에도 쉽게 적용할 수 있을 것 같다.
구현하면서 신경 써야 했던 부분
1. 시스템의 long-press 시간 존중
딜레이 값을 임의로 정하지 말고 LocalViewConfiguration.current.longPressTimeoutMillis를 써야 한다. 사용자가 접근성 설정에서 long-press 시간을 늘려놓은 경우, 우리가 임의로 짧게 잡아두면 그 설정을 무시하는 셈이 된다.
2. 포커스 떠날 때 Job 취소
키보드 내비게이션에서 가장 헷갈리는 부분이 이 부분이다. 포커스가 빠르게 이동하면 이전 코루틴이 살아남아 엉뚱한 프리뷰가 떠버린다. focusJob?.cancel()을 잊지 말아야 한다.
3. 스크린 리더에는 딜레이 없이
키보드와 달리 스크린 리더의 Custom Action은 사용자가 명시적으로 선택한 행위이므로, 즉시 반응해야 한다. delay를 넣으면 오히려 어색해진다.
4. onPress + awaitRelease로 프리뷰 해제
onLongPress는 시작만 알려준다. 끝나는 시점은 onPress 블록 안에서 awaitRelease()로 잡아야 한다.
마무리
이번 글에서는 iOS의 Large Content Viewer를 Jetpack Compose로 구현하고, 거기에 키보드 내비게이션·스크린 리더·Voice Access 지원까지 추가하는 패턴을 살펴보았다.
접근성은 보통 "추가 작업"으로 여겨지기 쉬운데, 이번 예제를 직접 만들어보면서 적절한 패턴을 따르면 그렇게까지 어렵지 않다는 것을 알게 되었다. 특히 onFocusChanged, semantics, pointerInput 같은 Modifier들이 각자 자기 영역을 잘 분담해주기 때문에, 코드 자체는 오히려 명확하게 분리되는 느낌이었다.
다음에는 이 패턴을 응용해서 툴바 버튼이나 이모지 피커 같은 다른 작은 UI 요소에도 동일한 접근성 패턴을 적용해보는 것을 한번 정리해 볼 예정이다.
필자도 아직 안드로이드의 접근성 API에 대해서는 공부 중이기 때문에 작성한 개념이나 설명에 오류가 있을 가능성이 있다는 점을 감안하고 읽어주었으면 좋겠다.
해당 게시글에 사용한 예제는 다음 Github에 올려두었다.
https://github.com/HeeGyeong/ComposeSample
GitHub - HeeGyeong/ComposeSample: This project provides various examples needed to actually use Jetpack Compose.
This project provides various examples needed to actually use Jetpack Compose. - HeeGyeong/ComposeSample
github.com
'Android > Jetpack Compose' 카테고리의 다른 글
| [Compose] 스피닝 휠에 모션 블러를 적용해보자 - Ghost Frames, BlurMaskFilter, RenderEffect 비교 (0) | 2026.06.28 |
|---|---|
| [Compose] 스티커 캔버스를 만들어보자 - 복합 제스처, Spring 물리, 필오프(Peel-Off) 애니메이션 (0) | 2026.06.14 |
| [Compose] Canvas로 원형 다이얼(Dial) 컴포넌트를 직접 구현해보자 (1) | 2026.06.07 |
| [Compose] Shared Element Transitions로 화면 간 요소를 부드럽게 전환해보자 (0) | 2026.06.03 |
| [Compose] Material 3의 SwipeToDismissBox를 구현해보자 (0) | 2026.05.25 |