提升 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 时,您表示此元素没有关联的操作或状态。

@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 级别修饰符,因为这些修饰符包含开箱即用的可访问性考虑因素。

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

其他资源