Compose 中的无障碍服务

使用 Compose 编写的应用应支持无障碍服务,供具有不同需求的用户使用。无障碍服务用于将屏幕上显示的内容转换为更适合有特定需求的用户使用的格式。为了支持无障碍服务,应用使用 Android 框架中的 API 公开有关其界面元素的语义信息。然后,Android 框架会将此语义信息传达给无障碍服务。每项无障碍服务都可以选择向用户描述应用的最佳方式。Android 提供了几种无障碍服务,包括 TalkBack开关控制

语义

Compose 使用语义属性向无障碍服务传递信息。语义属性提供有关向用户显示的界面元素的信息。大多数内置可组合项(如 TextButton)使用从可组合项及其子项推断得出的信息填充这些语义属性。某些修饰符(例如 toggleableclickable)也会设置某些语义属性。但是,有时框架需要更多信息,才能了解如何向用户描述界面元素。

本文档介绍了各种情况,在这些情况下,您需要向可组合项显式添加额外信息,这样系统才能向 Android 框架正确描述该可组合项。另外,还介绍了如何完全替换给定可组合项的语义信息。本文假定您对 Android 中的无障碍服务有基本的了解。

常见用例

为帮助有无障碍服务需求的用户成功使用您的应用,您的应用应遵循本文所述的最佳实践。

考虑最小触摸目标尺寸

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

CheckboxRadioButtonSwitchSliderSurface 等 Material 组件在内部设置此最小尺寸,但仅在相应组件可接收用户操作时设置。例如,当 CheckboxonCheckedChange 参数设为非 null 值时,则会添加内边距以让宽度和高度至少为 48dp。

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

onCheckedChange 参数设为 null 时,将不会添加内边距,因为用户无法直接与该组件互动。

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

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

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

@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 中,您可以通过定义可组合项的语义属性来指示可组合项是一个标题

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

自动测试无障碍功能属性

在自定义应用的语义属性时(例如在遵循上述用例时),您可以使用自动化界面测试来验证正确性并防止出现回归。

例如,如需测试元素的点击标签是否已正确设置,请使用以下代码:

@Test
fun test() {
    composeTestRule
        .onNode(nodeMatcher)
        .assert(
            SemanticsMatcher("onClickLabel is set correctly") {
                it.config.getOrNull(SemanticsActions.OnClick)?.label == "My Click Label"
            }
        )
}

创建自定义低级别可组合项

更高级的用例涉及将应用中某些 Material 组件替换为自定义版本。在这种情况下,请务必牢记无障碍功能注意事项。假设您要将 Material Checkbox 替换为您自己的实现。很容易忘记添加 triStateToggleable 修饰符,该修饰符用于处理该组件的无障碍属性。

一般来讲,您应该查看组件在 Material 库中的实现,并模仿您可以发现的任何无障碍行为。此外,要大量使用 Foundation 修饰符(而不是界面级修饰符),因为 Foundation 修饰符包含开箱即用的无障碍服务注意事项。请务必使用多种无障碍服务测试您的自定义组件实现,以验证其行为。

使用 isTraversalGrouptraversalIndex 修改遍历顺序

默认情况下,Compose 应用中的无障碍功能屏幕阅读器行为会按照预期的阅读顺序(通常是从左到右,然后从上到下)实现。不过,对于某些类型的应用布局,如果没有额外的提示,算法就无法确定实际的阅读顺序。在基于 View 的应用中,您可以使用 traversalBeforetraversalAfter 属性修复此类问题。从 Compose 1.5 开始,Compose 提供了一个同样灵活的 API,但采用了新的概念模型。

isTraversalGrouptraversalIndex 是语义属性,可让您在默认排序算法不适用的情况下控制无障碍功能和 TalkBack 焦点顺序。isTraversalGroup 用于标识语义上重要的组,而 traversalIndex 用于调整这些组中各个元素的顺序。您可以单独使用 isTraversalGroup,也可以与 traversalIndex 一起使用以进行进一步自定义。

本页介绍了如何在应用中使用 isTraversalGrouptraversalIndex 控制屏幕阅读器的遍历顺序。

使用 isTraversalGroup 为元素分组

isTraversalGroup 是一个布尔值属性,用于定义 semantics 节点是否为遍历组。这种类型的节点的作用是在组织其子节点时充当边界或边框。

在节点上设置 isTraversalGroup = true 意味着,系统会先访问该节点的所有子节点,然后再移至其他元素。您可以在非屏幕阅读器可聚焦节点(例如 Column、Row 或 Box)上设置 isTraversalGroup

在此示例中,代码段已修改为使用 isTraversalGroup。以下代码段会发出四个文本元素。左两个元素属于一个 CardBox 元素,而右两个元素属于另一个 CardBox 元素:

// CardBox() function takes in top and bottom sample text.
@Composable
fun CardBox(
    topSampleText: String,
    bottomSampleText: String,
    modifier: Modifier = Modifier
) {
    Box(modifier) {
        Column {
            Text(topSampleText)
            Text(bottomSampleText)
        }
    }
}

@Composable
fun TraversalGroupDemo() {
    val topSampleText1 = "This sentence is in "
    val bottomSampleText1 = "the left column."
    val topSampleText2 = "This sentence is "
    val bottomSampleText2 = "on the right."
    Row {
        CardBox(
            topSampleText1,
            bottomSampleText1
        )
        CardBox(
            topSampleText2,
            bottomSampleText2
        )
    }
}

代码会生成类似于以下内容的输出:

包含两列文本的布局,左列显示“This sentence is in the left column”(这个句子位于左列),右列写为“This sentence is the right.”。
图 1. 包含两个句子(一个位于左列,一个位于右列)的布局。

由于未设置语义,因此屏幕阅读器的默认行为是从左到右、从上到下遍历元素。由于存在此默认情况,TalkBack 会以错误的顺序读出句子片段:

“This sentence is in”→“This sentence is”→“the left column”。→“在右侧”。

如需对 fragment 正确排序,请修改原始代码段,将 isTraversalGroup 设置为 true

@Composable
fun TraversalGroupDemo2() {
    val topSampleText1 = "This sentence is in "
    val bottomSampleText1 = "the left column."
    val topSampleText2 = "This sentence is"
    val bottomSampleText2 = "on the right."
    Row {
        CardBox(
//      1,
            topSampleText1,
            bottomSampleText1,
            Modifier.semantics { isTraversalGroup = true }
        )
        CardBox(
//      2,
            topSampleText2,
            bottomSampleText2,
            Modifier.semantics { isTraversalGroup = true }
        )
    }
}

由于 isTraversalGroup 是针对每个 CardBox 专门设置的,因此在对其元素进行排序时,将遵循 CardBox 边界。在这种情况下,先读取左侧的 CardBox,然后读取右侧的 CardBox

现在,TalkBack 以正确顺序读出句子片段:

“This sentence is in”→“the left column”。→“这句话是”→“在右侧。”

使用 traversalIndex 进一步自定义遍历顺序

traversalIndex 是一个浮点属性,可用于自定义 TalkBack 遍历顺序。如果只是将元素分组在一起还不足以使 TalkBack 正常运行,您可以将 traversalIndexisTraversalGroup 结合使用,以进一步自定义屏幕阅读器的排序。

traversalIndex 属性具有以下特征:

  • traversalIndex 值较低的元素优先。
  • 可以是正面的,也可以是负面的。
  • 默认值为 0f
  • 仅影响屏幕阅读器可聚焦的节点,例如文本或按钮等屏幕上的元素。例如,仅在 Column 上设置 traversalIndex 不会产生任何影响,除非 Column 也设置了 isTraversalGroup

以下示例展示了如何将 traversalIndexisTraversalGroup 结合使用。

示例:遍历钟面

钟面是标准遍历排序不起作用的常见场景。本部分中的示例基于时间选择器,用户可在钟面上浏览数字并选择小时和分钟时段的数字。

上面有一个时间选择器的钟面。
图 2. 钟面的图片。

在下面的简化代码段中,有一个 CircularLayout,其中绘制了 12 个数字,从 12 开始,围绕圆圈顺时针移动:

@Composable
fun ClockFaceDemo() {
    CircularLayout {
        repeat(12) { hour ->
            ClockText(hour)
        }
    }
}

@Composable
private fun ClockText(value: Int) {
    Box(modifier = Modifier) {
        Text((if (value == 0) 12 else value).toString())
    }
}

由于使用默认的从左到右和从上到下排序,系统无法以逻辑方式读取钟面,因此 TalkBack 会不按顺序读出数字。如需解决此问题,请使用递增计数器值,如以下代码段所示:

@Composable
fun ClockFaceDemo() {
    CircularLayout(Modifier.semantics { isTraversalGroup = true }) {
        repeat(12) { hour ->
            ClockText(hour)
        }
    }
}

@Composable
private fun ClockText(value: Int) {
    Box(modifier = Modifier.semantics { this.traversalIndex = value.toFloat() }) {
        Text((if (value == 0) 12 else value).toString())
    }
}

如需正确设置遍历顺序,请先将 CircularLayout 设为遍历组,并设置 isTraversalGroup = true。然后,当每个时钟文本被绘制到布局上时,将其对应的 traversalIndex 设置为计数器值。

由于计数器值会不断增加,因此每个时钟值的 traversalIndex 会随着数字的增加而增大 - 时钟值 0 的 traversalIndex 为 0,时钟值 1 的 traversalIndex 为 1,依此类推。这样,TalkBack 读取这些消息的顺序就设置好了。现在,CircularLayout 内的数字按预期顺序读取。

由于已设置的 traversalIndexes 仅相对于同一分组中的其他索引,因此保留了屏幕排序的其余部分。换句话说,上面代码段中显示的语义更改只会修改已设置 isTraversalGroup = true 的钟面中的顺序。

请注意,如果不将 CircularLayout's 语义设置为 isTraversalGroup = truetraversalIndex 更改仍然适用。不过,如果没有 CircularLayout 进行绑定,在访问屏幕上的所有其他元素之后,系统会读取钟面的 12 位数。这是因为所有其他元素的默认 traversalIndex0f,并且时钟文本元素是在所有其他 0f 元素之后读取的。

示例:自定义悬浮操作按钮的遍历顺序

在此示例中,您将使用 traversalIndexisTraversalGroup 来控制 Material Design 悬浮操作按钮 (FAB) 的遍历顺序。此示例基于以下布局:

包含顶部应用栏、示例文本、悬浮操作按钮和底部应用栏的布局。
图 3. 包含顶部应用栏、示例文本、悬浮操作按钮和底部应用栏的布局。

默认情况下,上面的布局具有以下 TalkBack 顺序:

顶部应用栏 → 示例文本 0 至 6 → 悬浮操作按钮 (FAB) → 底部应用栏

您可能需要先让屏幕阅读器聚焦在 FAB 上。如需在 Material 元素(例如 FAB)上设置 traversalIndex,请执行以下操作:

@Composable
fun FloatingBox() {
    Box(modifier = Modifier.semantics { isTraversalGroup = true; traversalIndex = -1f }) {
        FloatingActionButton(onClick = {}) {
            Icon(imageVector = Icons.Default.Add, contentDescription = "fab icon")
        }
    }
}

在此代码段中,创建一个框并将 isTraversalGroup 设置为 true,并在同一框上设置 traversalIndex(-1f 小于默认值 0f)意味着浮动框位于屏幕上所有其他元素之前。

接下来,您可以将浮动框和其他元素放入基架中,从而实现简单的 Material Design 布局:

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ColumnWithFABFirstDemo() {
    Scaffold(
        topBar = { TopAppBar(title = { Text("Top App Bar") }) },
        floatingActionButtonPosition = FabPosition.End,
        floatingActionButton = { FloatingBox() },
        content = { padding -> ContentColumn(padding = padding) },
        bottomBar = { BottomAppBar { Text("Bottom App Bar") } }
    )
}

TalkBack 会按以下顺序与这些元素互动:

FAB → 顶部应用栏 → 示例文本 0 到 6 → 底部应用栏

了解详情

如需详细了解如何在 Compose 代码中支持无障碍功能,请学习“使用 Jetpack Compose 改进应用的无障碍功能”Codelab