Compose 접근성 개선을 위한 주요 단계

접근성 기능이 필요한 사용자가 앱을 성공적으로 사용하도록 지원하려면 주요 접근성 요구사항을 지원하도록 앱을 설계하세요.

터치 영역 최소 크기 고려

사용자가 클릭, 터치 등의 방법으로 상호작용할 수 있는 화면상의 요소는 안정적으로 상호작용할 수 있도록 충분히 커야 합니다. 이러한 요소의 크기를 조절할 때 Material Design 접근성 가이드라인을 올바르게 준수하도록 최소 크기를 48dp로 설정해야 합니다.

Checkbox, RadioButton, Switch, Slider, Surface와 같은 Material 구성요소는 내부적으로 이 최소 크기를 설정하지만 구성요소가 사용자 작업을 수신할 수 있는 경우에만 설정합니다. 예를 들어 CheckboxonCheckedChange 매개변수가 null이 아닌 값으로 설정된 경우 체크박스에는 너비와 높이가 최소 48dp인 패딩이 포함됩니다.

@Composable
private fun CheckableCheckbox() {
    Checkbox(checked = true, onCheckedChange = {})
}

onCheckedChange 매개변수를 null로 설정하면 구성요소와 직접 상호작용할 수 없으므로 패딩이 포함되지 않습니다.

@Composable
private fun NonClickableCheckbox() {
    Checkbox(checked = true, onCheckedChange = null)
}

그림 1. 패딩이 없는 체크박스

Switch, RadioButton 또는 Checkbox와 같은 선택 컨트롤을 구현할 때는 일반적으로 클릭 가능한 동작을 상위 컨테이너로 올리고, 컴포저블의 클릭 콜백을 null로 설정하고, 상위 컴포저블에 toggleable 또는 selectable 수정자를 추가합니다.

@Composable
private fun CheckableRow() {
    MaterialTheme {
        var checked by remember { mutableStateOf(false) }
        Row(
            Modifier
                .toggleable(
                    value = checked,
                    role = Role.Checkbox,
                    onValueChange = { checked = !checked }
                )
                .padding(16.dp)
                .fillMaxWidth()
        ) {
            Text("Option", Modifier.weight(1f))
            Checkbox(checked = checked, onCheckedChange = null)
        }
    }
}

클릭 가능한 컴포저블의 크기가 터치 영역 최소 크기보다 작은 경우 Compose는 여전히 터치 영역 크기를 늘립니다. 컴포저블의 경계 밖으로 터치 영역 크기를 확장하여 이 작업을 수행합니다.

다음 예에는 클릭 가능한 매우 작은 Box가 포함되어 있습니다. 터치 영역 영역은 Box 경계를 넘어 자동으로 확장되므로 Box 옆을 탭하면 여전히 클릭 이벤트가 트리거됩니다.

@Composable
private fun SmallBox() {
    var clicked by remember { mutableStateOf(false) }
    Box(
        Modifier
            .size(100.dp)
            .background(if (clicked) Color.DarkGray else Color.LightGray)
    ) {
        Box(
            Modifier
                .align(Alignment.Center)
                .clickable { clicked = !clicked }
                .background(Color.Black)
                .size(1.dp)
        )
    }
}

서로 다른 컴포저블의 터치 영역이 겹치지 않도록 항상 컴포저블에 충분히 큰 최소 크기를 사용합니다. 이 예에서는 sizeIn 수정자를 사용하여 내부 상자의 최소 크기를 설정하는 것을 의미합니다.

@Composable
private fun LargeBox() {
    var clicked by remember { mutableStateOf(false) }
    Box(
        Modifier
            .size(100.dp)
            .background(if (clicked) Color.DarkGray else Color.LightGray)
    ) {
        Box(
            Modifier
                .align(Alignment.Center)
                .clickable { clicked = !clicked }
                .background(Color.Black)
                .sizeIn(minWidth = 48.dp, minHeight = 48.dp)
        )
    }
}

클릭 라벨 추가

클릭 라벨을 사용하여 컴포저블의 클릭 동작에 시맨틱 의미를 추가할 수 있습니다. 클릭 라벨은 사용자가 컴포저블과 상호작용할 때 발생하는 결과를 설명합니다. 접근성 서비스는 클릭 라벨을 사용하여 특정 요구사항이 있는 사용자에게 앱을 설명할 수 있습니다.

clickable 수정자에 매개변수를 전달하여 클릭 라벨을 설정합니다.

@Composable
private fun ArticleListItem(openArticle: () -> Unit) {
    Row(
        Modifier.clickable(
            // R.string.action_read_article = "read article"
            onClickLabel = stringResource(R.string.action_read_article),
            onClick = openArticle
        )
    ) {
        // ..
    }
}

또는 클릭 가능한 수정자에 액세스할 수 없는 경우 시맨틱 수정자에 클릭 라벨을 설정합니다.

@Composable
private fun LowLevelClickLabel(openArticle: () -> Boolean) {
    // R.string.action_read_article = "read article"
    val readArticleLabel = stringResource(R.string.action_read_article)
    Canvas(
        Modifier.semantics {
            onClick(label = readArticleLabel, action = openArticle)
        }
    ) {
        // ..
    }
}

시각적 요소 설명

Image 또는 Icon 컴포저블을 정의할 때 Android 프레임워크가 앱에서 표시하는 내용을 자동으로 이해할 수 있는 방법은 없습니다. 시각적 요소의 텍스트 설명을 전달해야 합니다.

사용자가 현재 페이지를 친구와 공유할 수 있는 화면이 있다고 가정해 보겠습니다. 이 화면에는 클릭 가능한 공유 아이콘이 포함되어 있습니다.

클릭 가능한 아이콘 모음,

Android 프레임워크는 아이콘만으로는 시각 장애가 있는 사용자에게 아이콘을 설명할 수 없습니다. Android 프레임워크에는 아이콘의 추가 텍스트 설명이 필요합니다.

contentDescription 매개변수는 시각적 요소를 설명합니다. 사용자에게 표시되는 현지화된 문자열을 사용합니다.

@Composable
private fun ShareButton(onClick: () -> Unit) {
    IconButton(onClick = onClick) {
        Icon(
            imageVector = Icons.Filled.Share,
            contentDescription = stringResource(R.string.label_share)
        )
    }
}

일부 시각적 요소는 완전히 장식용이므로 사용자에게 전달하지 않아도 됩니다. contentDescription 매개변수를 null로 설정하면 이 요소에 연결된 작업 또는 상태가 없음을 Android 프레임워크에 나타냅니다.

@Composable
private fun PostImage(post: Post, modifier: Modifier = Modifier) {
    val image = post.imageThumb ?: painterResource(R.drawable.placeholder_1_1)

    Image(
        painter = image,
        // Specify that this image has no semantic meaning
        contentDescription = null,
        modifier = modifier
            .size(40.dp, 40.dp)
            .clip(MaterialTheme.shapes.small)
    )
}

특정 시각적 요소에 contentDescription이 필요한지 여부는 개발자가 결정합니다. 요소가 작업을 실행하는 데 필요한 정보를 전달하는지 자문해 보세요. 그렇지 않으면 설명은 생략하는 것이 좋습니다.

요소 병합

TalkBack 및 스위치 제어와 같은 접근성 서비스를 사용하면 사용자가 화면의 요소 간에 포커스를 이동할 수 있습니다. 요소의 올바른 세부사항에 포커스를 맞춰야 합니다. 화면의 모든 하위 수준 컴포저블에 독립적으로 포커스가 있는 경우 사용자는 화면 간에 이동하기 위해 많은 상호작용을 해야 합니다. 요소가 너무 적극적으로 병합되면 사용자가 어떤 요소가 서로에게 속해 있는지 이해하지 못할 수 있습니다.

컴포저블에 clickable 수정자를 적용하면 Compose는 컴포저블에 포함된 모든 요소를 자동으로 병합합니다. 이는 ListItem의 경우에도 마찬가지입니다. 목록 항목 내의 요소가 함께 병합되고 접근성 서비스에서 하나의 요소로 간주됩니다.

컴포저블의 집합이 논리적 그룹을 구성할 수 있지만 이 그룹은 클릭할 수 없거나 목록 항목의 일부가 아닙니다. 접근성 서비스는 계속해서 하나의 요소로 보기를 원할 것입니다. 예를 들어 사용자의 아바타, 이름, 추가 정보를 표시하는 컴포저블을 생각해 보세요.

사용자 이름이 포함된 UI 요소의 그룹. 이름이 선택되어 있습니다.

semantics 수정자의 mergeDescendants 매개변수를 사용하여 Compose에서 이러한 요소를 병합하도록 할 수 있습니다. 이렇게 하면 접근성 서비스에서 병합된 요소만 선택하고 하위 요소의 모든 시맨틱 속성이 병합됩니다.

@Composable
private fun PostMetadata(metadata: Metadata) {
    // Merge elements below for accessibility purposes
    Row(modifier = Modifier.semantics(mergeDescendants = true) {}) {
        Image(
            imageVector = Icons.Filled.AccountCircle,
            contentDescription = null // decorative
        )
        Column {
            Text(metadata.author.name)
            Text("${metadata.date} • ${metadata.readTimeMinutes} min read")
        }
    }
}

이제 접근성 서비스가 전체 컨테이너에 한 번에 포커스를 두어 콘텐츠를 병합합니다.

사용자 이름이 포함된 UI 요소의 그룹. 모든 요소가 함께 선택되어 있습니다.

맞춤 작업 추가

다음 목록 항목을 살펴보세요.

일반적인 목록 항목, 기사 제목, 저자, 북마크 아이콘이 포함됨

TalkBack과 같은 스크린 리더를 사용하여 화면에 표시된 내용을 들을 때 스크린 리더는 먼저 전체 항목을 선택한 다음 북마크 아이콘을 선택합니다.

목록 항목, 모든 요소가 함께 선택됨

목록 항목, 북마크 아이콘이 선택됨

목록이 길면 이 작업이 매우 반복적일 수 있습니다. 더 나은 접근 방식은 사용자가 항목을 북마크에 추가할 수 있는 맞춤 작업을 정의하는 것입니다. 또한 접근성 서비스에서 북마크 아이콘 자체를 선택하지 않도록 하려면 북마크 아이콘 자체의 동작을 명시적으로 삭제해야 합니다. 이렇게 하려면 clearAndSetSemantics 수정자를 사용합니다.

@Composable
private fun PostCardSimple(
    /* ... */
    isFavorite: Boolean,
    onToggleFavorite: () -> Boolean
) {
    val actionLabel = stringResource(
        if (isFavorite) R.string.unfavorite else R.string.favorite
    )
    Row(
        modifier = Modifier
            .clickable(onClick = { /* ... */ })
            .semantics {
                // Set any explicit semantic properties
                customActions = listOf(
                    CustomAccessibilityAction(actionLabel, onToggleFavorite)
                )
            }
    ) {
        /* ... */
        BookmarkButton(
            isBookmarked = isFavorite,
            onClick = onToggleFavorite,
            // Clear any semantics properties set on this node
            modifier = Modifier.clearAndSetSemantics { }
        )
    }
}

요소의 상태 설명

컴포저블은 Android 프레임워크가 컴포저블의 상태를 읽는 데 사용하는 시맨틱의 stateDescription를 정의할 수 있습니다. 예를 들어 전환 가능한 컴포저블은 '선택됨' 또는 '선택 해제됨' 상태일 수 있습니다. 경우에 따라 Compose에서 사용하는 기본 상태 설명 라벨을 재정의해야 할 수 있습니다. 이렇게 하려면 컴포저블을 전환 가능으로 정의하기 전에 상태 설명 라벨을 명시적으로 지정하면 됩니다.

@Composable
private fun TopicItem(itemTitle: String, selected: Boolean, onToggle: () -> Unit) {
    val stateSubscribed = stringResource(R.string.subscribed)
    val stateNotSubscribed = stringResource(R.string.not_subscribed)
    Row(
        modifier = Modifier
            .semantics {
                // Set any explicit semantic properties
                stateDescription = if (selected) stateSubscribed else stateNotSubscribed
            }
            .toggleable(
                value = selected,
                onValueChange = { onToggle() }
            )
    ) {
        /* ... */
    }
}

제목 정의

앱이 스크롤 가능한 컨테이너의 한 화면에 많은 콘텐츠를 표시하는 경우가 있습니다. 예를 들어 사용자가 읽고 있는 기사의 전체 내용을 화면에 표시할 수 있습니다.

블로그 게시물의 스크린샷. 스크롤 가능한 컨테이너에 있는 기사 텍스트가 있음

접근성 기능이 필요한 사용자는 이러한 화면을 탐색하기가 어렵습니다. 탐색을 돕기 위해 어떤 요소가 제목인지 표시합니다. 앞의 예에서 각 하위 섹션 제목을 접근성을 위한 제목으로 정의할 수 있습니다. TalkBack과 같은 일부 접근성 서비스에서는 사용자가 제목에서 제목으로 바로 이동할 수 있습니다.

Compose에서 semantics 속성을 정의하여 컴포저블이 제목임을 나타냅니다.

@Composable
private fun Subsection(text: String) {
    Text(
        text = text,
        style = MaterialTheme.typography.headlineSmall,
        modifier = Modifier.semantics { heading() }
    )
}

맞춤 컴포저블 처리

앱의 특정 Material 구성요소를 맞춤 버전으로 바꿀 때마다 접근성 고려사항에 유의해야 합니다.

Material Checkbox를 자체 구현으로 대체한다고 가정해 보겠습니다. 이 구성요소의 접근성 속성을 처리하는 triStateToggleable 수정자를 추가하는 것을 잊어버릴 수 있습니다.

일반적으로 Material 라이브러리에서 구성요소 구현을 살펴보고 찾을 수 있는 접근성 동작을 모방합니다. 또한 UI 수준 수정자가 아니라 기초 수정자를 많이 활용하세요. 기초 수정자에는 접근성 고려 사항이 기본적으로 포함되어 있습니다.

여러 접근성 서비스로 맞춤 구성요소 구현을 테스트하여 동작을 확인합니다.

추가 리소스