1. 简介
在本 Codelab 中,您将学习如何使用 Jetpack Compose 改进应用的无障碍功能。我们将逐步介绍几种常见用例并改进一个示例应用。我们将涵盖触摸目标大小、内容描述、点击标签等。
视力障碍、色盲、听力障碍、肢体灵活度障碍、认知障碍以及许多其他障碍人士都会使用 Android 设备完成日常生活中的任务。在开发应用时考虑到无障碍功能,可以改善用户体验,特别是对于有这些或其他无障碍需求的用户而言。
在本 Codelab 期间,我们将使用 TalkBack 手动测试我们的代码更改。TalkBack 是一种主要供视障人士使用的无障碍服务。请务必也使用其他无障碍服务测试您的代码更改,例如 Switch Access。
TalkBack 在 Jetnews 应用中的运行情况。
学习内容
在本 Codelab 中,您将学习
- 如何通过增大触摸目标大小来服务于肢体灵活度障碍用户。
- 什么是语义属性以及如何更改它们。
- 如何向可组合函数提供信息,使其更具无障碍性。
准备工作
- 熟悉 Kotlin 语法,包括 lambda 表达式。
- 具有 Compose 基础经验。建议在本 Codelab 之前完成 Jetpack Compose 基础知识 Codelab。
- 已启用 TalkBack 的 Android 设备或模拟器。
构建内容
在本 Codelab 中,我们将改进新闻阅读应用的无障碍功能。我们将从一个缺少重要无障碍功能的应用开始,并应用所学知识,使我们的应用更易于有无障碍需求的用户使用。
2. 设置
在此步骤中,您将下载包含一个简单新闻阅读器应用的代码。
所需条件
获取代码
本 Codelab 的代码可在 codelab-android-compose Github 仓库中找到。要克隆它,请运行
$ git clone https://github.com/android/codelab-android-compose
或者,您可以下载两个 zip 文件
查看示例应用
您刚刚下载的代码包含所有可用的 Compose Codelab 的代码。要完成本 Codelab,请在 Android Studio 中打开 AccessibilityCodelab
项目。
我们建议您从 main
分支中的代码开始,按照自己的节奏逐步完成 Codelab。
设置 TalkBack
在本 Codelab 期间,我们将使用 TalkBack 检查更改。如果您使用实体设备进行测试,请按照这些说明打开 TalkBack。模拟器默认未安装 TalkBack。请选择包含 Play 商店的模拟器,然后下载 Android 无障碍套件。
3. 触摸目标大小
屏幕上任何可供用户点击、触摸或以其他方式交互的元素都应该足够大,以便进行可靠的交互。您应确保这些元素的宽度和高度至少为 48dp。
如果这些控件的大小是动态调整的,或根据其内容大小调整,请考虑使用带有 sizeIn
修饰符来设置其尺寸的下限。
一些 Material 组件会为您设置这些大小。例如,Button 可组合函数将其 MinHeight
设置为 36dp,并使用 8dp 的垂直内边距。这加起来就是所需的 48dp 高度。
当我们打开示例应用并运行 TalkBack 时,我们会注意到帖子卡片中的交叉图标触摸目标非常小。我们希望这个触摸目标至少为 48dp。
这是屏幕截图,左侧是我们的原始应用,右侧是改进后的解决方案。
我们来看看实现并检查此可组合函数的大小。打开 PostCards.kt
并查找 PostCardHistory
可组合函数。如您所见,实现将溢出菜单图标的大小设置为 24dp
@Composable
fun PostCardHistory(post: Post, navigateToArticle: (String) -> Unit) {
// ...
Row(
// ...
) {
// ...
CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onSurfaceVariant) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = stringResource(R.string.cd_show_fewer),
modifier = Modifier
.clickable { openDialog = true }
.size(24.dp)
)
}
}
// ...
}
要增大此 Icon
的触摸目标大小,我们可以添加内边距
@Composable
fun PostCardHistory(post: Post, navigateToArticle: (String) -> Unit) {
// ...
Row(
// ...
) {
// ...
CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onSurfaceVariant) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = stringResource(R.string.cd_show_fewer),
modifier = Modifier
.clickable { openDialog = true }
.padding(12.dp)
.size(24.dp)
)
}
}
// ...
}
在我们的用例中,有一个更简单的方法来确保触摸目标至少为 48dp。我们可以利用 Material 组件 IconButton
来为我们处理这个问题
@Composable
fun PostCardHistory(post: Post, navigateToArticle: (String) -> Unit) {
// ...
Row(
// ...
) {
// ...
CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onSurfaceVariant) {
IconButton(onClick = { openDialog = true }) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = stringResource(R.string.cd_show_fewer)
)
}
}
}
// ...
}
现在使用 TalkBack 浏览屏幕可以正确显示 48dp 的触摸目标区域。此外,IconButton
还添加了波纹指示,向用户表明该元素可点击。
4. 点击标签
您的应用中可点击元素默认不提供任何关于点击该元素将执行什么操作的信息。因此,TalkBack 等无障碍服务会使用一个非常通用的默认描述。
为了为有无障碍需求的用户提供最佳体验,我们可以提供一个具体的描述,解释用户点击此元素时会发生什么。
在 Jetnews 应用中,用户可以点击各种帖子卡片阅读完整帖子。默认情况下,TalkBack 会读出可点击元素的内容,后跟文本“双击激活”。相反,我们希望更具体,使用“双击阅读文章”。这是原始版本与我们理想解决方案的对比
更改可组合函数的点击标签。之前(左侧)与之后(右侧)。
将 clickable
修饰符包含一个参数,允许您直接设置此点击标签。
我们再看看 PostCardHistory
的实现
@Composable
fun PostCardHistory(
// ...
) {
Row(
Modifier.clickable { navigateToArticle(post.id) }
) {
// ...
}
}
如您所见,此实现使用了 clickable
修饰符。要设置点击标签,我们可以设置 onClickLabel
参数
@Composable
fun PostCardHistory(
// ...
) {
Row(
Modifier.clickable(
// R.string.action_read_article = "read article"
onClickLabel = stringResource(R.string.action_read_article)
) {
navigateToArticle(post.id)
}
) {
// ...
}
}
TalkBack 现在正确播报“双击阅读文章”。
主屏幕中的其他帖子卡片具有相同的通用点击标签。我们来看看 PostCardPopular
可组合函数的实现并更新其点击标签
@Composable
fun PostCardPopular(
// ...
) {
Card(
shape = MaterialTheme.shapes.medium,
modifier = modifier.size(280.dp, 240.dp),
onClick = { navigateToArticle(post.id) }
) {
// ...
}
}
此可组合函数内部使用了 Card
可组合函数,它不允许您直接设置点击标签。相反,您可以使用 semantics
修饰符来设置点击标签
@Composable
fun PostCardPopular(
post: Post,
navigateToArticle: (String) -> Unit,
modifier: Modifier = Modifier
) {
val readArticleLabel = stringResource(id = R.string.action_read_article)
Card(
shape = MaterialTheme.shapes.medium,
modifier = modifier
.size(280.dp, 240.dp)
.semantics { onClick(label = readArticleLabel, action = null) },
onClick = { navigateToArticle(post.id) }
) {
// ...
}
}
5. 自定义操作
许多应用会显示某种列表,列表中的每个项目包含一个或多个操作。使用屏幕阅读器时,浏览此类列表会变得很繁琐,因为同一个操作会被一遍又一遍地聚焦。
相反,我们可以为可组合函数添加自定义无障碍操作。这样,与同一列表项相关的操作可以分组在一起。
在 Jetnews 应用中,我们显示了一个用户可以阅读的文章列表。每个列表项都包含一个操作,指示用户希望减少查看此主题的内容。在本节中,我们将把此操作移到自定义无障碍操作中,以便更容易浏览列表。
左侧是默认情况,每个交叉图标都是可聚焦的。右侧是解决方案,其中操作包含在 TalkBack 的自定义操作中
为帖子项目添加自定义操作。之前(左侧)与之后(右侧)。
我们打开 PostCards.kt
并查看 PostCardHistory
可组合函数的实现。请注意 Row
和 IconButton
的可点击属性,使用了 Modifier.clickable
和 onClick
@Composable
fun PostCardHistory(post: Post, navigateToArticle: (String) -> Unit) {
// ...
Row(
Modifier.clickable(
onClickLabel = stringResource(R.string.action_read_article)
) {
navigateToArticle(post.id)
}
) {
// ...
CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onSurfaceVariant) {
IconButton(onClick = { openDialog = true }) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = stringResource(R.string.cd_show_fewer)
)
}
}
}
// ...
}
默认情况下,Row
和 IconButton
可组合函数都是可点击的,因此会被 TalkBack 聚焦。这种情况发生在列表中的每个项目上,这意味着在浏览列表时需要大量滑动。我们宁愿将与 IconButton
相关的操作作为列表项上的自定义操作包含进来。我们可以使用 clearAndSetSemantics
修饰符告诉无障碍服务不要与此 Icon
进行交互
@Composable
fun PostCardHistory(post: Post, navigateToArticle: (String) -> Unit) {
// ...
Row(
Modifier.clickable(
onClickLabel = stringResource(R.string.action_read_article)
) {
navigateToArticle(post.id)
}
) {
// ...
CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onSurfaceVariant) {
IconButton(
modifier = Modifier.clearAndSetSemantics { },
onClick = { openDialog = true }
) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = stringResource(R.string.cd_show_fewer)
)
}
}
}
// ...
}
但是,通过移除 IconButton
的语义,现在无法再执行该操作了。我们可以通过在 semantics
修饰符中添加自定义操作,将该操作添加到列表项中
@Composable
fun PostCardHistory(post: Post, navigateToArticle: (String) -> Unit) {
// ...
val showFewerLabel = stringResource(R.string.cd_show_fewer)
Row(
Modifier
.clickable(
onClickLabel = stringResource(R.string.action_read_article)
) {
navigateToArticle(post.id)
}
.semantics {
customActions = listOf(
CustomAccessibilityAction(
label = showFewerLabel,
// action returns boolean to indicate success
action = { openDialog = true; true }
)
)
}
) {
// ...
CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onSurfaceVariant) {
IconButton(
modifier = Modifier.clearAndSetSemantics { },
onClick = { openDialog = true }
) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = showFewerLabel
)
}
}
}
// ...
}
现在我们可以在 TalkBack 中使用自定义操作弹出窗口来应用操作。随着列表项内操作数量的增加,这一点变得越来越重要。
6. 视觉元素描述
并非所有应用用户都能看到或解读应用中显示的视觉元素,例如图标和插图。无障碍服务也无法仅凭像素理解视觉元素的含义。因此,作为开发者,您需要将应用中视觉元素的更多信息传递给无障碍服务。
Image
和 Icon
等视觉可组合函数包含一个参数 contentDescription
。您可以在此处传入该视觉元素的本地化描述,如果该元素纯粹是装饰性的,则传入 null
。
在我们的应用中,文章屏幕缺少一些内容描述。我们运行应用并选择顶部文章导航到文章屏幕。
添加视觉内容描述。之前(左侧)与之后(右侧)。
当我们不提供任何信息时,左上角的导航图标将简单地播报“按钮,双击激活”。这没有告诉用户激活该按钮时将执行什么操作。我们打开 ArticleScreen.kt
@Composable
fun ArticleScreen(
// ...
) {
// ...
Scaffold(
topBar = {
InsetAwareTopAppBar(
title = {
// ...
},
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
imageVector = Icons.Filled.ArrowBack,
contentDescription = null
)
}
}
)
}
) {
// ...
}
}
为 Icon 添加有意义的内容描述
@Composable
fun ArticleScreen(
// ...
) {
// ...
Scaffold(
topBar = {
InsetAwareTopAppBar(
title = {
// ...
},
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
imageVector = Icons.Filled.ArrowBack,
contentDescription = stringResource(
R.string.cd_navigate_up
)
)
}
}
)
}
) {
// ...
}
}
本文中的另一个视觉元素是页眉图片。在我们的例子中,这张图片纯粹是装饰性的,它没有显示任何需要传达给用户的信息。因此,内容描述设置为 null
,并且在使用无障碍服务时会跳过该元素。
屏幕中的最后一个视觉元素是个人资料图片。在这种情况下,我们使用的是通用头像,因此在此处添加内容描述不是必需的。如果使用此作者的真实个人资料图片,我们可以请他们为其提供合适的内容描述。
7. 标题
当屏幕包含大量文本时,就像我们的文章屏幕一样,对于有视力困难的用户来说,快速找到他们正在查找的部分会非常困难。为了帮助解决这个问题,我们可以指明文本的哪些部分是标题。然后,用户可以通过向上或向下轻扫快速浏览这些不同的标题。
默认情况下,没有可组合函数被标记为标题,因此无法进行导航。我们希望我们的文章屏幕提供按标题导航的功能
添加标题。之前(左侧)与之后(右侧)。
文章中的标题在 PostContent.kt
中定义。我们打开该文件并滚动到 Paragraph
可组合函数
@Composable
private fun Paragraph(paragraph: Paragraph) {
// ...
Box(modifier = Modifier.padding(bottom = trailingPadding)) {
when (paragraph.type) {
// ...
ParagraphType.Header -> {
Text(
modifier = Modifier.padding(4.dp),
text = annotatedString,
style = textStyle.merge(paragraphStyle)
)
}
// ...
}
}
}
在这里,Header
定义为一个简单的 Text
可组合函数。我们可以设置 heading
语义属性来表明此可组合函数是一个标题。
@Composable
private fun Paragraph(paragraph: Paragraph) {
// ...
Box(modifier = Modifier.padding(bottom = trailingPadding)) {
when (paragraph.type) {
// ...
ParagraphType.Header -> {
Text(
modifier = Modifier.padding(4.dp)
.semantics { heading() },
text = annotatedString,
style = textStyle.merge(paragraphStyle)
)
}
// ...
}
}
}
8. 自定义合并
正如我们在前面的步骤中看到的,TalkBack 等无障碍服务会逐个元素地浏览屏幕。默认情况下,Jetpack Compose 中设置了至少一个语义属性的每个低级可组合函数都会获得焦点。例如,Text
可组合函数设置了 text
语义属性,因此会获得焦点。
但是,屏幕上过多的可聚焦元素可能导致用户逐个浏览时感到困惑。相反,可以使用带有 mergeDescendants
属性的 semantics
修饰符将可组合函数合并在一起。
我们来检查文章屏幕。大多数元素获得了正确的聚焦级别。但是,文章的元数据目前是作为几个单独的项目读出的。通过将其合并到一个可聚焦实体中可以得到改进
合并可组合函数。之前(左侧)与之后(右侧)。
我们打开 PostContent.kt
并检查 PostMetadata
可组合函数
@Composable
private fun PostMetadata(metadata: Metadata) {
// ...
Row {
Image(
// ...
)
Spacer(Modifier.width(8.dp))
Column {
Text(
// ...
)
CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onSurfaceVariant) {
Text(
// ..
)
}
}
}
}
我们可以告诉顶层 Row 合并其后代,这将达到我们想要的效果
@Composable
private fun PostMetadata(metadata: Metadata) {
// ...
Row(Modifier.semantics(mergeDescendants = true) {}) {
Image(
// ...
)
Spacer(Modifier.width(8.dp))
Column {
Text(
// ...
)
CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onSurfaceVariant) {
Text(
// ..
)
}
}
}
}
9. 开关和复选框
Switch
和 Checkbox
等可切换元素在被 TalkBack 选中时会读出其选中状态。但如果没有上下文,很难理解这些可切换元素指的是什么。我们可以通过提升可切换状态来包含可切换元素的上下文,这样用户可以通过按下可组合函数本身或描述它的标签来切换 Switch
或 Checkbox
。
我们可以在“兴趣”屏幕中看到一个例子。您可以从主屏幕打开导航抽屉导航到那里。在“兴趣”屏幕上,我们有一个用户可以订阅的主题列表。默认情况下,此屏幕上的复选框与其标签是分开聚焦的,这使得难以理解它们的上下文。我们更希望整个 Row
是可切换的
使用复选框。之前(左侧)与之后(右侧)。
我们打开 InterestsScreen.kt
并查看 TopicItem
可组合函数的实现
@Composable
private fun TopicItem(itemTitle: String, selected: Boolean, onToggle: () -> Unit) {
// ...
Row(
modifier = Modifier
.padding(horizontal = 16.dp, vertical = 8.dp)
) {
// ...
Checkbox(
checked = selected,
onCheckedChange = { onToggle() },
modifier = Modifier.align(Alignment.CenterVertically)
)
}
}
如您在此处所见,Checkbox
有一个 onCheckedChange
回调,用于处理元素的切换。我们可以将此回调提升到整个 Row
的级别
@Composable
private fun TopicItem(itemTitle: String, selected: Boolean, onToggle: () -> Unit) {
// ...
Row(
modifier = Modifier
.toggleable(
value = selected,
onValueChange = { _ -> onToggle() },
role = Role.Checkbox
)
.padding(horizontal = 16.dp, vertical = 8.dp)
) {
// ...
Checkbox(
checked = selected,
onCheckedChange = null,
modifier = Modifier.align(Alignment.CenterVertically)
)
}
}
10. 状态描述
在上一步中,我们将切换行为从 Checkbox
提升到父级 Row
。通过为可组合函数的状态添加自定义描述,我们可以进一步提高此元素的无障碍性。
默认情况下,我们的 Checkbox
状态会被读作“已勾选”或“未勾选”。我们可以用自己的自定义描述替换此描述
添加状态描述。之前(左侧)与之后(右侧)。
我们可以继续使用我们在上一步中修改的 TopicItem
可组合函数
@Composable
private fun TopicItem(itemTitle: String, selected: Boolean, onToggle: () -> Unit) {
// ...
Row(
modifier = Modifier
.toggleable(
value = selected,
onValueChange = { _ -> onToggle() },
role = Role.Checkbox
)
.padding(horizontal = 16.dp, vertical = 8.dp)
) {
// ...
Checkbox(
checked = selected,
onCheckedChange = null,
modifier = Modifier.align(Alignment.CenterVertically)
)
}
}
我们可以使用 semantics
修饰符内部的 stateDescription
属性添加自定义状态描述
@Composable
private fun TopicItem(itemTitle: String, selected: Boolean, onToggle: () -> Unit) {
// ...
val stateNotSubscribed = stringResource(R.string.state_not_subscribed)
val stateSubscribed = stringResource(R.string.state_subscribed)
Row(
modifier = Modifier
.semantics {
stateDescription = if (selected) {
stateSubscribed
} else {
stateNotSubscribed
}
}
.toggleable(
value = selected,
onValueChange = { _ -> onToggle() },
role = Role.Checkbox
)
.padding(horizontal = 16.dp, vertical = 8.dp)
) {
// ...
Checkbox(
checked = selected,
onCheckedChange = null,
modifier = Modifier.align(Alignment.CenterVertically)
)
}
}
11. 恭喜!
恭喜!您已成功完成本 Codelab,并了解了 Compose 中无障碍功能的更多知识。您学习了触摸目标、视觉元素描述和状态描述。您添加了点击标签、标题、自定义操作。您知道如何添加自定义合并以及如何使用开关和复选框。将这些知识应用到您的应用中将大大提高其无障碍性!
查看Compose 路径上的其他 Codelab。以及包括 Jetnews 在内的其他代码示例。
文档
有关这些主题的更多信息和指导,请参阅以下文档