使用 Compose 编写的应用应支持无障碍服务,供具有不同需求的用户使用。无障碍服务用于将屏幕上显示的内容转换为更适合有特定需求的用户使用的格式。为了支持无障碍服务,应用使用 Android 框架中的 API 公开有关其界面元素的语义信息。然后,Android 框架会将此语义信息传达给无障碍服务。每项无障碍服务都可以选择向用户描述应用的最佳方式。Android 提供了几种无障碍服务,包括 TalkBack 和开关控制。
语义
Compose 使用语义属性向无障碍服务传递信息。语义属性提供有关向用户显示的界面元素的信息。大多数内置可组合项(如 Text
和 Button
)使用从可组合项及其子项推断得出的信息填充这些语义属性。某些修饰符(例如 toggleable
和 clickable
)也会设置某些语义属性。但是,有时框架需要更多信息,才能了解如何向用户描述界面元素。
本文档介绍了各种情况,在这些情况下,您需要向可组合项显式添加额外信息,这样系统才能向 Android 框架正确描述该可组合项。另外,还介绍了如何完全替换给定可组合项的语义信息。本文假定您对 Android 中的无障碍服务有基本的了解。
常见用例
为帮助有无障碍服务需求的用户成功使用您的应用,您的应用应遵循本文所述的最佳实践。
考虑最小触摸目标尺寸
屏幕上可供用户点击、触摸或可与用户互动的所有元素都应足够大,让用户能够进行可靠的互动。调整这些元素的尺寸时,请确保将最小尺寸设置为 48dp,以正确遵循 Material Design 无障碍指南。
Checkbox
、RadioButton
、Switch
、Slider
和 Surface
等 Material 组件在内部设置此最小尺寸,但仅在相应组件可接收用户操作时设置。例如,当 Checkbox
的 onCheckedChange
参数设为非 null 值时,则会添加内边距以让宽度和高度至少为 48dp。
@Composable private fun CheckableCheckbox() { Checkbox(checked = true, onCheckedChange = {}) }
当 onCheckedChange
参数设为 null 时,将不会添加内边距,因为用户无法直接与该组件互动。
@Composable private fun NonClickableCheckbox() { Checkbox(checked = true, onCheckedChange = null) }
实现 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
而言也是如此;系统将合并列表项中的元素,无障碍服务会将它们视为一个元素。
可能会有这样一组可组合项:它们组成了一个逻辑组,但该逻辑组不可点击,也不是列表项的组成部分。您仍希望无障碍服务将它们视为一个元素。例如,假设有一个可组合项,用于显示用户的头像、名称以及一些额外信息:
您可以在 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 修饰符包含开箱即用的无障碍服务注意事项。请务必使用多种无障碍服务测试您的自定义组件实现,以验证其行为。
使用 isTraversalGroup
和 traversalIndex
修改遍历顺序
默认情况下,Compose 应用中的无障碍功能屏幕阅读器行为会按照预期的阅读顺序(通常是从左到右,然后从上到下)实现。不过,对于某些类型的应用布局,如果没有额外的提示,算法就无法确定实际的阅读顺序。在基于 View 的应用中,您可以使用 traversalBefore
和 traversalAfter
属性修复此类问题。从 Compose 1.5 开始,Compose 提供了一个同样灵活的 API,但采用了新的概念模型。
isTraversalGroup
和 traversalIndex
是语义属性,可让您在默认排序算法不适用的情况下控制无障碍功能和 TalkBack 焦点顺序。isTraversalGroup
用于标识语义上重要的组,而 traversalIndex
用于调整这些组中各个元素的顺序。您可以单独使用 isTraversalGroup
,也可以与 traversalIndex
一起使用以进行进一步自定义。
本页介绍了如何在应用中使用 isTraversalGroup
和 traversalIndex
控制屏幕阅读器的遍历顺序。
使用 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 ) } }
代码会生成类似于以下内容的输出:
由于未设置语义,因此屏幕阅读器的默认行为是从左到右、从上到下遍历元素。由于存在此默认情况,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 正常运行,您可以将 traversalIndex
与 isTraversalGroup
结合使用,以进一步自定义屏幕阅读器的排序。
traversalIndex
属性具有以下特征:
traversalIndex
值较低的元素优先。- 可以是正面的,也可以是负面的。
- 默认值为
0f
。 - 仅影响屏幕阅读器可聚焦的节点,例如文本或按钮等屏幕上的元素。例如,仅在 Column 上设置
traversalIndex
不会产生任何影响,除非 Column 也设置了isTraversalGroup
。
以下示例展示了如何将 traversalIndex
和 isTraversalGroup
结合使用。
示例:遍历钟面
钟面是标准遍历排序不起作用的常见场景。本部分中的示例基于时间选择器,用户可在钟面上浏览数字并选择小时和分钟时段的数字。
在下面的简化代码段中,有一个 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 =
true
,traversalIndex
更改仍然适用。不过,如果没有 CircularLayout
进行绑定,在访问屏幕上的所有其他元素之后,系统会读取钟面的 12 位数。这是因为所有其他元素的默认 traversalIndex
为 0f
,并且时钟文本元素是在所有其他 0f
元素之后读取的。
示例:自定义悬浮操作按钮的遍历顺序
在此示例中,您将使用 traversalIndex
和 isTraversalGroup
来控制 Material Design 悬浮操作按钮 (FAB) 的遍历顺序。此示例基于以下布局:
默认情况下,上面的布局具有以下 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。
为您推荐
- 注意:当 JavaScript 处于关闭状态时,系统会显示链接文字
- Compose 中的语义
- 了解手势
- Compose 中的 Material Design 2