Jetpack Compose 드롭다운 접근성 가이드
Jetpack Compose 드롭다운 접근성 가이드
안녕하세요, 엔비전스입니다.
드롭다운은 일반적으로 기존 선택값을 변경하거나 숨겨진 목록에서 하나를 선택할 때 사용하는 UI 요소입니다.
시각적으로 드롭다운은 아래쪽 화살표 모양을 포함하고 있어, 사용자들이 이 요소를 클릭하면 다른 옵션을 선택할 수 있다는 것을 직관적으로 알 수 있습니다. 따라서 스크린 리더 사용자에게도 동일한 경험, 특정 요소를 읽을 때 "이 요소는 여러 옵션 중 하나를 선택할 수 있으며, 더블 탭으로 다른 옵션을 선택할 수 있는 화면이 열린다"는 정보를 명확히 전달해야 합니다.
드롭다운 리스트가 표시될 때는 다음 사항들이 보장되어야 합니다:
- 접근성 초점이 드롭다운 목록으로 올바르게 이동
- 배경의 흐려진 영역에는 접근 차단
- 드롭다운 취소 기능 제공
- 현재 선택된 값의 명확한 읽기 지원
또한 드롭다운을 선택하거나 취소했을 때, 접근성 초점이 원래의 드롭다운 버튼으로 돌아가야 합니다.
이번 시간에는 Android Jetpack Compose에서 드롭다운을 구현할 때 이러한 접근성 요구사항을 충족시키는 방법을 살펴보겠습니다.
Android Jetpack Compose에서 드롭다운을 구현하는 방법은 크게 세 가지로 나눌 수 있습니다:
- 커스텀 버튼 + DropdownMenu/DropdownMenuItem 조합
- 드롭다운을 여는 버튼은 커스텀으로 구현
- 선택 가능한 요소들은 DropdownMenu와 DropdownMenuItem으로 구현
- ExposedDropdownMenu 사용
- ExposedDropdownMenuBox를 통해 버튼과 선택 요소를 통합 구현 (검색 가능/불가 형태 포함)
- 완전 커스텀 구현
- 커스텀 팝업과 커스텀 아이템을 사용하여 모든 요소를 직접 구현하는 방법
각 방법에 따른 접근성 적용 방법을 자세히 살펴보겠습니다.
1. 커스텀 버튼 + DropdownMenu/DropdownMenuItem 조합
이 방법은 드롭다운을 여는 버튼은 커스텀으로 구현하고, 드롭다운 메뉴는 Compose에서 제공하는 컴포넌트를 활용하는 방식입니다.
드롭다운 목록 역할(Role.DropdownList) 적용
드롭다운을 여는 버튼에 `DropdownList` 역할을 지정하여 스크린 리더가 해당 요소를 드롭다운 목록으로 인식하도록 합니다.
만약 `Role.DropdownList`를 적용하지 않으면, 스크린 리더 사용자는 해당 UI가 드롭다운 목록임을 인지하지 못하기 때문에 해당 요소를 더블 탭 하면 기존 요소를 다른 요소로 변경하는 역할을 가진 기능을 한다는 것 자체를 알 수 없게 됩니다.
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.SemanticsProperties
import androidx.compose.ui.semantics.semantics
import androidx.compose.material3.Button
import androidx.compose.material3.Text
Button(
onClick = { /* 드롭다운 확장/축소 로직 */ },
modifier = Modifier.semantics {
set(SemanticsProperties.Role, Role.DropdownList)
// TalkBack은 자동으로 "드롭다운 목록"이라고 안내하며,
// 버튼의 텍스트를 읽어주므로 별도의 contentDescription은 불필요합니다.
}
) {
Text("옵션 선택") // 예: 현재 선택된 값 표시
}
드롭다운 아이템 \'선택됨\' 상태 적용
드롭다운 메뉴 내 각 아이템의 선택 상태를 스크린 리더에 전달하는 것이 중요합니다. 시각적으로도 선택됨 상태를 적용하였다면 반드시 톡백에서도 선택됨 상태를 읽어주도록 해야 합니다.
만약 선택된 아이템에 대한 '선택됨' 상태를 스크린 리더에 제대로 전달하지 않으면, 사용자는 어떤 항목이 현재 선택되어 있는지 알 수 없습니다. 이는 특히 여러 항목 중 하나를 정확히 선택해야 하는 상황에서 사용자의 불편을 초래하고, 잘못된 선택으로 이어질 수 있습니다.
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.selected
import androidx.compose.ui.semantics.semantics
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Text
import androidx.compose.runtime.remember
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.text.font.FontWeight
val currentItemText = "예시 아이템"
val selectedItemText = remember { mutableStateOf("예시 아이템") }
val isItemSelected = currentItemText == selectedItemText.value
DropdownMenuItem(
text = {
Text(
text = currentItemText,
fontWeight = if (isItemSelected) FontWeight.Bold else FontWeight.Normal,
modifier = Modifier.semantics {
// 현재 아이템이 선택된 경우 selected를 true로 설정
if (isItemSelected) {
selected = true
}
}
)
},
onClick = {
selectedItemText.value = currentItemText
// 추가적인 선택 로직 (예: expanded = false)
}
)
조건부 시맨틱스 적용 (대안)
`Modifier.then`을 사용하여 더 깔끔하게 조건부로 시맨틱스를 적용할 수도 있습니다:
DropdownMenuItem(
text = {
Text(
text = currentItemText,
fontWeight = if (isItemSelected) FontWeight.Bold else FontWeight.Normal,
modifier = Modifier.then(
if (isItemSelected) {
Modifier.semantics { selected = true }
} else {
Modifier
}
)
)
},
onClick = { /* 아이템 선택 로직 */ }
)
드롭다운 메뉴 및 아이템 사용 시 접근성 초점 관리
`DropdownMenu`와 `DropdownMenuItem`을 사용하면 접근성 초점 관리가 용이합니다. 드롭다운 메뉴가 열리면 초점은 자동으로 드롭다운 영역 내부로 이동하며, 외부 요소로 이동하지 않습니다. 드롭다운 메뉴가 닫히면 초점은 이전에 드롭다운을 열었던 요소로 자동으로 돌아갑니다. 이는 스크린 리더 사용자가 컨텍스트를 잃지 않고 원활하게 상호작용할 수 있도록 돕습니다.
만약 드롭다운 메뉴가 열리거나 닫힐 때 접근성 초점 관리가 제대로 이루어지지 않으면, 스크린 리더 사용자는 콘텐츠 흐름에 따른 연속적인 탐색이 불가능하게 됩니다.
Android View Spinner와의 차이점: 선택됨 상태 수동 구현
기존 Android View 시스템의 `Spinner` 위젯은 드롭다운 목록에서 항목을 선택했을 때 해당 항목의 '선택됨' 상태에 대한 접근성을 자동으로 처리해주는 부분이 있었습니다. 그러나 Jetpack Compose의 `DropdownMenu` 및 `DropdownMenuItem`을 사용하는 경우, 현재 어떤 항목이 선택되었는지에 대한 접근성 정보는 개발자가 수동으로 구현해야 합니다. 앞서 설명된 '드롭다운 아이템 '선택됨' 상태 적용' 섹션에서처럼 `semantics` 수정자를 사용하여 `selected` 속성을 명시적으로 설정해주어야 스크린 리더 사용자가 선택된 항목을 인지할 수 있습니다.
2. ExposedDropdownMenu 사용
`ExposedDropdownMenuBox`는 내부에 `TextField`와 `ExposedDropdownMenu`를 포함하는 구조입니다. 이로 인해 TalkBack은 기본적으로 "수정창"(TextField)과 "드롭다운 목록"(ExposedDropdownMenu)을 함께 안내합니다.
`ExposedDropdownMenuBox`는 검색 가능 여부에 따라 두 가지 주요 형태로 구현할 수 있습니다.
2.1. 검색 불가능한 ExposedDropdownMenu (Non-searchable)
이 경우 `TextField`를 `readOnly = true`로 설정합니다. 사용자는 `TextField`를 직접 편집할 수 없으며, 탭하여 드롭다운을 열고 항목을 선택합니다.
TalkBack 동작:
현재 Android 15 최신 TalkBack 버전에서는 `readOnly = true`로 설정된 `TextField`에 대해 "수정창"이라는 정보 외에 읽기 전용 상태임을 명확히 안내하지 않습니다. 이 점은 구현 시 참고해야 합니다.
선택됨\ 상태 적용:
위에서 설명했듯이 `DropdownMenuItem` 내에서 선택된 항목에 대해 시각적 강조(예: `FontWeight.Bold`)와 함께 `modifier = Modifier.semantics { if (isSelected) selected = true }`를 적용하여 스크린 리더에 선택 상태를 전달합니다.
2.2. 검색 가능한 ExposedDropdownMenu (Searchable)
이 경우 `TextField`는 편집 가능하며, 사용자가 입력한 텍스트를 기반으로 드롭다운 목록의 항목을 필터링할 수 있습니다.
TalkBack 동작 및 키보드 접근성:
`TextField`에 입력이 가능하며, 드롭다운 메뉴가 열리면 화면 하단에 키보드가 나타나 검색을 용이하게 합니다. 그러나 현재 Android 15 최신 TalkBack 버전에서는 이 키보드에 접근성 초점이 이동하지 않는 문제가 있습니다. 이는 Android API 자체의 버그일 수 있으며, 빠른 해결을 희망합니다.
3. 완전 커스텀 구현
완전 커스텀 구현은 기본 드롭다운 컴포넌트 대신 직접 모달이나 팝업으로 드롭다운을 구현하는 방법입니다. 접근성 구현에 더욱 신중한 접근이 필요합니다.
3.1. 드롭다운 앵커 버튼의 접근성 설정
Box(
modifier = Modifier
.focusRequester(focusRequester)
.semantics {
set(SemanticsProperties.Role, Role.DropdownList)
}
) {
// 버튼 내용
}
TalkBack이 해당 요소를 "드롭다운 목록"으로 인식하도록 `Role.DropdownList`를 설정합니다.
3.2. 선택된 아이템 상태 전달
Text(
text = item,
modifier = Modifier.semantics {
if (isSelected) {
this.selected = true
}
}
)
선택된 항목에 `selected = true`를 설정하여 스크린 리더가 "선택됨"이라고 안내하도록 합니다.
3.3. 모달 열림 시 배경 접근성 차단
Column(
modifier = Modifier.then(
if (showModal) {
Modifier.clearAndSetSemantics { }
} else {
Modifier
}
)
) {
// 기존 드롭다운들
}
모달이 열렸을 때 `clearAndSetSemantics { }`를 사용하여 배경의 모든 요소를 접근성 트리에서 제외시킵니다. 이를 통해 접근성 초점이 모달 외부로 이동하는 것을 방지합니다.
이 방법은 조건부로 적용되며, 모달이 닫혔을 때는 일반 `Modifier`를 사용하여 정상적인 접근성을 유지하고, 모달이 열렸을 때만 `clearAndSetSemantics`를 적용하여 해당 영역의 모든 자식 컴포넌트들을 접근성 트리에서 완전히 제거합니다. 이는 TalkBack 사용자가 모달이 열린 상태에서 배경의 드롭다운들에 접근하지 못하도록 하는 핵심적인 구현입니다.
커스텀 모달(팝업) 형태로 드롭다운을 구현할 때 배경 요소에 대한 접근성을 제대로 차단하지 않으면, 모달이 활성화된 상태에서도 스크린 리더의 초점이 모달 바깥 화면에 없는 배경 콘텐츠로 이동할 수 있습니다. 이 경우 화면의 실제 콘텐츠가 어떤 콘텐츠인지 구분을 할 수 없게 됩니다.
3.4. 포커스 관리
// 모달 닫힘 후 원래 위치로 포커스 복귀
LaunchedEffect(showModal) {
if (!showModal) {
delay(100)
dropdownFocusRequester.requestFocus()
}
}
// 뒤로가기 버튼 지원
BackHandler(enabled = showModal) {
showModal = false
}
포커스 관리는 상위 컴포저블과 하위 컴포저블 간의 상태 및 이벤트 전달을 통해 구현됩니다. 예를 들어, 하위 컴포저블에서 생성한 `FocusRequester`를 콜백 함수를 통해 상위 컴포저블로 전달하고, 상위 컴포저블은 이 `FocusRequester`를 사용하여 특정 조건(예: 모달 상태 변경)에 따라 하위 컴포저블의 포커스를 제어할 수 있습니다.
모달이 닫힌 후에는 반드시 원래의 드롭다운 앵커 버튼으로 접근성 초점이 돌아가야 합니다. 이때 `delay(100)`을 두는 이유는 모달 닫힘 애니메이션이나 UI 업데이트가 완료된 후에 포커스를 이동시키기 위함입니다. 포커스 요청 시 예외 처리도 함께 구현하여 안정성을 확보합니다.
드롭다운 모달에 닫기 버튼이 존재하지 않을 경우, TalkBack 사용자는 모달을 취소할 방법이 없습니다. 비장애인은 드롭다운 외부를 터치해서 닫을 수 있지만, 스크린 리더 사용자는 이 방법을 사용할 수 없기 때문입니다. 따라서 반드시 뒤로가기 제스처를 했을 때 드롭다운만 취소될 수 있도록 하는 구현이 필수적으로 필요합니다.
마무리
지금까지 Android Jetpack Compose에서 드롭다운을 구현할 때의 다양한 접근성 구현 방법을 살펴보았습니다:
- 커스텀 버튼 + DropdownMenu: `Role.DropdownList` 설정과 선택된 아이템의 `selected` 상태 적용
- ExposedDropdownMenu: 검색 가능/불가능 형태에 따른 접근성 고려사항
- 완전 커스텀 구현: `clearAndSetSemantics`를 통한 배경 차단과 포커스 관리
각 케이스에 따라 위의 방법들을 적절히 적용하면 모든 사용자가 동등하게 드롭다운 기능을 활용할 수 있는 접근 가능한 앱을 만들 수 있습니다.
소스코드 참고