아티클

커스텀 슬라이더 접근성 구현하기 - Android Jetpack Compose

엔비전스 접근성 2026-07-24 17:41:18

안녕하세요. 엔비전스입니다.

이번 글에서는 안드로이드 젯팩 컴포즈(Jetpack Compose) 환경에서 커스텀 슬라이더의 접근성을 구현하는 방법을 다룹니다.

머티리얼(Material)에서 제공하는 기본 Slider 컴포저블(Composable)은 접근성이 기본으로 지원됩니다. 톡백(TalkBack) 사용자가 슬라이더에 초점을 맞추면 슬라이더라는 역할과 현재 값이 낭독되고, 한 손가락을 위로 쓸면 값이 커지고 아래로 쓸면 값이 작아집니다. 개발자가 아무것도 하지 않아도 이렇게 동작하는 이유는, Slider 내부에 접근성을 위한 시맨틱스(Semantics) 정보가 이미 구성되어 있기 때문입니다.

문제는 슬라이더를 커스텀으로 구현할 때입니다. 디자인 요구사항 때문에 드래그 제스처와 커스텀 드로잉으로 슬라이더를 직접 만들면 이 시맨틱스 정보가 함께 만들어지지 않습니다. 톡백이 실행 중일 때는 화면 터치가 탐색 제스처로 먼저 해석되므로, 드래그로만 동작하는 커스텀 슬라이더는 톡백 사용자가 값을 바꿀 방법이 없습니다. 볼륨 조절처럼 핵심 기능이 슬라이더 하나에 담겨 있다면 기능 전체가 차단되는 심각한 접근성 문제가 됩니다.

먼저 커스텀 슬라이더에 필요한 시맨틱스 API를 하나씩 정리하고, 볼륨 조절 슬라이더와 주간 달력이라는 두 가지 사례로 구현 방법을 설명하겠습니다. 특히 두 번째 사례는 슬라이더와 무관해 보이는 UI에 슬라이더 패턴을 적용해, 접근성 문제 해결을 넘어 탐색 사용성까지 개선하는 예시입니다.

기본 Slider가 제공하는 것

머티리얼 Slider의 기본적인 사용 패턴은 다음과 같습니다.

Slider(
    value = volume,
    onValueChange = { volume = it },
    valueRange = 0f..10f,
    steps = 9
)

이것만으로 톡백은 이 요소를 슬라이더로 낭독하고, 한 손가락 위로 쓸기와 아래로 쓸기를 값 조절로 매핑하며, 값이 바뀔 때마다 현재 값을 낭독합니다. Slider의 내부 구현을 보면 값 변경 액션을 등록하는 semantics 수정자(Modifier)와 범위 정보를 설정하는 progressSemantics 수정자가 체이닝되어 있습니다. 즉, 커스텀 슬라이더에서 개발자가 해야 할 일은 Slider가 내부에서 하고 있는 이 작업을 그대로 재현하는 것입니다.

핵심 API 알아보기

본격적인 구현에 앞서, 커스텀 슬라이더에 사용하는 시맨틱스 API를 정리하겠습니다. 모두 컴포즈 1.0부터 제공되는 안정 API입니다.

1. contentDescription과 stateDescription

contentDescription은 요소의 이름입니다. 이 슬라이더가 무엇인지("미디어 볼륨", "일정 달력")를 낭독합니다. stateDescription은 요소의 현재 상태입니다. 초점을 맞추면 contentDescription은 한 번 낭독되고, 이후에는 값이 바뀔 때마다 stateDescription이 다시 낭독됩니다. 조절 컨트롤에서 stateDescription을 설정하지 않으면 톡백은 범위 정보에서 계산한 퍼센트("70%")를 낭독합니다. 볼륨이라면 퍼센트 낭독도 자연스럽지만, 값의 의미가 퍼센트로 전달되지 않는 경우("7월 7일 화요일")라면 반드시 직접 설정해야 합니다.

2. progressSemantics와 ProgressBarRangeInfo

progressSemantics는 요소에 범위 정보를 부여하는 수정자입니다. 현재 값, 값의 범위, 단계 수(steps)를 받습니다. 내부적으로는 시맨틱스 프로퍼티인 progressBarRangeInfoProgressBarRangeInfo(current, range, steps)를 설정하는 것이 전부이므로, 시맨틱스 블록 안에서 progressBarRangeInfo를 직접 설정해도 동작은 같습니다. 이 차이는 뒤에서 설명할 clearAndSetSemantics와 함께 쓸 때 중요해집니다.

3. setProgress

setProgress는 값을 바꿔 달라는 접근성 액션을 처리하는 람다를 등록합니다. 람다는 목표값을 받아 적용에 성공하면 true를, 바꿀 수 없으면 false를 반환합니다. 다만 false를 반환해도 톡백이 조절할 수 없다는 안내를 따로 하지는 않습니다. 값이 그대로이므로 직전과 같은 상태가 다시 낭독될 뿐이고, 사용자는 같은 상태가 반복되는 것으로 경계에 도달했음을 알게 됩니다.

4. clearAndSetSemantics

clearAndSetSemantics는 해당 요소와 하위 요소의 시맨틱스를 모두 지우고, 블록 안에서 설정한 시맨틱스만 남깁니다. 여러 하위 요소로 구성된 컨테이너를 하나의 초점으로 묶을 때 사용합니다. 주의할 점은 접근성 트리만 바뀐다는 것입니다. 터치 동작과 시각적 UI는 그대로 유지되므로 화면을 보며 사용하는 사용자의 경험에는 영향이 없습니다.

톡백이 슬라이더로 인식하는 조건

톡백은 요소에 범위 정보(ProgressBarRangeInfo)와 setProgress 액션이 모두 있을 때만 그 요소를 조절 가능한 컨트롤(SeekControl)로 분류합니다. 둘 중 하나라도 빠지면 위아래 쓸기는 값 조절로 동작하지 않습니다.

값이 조절되는 과정도 정확히 알아둘 필요가 있습니다. 사용자가 한 손가락을 위 또는 아래로 쓸면 톡백은 ACTION_SCROLL_FORWARD 또는 ACTION_SCROLL_BACKWARD 액션을 보냅니다. 이것을 받은 컴포즈의 접근성 델리게이트가 범위 정보에서 증분을 계산해, 개발자가 등록한 setProgress 람다를 "현재값 ± 증분"으로 호출합니다. 증분은 steps가 0이면 전체 범위의 5퍼센트이고, steps를 지정했으면 정확히 1단계입니다. 이 계산 방식이 만드는 함정은 글 마지막의 주의할 점에서 다시 다루겠습니다.

사례 1: 볼륨 조절 커스텀 슬라이더

첫 번째 사례는 막대를 직접 그려 만든 볼륨 조절 슬라이더입니다. 값의 범위는 0부터 10까지이고, 드래그 제스처로 값을 바꾸는 커스텀 UI라고 가정합니다. 시맨틱스 구현은 다음과 같습니다.

var volume by remember { mutableIntStateOf(5) }

Row(
    modifier = Modifier
        .semantics(mergeDescendants = true) {
            contentDescription = "미디어 볼륨"
            stateDescription = "볼륨 $volume"
            setProgress { target ->
                // steps를 지정했으므로 목표값은 현재값 ± 1로 들어옵니다
                val next = target.roundToInt().coerceIn(0, 10)
                if (next == volume) {
                    false  // 경계에 도달해 더 바꿀 수 없는 경우
                } else {
                    volume = next
                    true
                }
            }
        }
        .progressSemantics(
            value = volume.toFloat(),
            valueRange = 0f..10f,
            steps = 9
        )
) {
    // 값에 따라 막대를 직접 그리는 커스텀 볼륨 바 UI
}

구조는 머티리얼 Slider의 내부 구현과 동일합니다. semantics 블록에서 이름과 상태, 값 변경 액션을 등록하고, progressSemantics로 범위 정보를 부여합니다. 0부터 10까지 정수 11개의 값을 가지므로 양 끝을 제외한 단계 수는 9가 되고, 이렇게 steps를 지정하면 쓸기 한 번의 증분이 정확히 1이 됩니다. 최대 볼륨에서 위로 쓸면 목표값이 범위로 잘려 현재 값과 같아지므로 false가 반환됩니다. 이때 톡백이 조절 불가를 따로 안내하지는 않고 "볼륨 10"이라는 같은 상태가 반복해서 낭독되므로, 사용자는 이 반복으로 최대값에 도달했음을 알게 됩니다. 이제 톡백 사용자는 기본 Slider와 똑같이 위아래 쓸기로 볼륨을 조절할 수 있고, 값이 바뀔 때마다 "볼륨 6", "볼륨 7"과 같은 상태를 듣게 됩니다.

사례 2: 주간 달력에 슬라이더 패턴 적용하기

두 번째 사례는 일정 앱의 주간 달력입니다. 화면 위쪽에 한 주 단위로 가로 스크롤되는 달력이 있고, 그 아래에 날짜별 일정 목록이 이어집니다. 각 날짜는 버튼으로 구현되어 있어, 누르면 해당 날짜의 일정 위치로 목록이 스크롤됩니다.

시각적으로는 자연스러운 구성이지만, 톡백으로 사용하면 두 가지 비효율이 생깁니다. 먼저 이전 주나 다음 주로 넘어가려면 달력 영역을 두 손가락으로 정확히 짚은 뒤 가로로 쓸어야 하는데, 화면을 보지 않고 달력의 위치를 특정하기 어려워 이 조작 자체가 쉽지 않습니다. 또한 날짜 하나하나가 개별 초점이므로, 원하는 날짜를 찾으려면 달력의 날짜들과 아래 일정 사이를 한 손가락 쓸기로 여러 번 오가며 탐색해야 합니다. 특정 날짜의 일정을 확인한다는 단순한 작업에 지나치게 많은 제스처가 필요한 것입니다.

해결 방향은 달력 전체를 하나의 초점으로 묶고, 그 초점을 슬라이더처럼 조절하게 만드는 것입니다. 슬라이더의 값을 날짜로 정의하는 것이 핵심입니다. 인덱스 0을 표시 범위의 가장 과거 날짜로, 최대값을 오늘로 두고 기본 선택을 오늘로 하면, 위로 쓸기는 다음 날짜, 아래로 쓸기는 이전 날짜가 됩니다. 오늘에서 위로 쓸기가 동작하지 않아야 한다는 요구사항도 범위의 경계가 자연스럽게 해결합니다. 여기에 날짜가 바뀔 때마다 일정 목록을 해당 날짜 위치로 스크롤해 주면, 초점은 달력에 둔 채 쓸기만으로 원하는 날짜까지 이동한 뒤 오른쪽 쓸기 한 번으로 바로 그 날짜의 일정을 확인할 수 있습니다.

구현은 다음과 같습니다.

Column(
    modifier = Modifier.clearAndSetSemantics {
        contentDescription = "일정 달력"
        // 퍼센트 대신 선택된 날짜와 일정 요약을 낭독합니다
        stateDescription = "7월 7일 화요일, 일정 2건"  // 실제로는 선택 날짜에서 계산
        // progressSemantics를 뒤에 체이닝하면 지워지므로 블록 안에서 직접 설정합니다
        progressBarRangeInfo = ProgressBarRangeInfo(
            current = selectedIndex.toFloat(),
            range = 0f..maxIndex.toFloat(),  // 0 = 가장 과거, 최대 = 오늘
            steps = maxIndex - 1  // 쓸기 한 번의 증분이 하루가 되도록
        )
        setProgress { target ->
            // 목표값의 방향만 해석해 정확히 하루씩 이동합니다
            val next = when {
                target > selectedIndex -> selectedIndex + 1  // 위로 쓸기 = 다음 날짜
                target < selectedIndex -> selectedIndex - 1  // 아래로 쓸기 = 이전 날짜
                else -> return@setProgress false
            }
            if (next in 0..maxIndex) {
                onDateSelected(next)
                true
            } else {
                false  // 오늘에서 위로 쓸기처럼 경계에 도달한 경우
            }
        }
    }
) {
    // 월 표시, 요일 헤더, 주 단위 페이저 등 달력 UI
}

// 날짜가 바뀌면 일정 목록을 해당 날짜 위치로 스크롤합니다
LaunchedEffect(selectedIndex) {
    listState.animateScrollToItem(headerPositions[selectedIndex])
    pagerState.animateScrollToPage(selectedIndex / 7)  // 주 경계를 넘으면 달력도 함께 넘어가도록
}

clearAndSetSemantics가 하위에 있는 날짜 버튼들의 초점을 모두 걷어내고 달력을 하나의 초점으로 만듭니다. 그 안에서 범위 정보와 setProgress를 설정했으므로 톡백은 이 달력을 조절 가능한 컨트롤로 낭독합니다. 사용자가 위아래로 쓸 때마다 날짜가 하루씩 바뀌면서 "7월 6일 월요일, 일정 없음"과 같은 새 상태가 낭독되고, 화면에서는 일정 목록이 해당 날짜로 따라 움직입니다. 주 경계를 넘으면 달력 페이저도 함께 넘어가므로 두 손가락 가로 스크롤은 더 이상 필요하지 않습니다. 초점이 달력에 머무는 동안 목록이 미리 스크롤되어 있기 때문에, 원하는 날짜에서 오른쪽으로 쓸면 바로 그 날짜의 일정으로 진입합니다.

달력에서 일정까지의 탐색 경로가 제스처 몇 번으로 줄어들므로, 이 개선은 접근성 문제 해결을 넘어 화면을 보며 쓰는 사용자와 대등한 수준의 탐색 효율을 제공합니다. 터치와 시각적 UI는 그대로이므로 기존 사용자 경험을 해치지 않는다는 점도 실무에서 중요한 장점입니다.

구현 시 주의할 점

첫 번째는 증분 계산입니다. 앞서 설명했듯 setProgress 람다가 받는 목표값은 톡백이 아니라 컴포즈의 접근성 델리게이트가 계산하며, steps가 0이면 증분이 전체 범위의 5퍼센트입니다. 달력처럼 값이 스무 개를 넘는 컨트롤에서 steps 없이 목표값을 그대로 적용하면 쓸기 한 번에 여러 날짜를 건너뛰게 됩니다. steps를 지정해 증분을 1로 만들고, 위 코드처럼 목표값의 방향만 해석해 한 단계씩 이동하도록 구현하면 컴포즈 버전에 따른 계산 차이에도 안전합니다.

두 번째는 stateDescription입니다. 설정하지 않으면 퍼센트가 낭독되므로, 값의 의미가 퍼센트로 전달되지 않는 컨트롤에서는 반드시 현재 상태를 직접 구성해야 합니다. 상태 문구에 일정 개수처럼 값에 딸린 정보를 함께 담으면 낭독만으로 맥락이 전달됩니다.

세 번째는 clearAndSetSemantics와의 체이닝 순서입니다. clearAndSetSemantics 뒤에 체이닝된 시맨틱스 수정자는 하위로 취급되어 함께 지워집니다. 컨테이너를 묶는 경우라면 범위 정보와 setProgress를 반드시 블록 안에서 직접 설정해야 합니다.

네 번째는 활성 상태입니다. 시맨틱스에 disabled가 설정된 요소는 조절 액션 자체가 노출되지 않아 쓸기가 동작하지 않습니다. 조절이 필요한 컨트롤은 활성 상태를 유지해야 합니다.

마치며

지금까지 젯팩 컴포즈 환경에서 커스텀 컨트롤을 톡백이 조절 가능한 슬라이더로 인식하도록 만드는 방법을 살펴보았습니다. 핵심은 범위 정보와 setProgress 액션 두 가지를 함께 제공하는 것이고, 퍼센트로 표현되지 않는 값이라면 stateDescription으로 현재 상태를 직접 낭독시키는 것입니다. 이 패턴은 볼륨 조절처럼 형태가 슬라이더인 컨트롤의 접근성을 복구하는 데에도, 달력처럼 슬라이더와 무관해 보이는 UI의 탐색 효율을 끌어올리는 데에도 활용할 수 있습니다. 감사합니다.

댓글 0
댓글을 작성하려면 해주세요.