改进 Compose 无障碍功能的关键步骤

为了帮助有无障碍功能需求的用户成功使用您的应用,请在设计应用时充分考虑关键的无障碍功能要求。

考虑最小触摸目标尺寸

屏幕上可供用户点击、触摸或可与用户互动的所有元素都应足够大,让用户能够进行可靠的互动。调整这些元素的大小时,请务必将最小尺寸设置为 48dp,以正确遵循 Material Design 无障碍指南

Material 组件(如 CheckboxRadioButtonSwitchSliderSurface)可在内部设置此最小尺寸,但仅限该组件可以接收用户操作时。例如,当 CheckboxonCheckedChange 参数设置为非 null 值时,该复选框将添加内边距,使宽度和高度至少为 48 dp。

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

onCheckedChange 参数设置为 null 时,系统不会添加内边距,因为无法直接与组件互动。

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

图 1. 不带内边距的复选框。

在实现 SwitchRadioButtonCheckbox 等选择控件时,您通常需要将可点击行为提升到父级容器,将可组合项上的点击回调设置为 null,并向父级可组合项添加 toggleableselectable 修饰符。

@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
        )
    ) {
        // ..
    }
}

或者,如果您无权访问可点击修饰符,请在 semantics 修饰符中设置点击标签:

@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)
        }
    ) {
        // ..
    }
}

描述视觉元素

在定义 ImageIcon 可组合项时,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;列表项中的元素会合并在一起,无障碍服务也会将它们视为一个元素。

可能会有这样一组可组合项:它们组成了一个逻辑组,但该逻辑组不可点击,也不是列表项的组成部分。您仍然希望无障碍服务将这些元素视为一个元素。例如,假设有一个可组合项,它会显示用户的头像、姓名和一些额外信息:

一组包含用户名的界面元素。选择名称。

您可以在 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")
        }
    }
}

无障碍服务现在可同时专注于整个容器,合并其内容:

一组包含用户名的界面元素。所有元素均同时选中。

添加自定义操作

我们来看看以下列表项:

典型的列表项,包含文章标题、作者和书签图标。

当您使用 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 { }
        )
    }
}

描述元素的状态

可组合项可以为语义定义 stateDescription,Android 框架使用该语义来读取可组合项所处的状态。例如,可切换的可组合项可以处于“已选中”或“未选中”状态。在某些情况下,您可能需要替换 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 库中查看该组件的实现,并模拟您能找到的任何无障碍行为。此外,应大量使用 Foundation 修饰符(而不是界面级修饰符),因为 Foundation 修饰符包含开箱即用的无障碍功能注意事项。

使用多个无障碍服务测试自定义组件实现,以验证其行为。

其他资源