모바일 앱에서 스크린 리더 사용자에게 영역 정보 제공해 주기 2부: 안드로이드 Jetpack Compose
들어가며
안녕하세요, 엔비전스입니다.
모바일 앱에서 스크린 리더 사용자에게 영역 정보 제공해 주기 1부에서는 안드로이드 View 시스템에서 접근성 컨테이너를 구현하는 방법을 살펴보았습니다. containerTitle, CollectionInfo, CollectionItemInfo 등의 API를 활용하여 스크린 리더 사용자에게 명확한 영역 정보를 제공하는 방법이 그러한 것들입니다.
이번 2부에서는 Jetpack Compose 환경에서 동일한 접근성 기능을 구현하는 방법을 알아보겠습니다. Compose는 선언적 UI 프레임워크로서 View 시스템과는 다른 접근 방식을 제공하지만, 최종적으로는 동일한 접근성 목표를 달성할 수 있습니다.
Compose에서의 컨테이너 정보 제공 방법
Compose에는 다양한 UI 컴포넌트에 접근성 정보를 제공하는 방법이 있습니다. 먼저 TabRow, RadioButton, LazyColumn, HorizontalPager와 같은 표준 컴포넌트의 영역 정보 제공 방법을 살펴본 후, 커스텀 레이아웃에서의 접근성 구현을 다루겠습니다.
1. TabRow (탭) 영역 정보
Jetpack Compose의 TabRow는 Material3에서 제공하는 탭 네비게이션 컴포넌트입니다.
TabRow(
selectedTabIndex = selectedTabIndex,
modifier = Modifier.fillMaxWidth()
) {
tabTitles.forEachIndexed { index, title ->
Tab(
selected = selectedTabIndex == index,
onClick = { onTabSelected(index) },
text = { Text(title) }
)
}
}
TabRow의 자동 접근성 지원:
- Tab 컴포저블은 자동으로 Role.Tab semantics를 제공합니다
- TalkBack이 "과일, 탭, 3개 중 1번째" 형식으로 읽어줍니다
- selected 파라미터를 통해 현재 선택된 탭을 자동으로 인식합니다
- 별도의 CollectionInfo 설정 없이도 전체 개수와 현재 위치 정보를 제공합니다
탭 컨테이너에 추가 설명이 필요한 경우:
탭 영역이 어떤 종류의 탭인지 명확히 하려면 TabRow에 contentDescription을 추가할 수 있습니다.
TabRow(
selectedTabIndex = selectedTabIndex,
modifier = Modifier
.fillMaxWidth()
.semantics {
contentDescription = "카테고리 탭"
}
) {
tabTitles.forEachIndexed { index, title ->
Tab(
selected = selectedTabIndex == index,
onClick = { onTabSelected(index) },
text = { Text(title) }
)
}
}
이렇게 하면 탭 영역에 진입할 때 기본적으로 포커스 된 탭 정보를 출력한 후에 영역 정보도 함께 출력해 줍니다.
2. RadioButton (라디오 버튼) 영역 정보 및 SelectableGroup 활용
라디오 버튼 그룹은 selectableGroup modifier를 사용하여 쉽게 구현할 수 있습니다. SelectableGroup은 라디오 버튼뿐만 아니라, 여러 항목 중 하나를 선택하는 커스텀 탭이나 다른 UI를 만들 때도 유용하게 사용할 수 있습니다.
Column(
modifier = Modifier.selectableGroup()
) {
options.forEachIndexed { index, option ->
Row(
modifier = Modifier
.fillMaxWidth()
.selectable(
selected = selectedIndex == index,
onClick = { onOptionSelected(index) },
role = Role.RadioButton
)
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
RadioButton(
selected = selectedIndex == index,
onClick = null // 포커스 중복 방지를 위해 null로 설정 (아래 참고)
)
Text(option)
}
}
}
selectableGroup의 장점:
- TalkBack이 자동으로 "4개 중 1번째", "4개 중 2번째" 같은 위치 정보를 읽어줍니다
- 개발자가 수동으로 인덱스를 지정할 필요가 없습니다
- selectable modifier와 함께 사용하여 선택 상태도 자동으로 전달됩니다
- CollectionInfo를 수동으로 설정하는 것보다 간편합니다
TalkBack 출력: "선택됨, 봄, 라디오 버튼, 4개 중 1번째,"
SelectableGroup 하위 요소의 TalkBack 힌트
selectableGroup은 하위 요소의 role에 따라 TalkBack 사용자에게 다른 행동 유도 힌트를 제공합니다.
- role = Role.RadioButton인 경우:
- 선택되지 않은 항목: "전환하려면 두 번 탭하세요."
- 선택된 항목: 별도 힌트 없음.
- role = Role.Tab인 경우 (커스텀 탭 구현 시):
- 선택되지 않은 항목: "선택하려면 두 번 탭하세요."
- 선택된 항목: 별도 힌트 없음.
라디오 버튼 그룹에 추가 설명이 필요한 경우:
라디오 버튼 영역이 무엇을 선택하는 그룹인지 명확히 하려면 selectableGroup에 contentDescription을 추가할 수 있습니다. 이는 커스텀 탭을 만들 때도 동일하게 적용하여 영역 정보를 제공할 수 있습니다.
Column(
modifier = Modifier
.selectableGroup()
.semantics {
contentDescription = "좋아하는 계절 선택"
}
) {
options.forEachIndexed { index, option ->
Row(
modifier = Modifier
.fillMaxWidth()
.selectable(
selected = selectedIndex == index,
onClick = { onOptionSelected(index) },
role = Role.RadioButton
)
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
RadioButton(
selected = selectedIndex == index,
onClick = null
)
Text(option)
}
}
}
이렇게 하면 위에서 설명한 탭 영역 정보를 읽어주는 것과 동일한 방식으로 contentDescription 을 영역 정보로 취급되어 읽어줍니다.
3. LazyColumn과 HorizontalPager의 영역 정보
탭이나 selectableGroup과 마찬가지로 LazyColumn과 HorizontalPager 같은 표준 컴포넌트는 contentDescription을 추가하여 영역 정보를 제공할 수 있습니다.
LazyColumn (목록)
LazyColumn(
modifier = Modifier.semantics {
contentDescription = "과일 목록"
}
) {
items(fruits) { fruit ->
FruitItem(fruit = fruit)
}
}
결과:
- 첫 번째 항목: "사과, 5개 중 1번째, 과일 목록에 있음"
- 두 번째 항목: "바나나, 5개 중 2번째"
LazyColumn의 특징:
- 대량의 항목을 효율적으로 처리
- 자동으로 "몇 개 중 몇 번째" 위치 정보를 제공 (CollectionInfo 설정 불필요)
- contentDescription만 추가하면 영역 정보와 위치 정보 모두 제공
HorizontalPager (ViewPager 대응)
HorizontalPager는 스와이프 가능한 페이지 네비게이션을 제공합니다. 영역 정보는 동적 contentDescription으로 현재 페이지 정보를 제공할 수 있습니다.
val pagerState = rememberPagerState(pageCount = { categories.size })
HorizontalPager(
state = pagerState,
modifier = Modifier
.fillMaxWidth()
.semantics {
contentDescription = when (pagerState.currentPage) {
0 -> "과일 목록"
1 -> "채소 목록"
2 -> "음료 목록"
else -> "카테고리 목록"
}
}
) { page ->
CategoryContent(category = categories[page])
}
HorizontalPager의 특징:
- TalkBack이 "과일 목록", "채소 목록" 등 현재 페이지 정보를 읽어줌
- 주의: HorizontalPager는 "몇 개 중 몇 번째" 같은 위치 정보를 자동으로 제공하지 않음
4. 커스텀 레이아웃의 CollectionInfo 활용
앞서 살펴본 TabRow, , LazyColumn, HorizontalPager는 Compose에서 제공하는 표준 컴포넌트로, 대부분 자동으로 접근성 정보를 제공합니다. 하지만 커스텀 레이아웃을 만들 때는 CollectionInfo와 CollectionItemInfo를 수동으로 설정해야 합니다.
커스텀 목록 레이아웃 예시
일반 Column으로 구성한 커스텀 날씨 예보 목록에 접근성 정보를 추가해봅시다.
CollectionInfo 설정
Column(
modifier = Modifier.semantics {
contentDescription = "시간별 날씨 예보"
collectionInfo = CollectionInfo(
rowCount = weatherData.size,
columnCount = 1
)
}
) {
// 항목들
}
파라미터 설명:
- rowCount: 전체 행 개수
- columnCount: 전체 열 개수
목록 vs 그리드 인식:
- rowCount나 columnCount 중 하나가 1이면 → 단일 목록으로 인식
- 둘 다 2 이상이면 → 그리드로 인식하고 "1행 3열" 같은 위치 정보도 읽어줌
CollectionItemInfo 설정
각 항목에는 개별적으로 CollectionItemInfo를 설정해야 합니다.
Column(
modifier = Modifier.semantics {
contentDescription = "시간별 날씨 예보"
collectionInfo = CollectionInfo(
rowCount = weatherData.size,
columnCount = 1
)
}
) {
weatherData.forEachIndexed { index, weather ->
Card(
modifier = Modifier.semantics {
collectionItemInfo = CollectionItemInfo(
rowIndex = index,
rowSpan = 1,
columnIndex = 0,
columnSpan = 1
)
}
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(weather.time)
Text("${weather.icon} ${weather.condition} ${weather.temperature}°C")
}
}
}
}
파라미터 설명:
- rowIndex: 항목의 행 위치 (0부터 시작)
- rowSpan: 차지하는 행 개수
- columnIndex: 항목의 열 위치 (0부터 시작)
- columnSpan: 차지하는 열 개수
결과:
TalkBack이 "09:00 - 맑음 22°C, 5개 중 1번째"처럼 현재 위치와 전체 개수 정보를 함께 읽어줍니다.
중요: forEachIndexed를 사용하여 인덱스를 명시적으로 관리해야 합니다. LazyColumn의 itemsIndexed처럼 자동으로 처리되지 않습니다.
커스텀 그리드 구조 예시
LazyVerticalGrid가 아닌 일반 레이아웃으로 그리드를 만들 때도 동일한 방식을 사용합니다.
Column(
modifier = Modifier.semantics {
contentDescription = "날씨 아이콘 그리드"
collectionInfo = CollectionInfo(
rowCount = (items.size + 1) / 2, // 2열이므로 행 개수 계산
columnCount = 2
)
}
) {
items.chunked(2).forEachIndexed { rowIndex, rowItems ->
Row(modifier = Modifier.fillMaxWidth()) {
rowItems.forEachIndexed { colIndex, item ->
Card(
modifier = Modifier
.weight(1f)
.semantics {
collectionItemInfo = CollectionItemInfo(
rowIndex = rowIndex,
rowSpan = 1,
columnIndex = colIndex,
columnSpan = 1
)
}
) {
WeatherIcon(item)
}
}
}
}
}
TalkBack이 "맑음 아이콘, 1행 1열, 행 5개, 열 2개"처럼 그리드 내 정확한 위치를 알려줍니다.
동적 데이터 처리
서버에서 데이터를 가져오는 경우에도 동일한 패턴을 사용할 수 있습니다.
@Composable
fun WeatherList(viewModel: WeatherViewModel) {
val weatherData by viewModel.weatherData.collectAsState()
Column(
modifier = Modifier.semantics {
contentDescription = "시간별 날씨 예보"
collectionInfo = CollectionInfo(
rowCount = weatherData.size, // 동적으로 계산
columnCount = 1
)
}
) {
weatherData.forEachIndexed { index, weather ->
WeatherCard(
weather = weather,
index = index,
total = weatherData.size
)
}
}
}
@Composable
fun WeatherCard(weather: Weather, index: Int, total: Int) {
Card(
modifier = Modifier.semantics {
collectionItemInfo = CollectionItemInfo(
rowIndex = index,
rowSpan = 1,
columnIndex = 0,
columnSpan = 1
)
}
) {
Text(weather.description)
}
}
특징:
- 데이터가 변경되면 Compose가 자동으로 recompose하여 접근성 정보도 업데이트됩니다
- weatherData.size가 동적으로 변하면 rowCount도 자동으로 업데이트됩니다
- View 시스템보다 동적 데이터 처리가 더 간편합니다
전체 개수를 알 수 없는 경우
무한 스크롤이나 페이징 처리로 전체 개수를 알 수 없는 경우, CollectionInfo의 rowCount를 -1로 설정하면 TalkBack이 "뉴스 피드 목록 안에 있음"처럼 그룹 정보만 제공하고 전체 개수는 읽지 않습니다.
마치며
Jetpack Compose는 View 시스템보다 더 직관적이고 유지보수하기 쉬운 방식으로 접근성 컨테이너를 구현할 수 있게 해줍니다. 표준 컴포넌트는 대부분 자동으로 접근성을 지원하며, 커스텀 레이아웃에서는 CollectionInfo와 CollectionItemInfo를 사용하여 명확한 구조 정보를 제공할 수 있습니다.
다음 3부에서는 iOS의 UIKit과 SwiftUI에서 컨테이너 접근성을 구현하는 방법을 다루겠습니다. 읽어주셔서 감사합니다.