提高 Compose 可访问性的关键步骤

为了帮助有无障碍需求的人员成功使用您的应用程序,请设计您的应用程序以支持关键的无障碍要求。

考虑最小触摸目标大小

任何用户可以点击、触摸或交互的屏幕元素都应该足够大,以确保可靠的交互。在调整这些元素大小时,请确保将最小尺寸设置为 48dp,以正确遵循Material Design 可访问性指南

Material 组件(例如CheckboxRadioButtonSwitchSliderSurface)会在内部设置此最小尺寸,但前提是组件可以接收用户操作。例如,当 CheckboxonCheckedChange 参数设置为非空值时,复选框会包含填充,使其宽度和高度至少为 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 框架没有自动的方式来理解应用程序正在显示的内容。您需要传递视觉元素的文本描述。

想象一下,用户可以在其中与朋友分享当前页面的屏幕。此屏幕包含一个可点击的分享图标

A strip of clickable icons, with the

仅凭图标,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 和 Switch Access 等辅助功能服务允许用户在屏幕上的元素之间移动焦点。重要的是元素的焦点要处于正确的粒度。当屏幕上的每个低级可组合元素都独立地获得焦点时,用户需要进行很多交互才能在屏幕上移动。如果元素合并得太激进,用户可能无法理解哪些元素属于一起

当您将 clickable 修饰符应用于可组合元素时,Compose 会自动合并可组合元素包含的所有元素。这也适用于 ListItem;列表项中的元素会合并在一起,辅助功能服务会将它们视为一个元素。

可以有一组可组合元素构成逻辑组,但该组不可点击,也不属于列表项。您仍然希望辅助功能服务将它们视为一个元素。例如,想象一下一个可组合元素,它显示用户的头像、姓名和一些额外信息

A group of UI elements including a user's name. The name is selected.

您可以通过使用 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")
        }
    }
}

辅助功能服务现在会同时聚焦整个容器,并将它们的内容合并。

A group of UI elements including a user's name. All the elements are selected together.

添加自定义操作

看一下以下列表项

A typical list item, containing an article title, author, and bookmark icon.

当您使用 Talkback 等屏幕阅读器来收听屏幕上显示的内容时,它会首先选择整个项目,然后选择书签图标。

The list item, with all the elements selected together.

The list item, with just the bookmark icon selected

在较长的列表中,这可能会变得非常重复。更好的方法是定义一个自定义操作,允许用户为项目添加书签。请记住,您还必须显式删除书签图标本身的行为,以确保它不会被辅助功能服务选择。这可以使用 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() }
            )
    ) {
        /* ... */
    }
}

定义标题

应用程序有时会在一个屏幕上显示很多内容,这些内容位于一个可滚动的容器中。例如,一个屏幕可以显示用户正在阅读的文章的全部内容

Screenshot of a blog post, with the article text in a scrollable container.

具有辅助功能需求的用户难以浏览这样的屏幕。为了帮助导航,请指示哪些元素是标题。在前面的示例中,每个小节标题都可以定义为标题,以供辅助功能使用。某些辅助功能服务(如 Talkback)允许用户直接从标题导航到标题。

在 Compose 中,您可以通过定义可组合元素的 semantics 属性来表明它是 heading

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

处理自定义可组合元素

每当您用自定义版本替换应用程序中的某些 Material 组件时,都必须牢记辅助功能方面的考虑。

假设您要将 Material Checkbox 替换为您自己的实现。您可能会忘记添加 triStateToggleable 修饰符,该修饰符处理该组件的辅助功能属性。

作为经验法则,请查看 Material 库中组件的实现,并模仿您能找到的任何辅助功能行为。此外,请大量使用 Foundation 修饰符,而不是 UI 级修饰符,因为这些修饰符默认包含辅助功能方面的考虑。

使用多个辅助功能服务测试您的自定义组件实现,以验证其行为。

其他资源