Jetpack Compose 中的无障碍功能

1. 简介

在本 Codelab 中,您将学习如何使用 Jetpack Compose 来改善您的应用的无障碍性。我们将逐步讲解几个常见的用例,并逐步改进示例应用。我们将涵盖触摸目标大小、内容描述、点击标签等。

视力障碍、色盲、听力障碍、灵活性障碍、认知障碍以及许多其他残疾人士都使用 Android 设备来完成日常生活中需要完成的任务。当您在开发应用时考虑到无障碍性,您就能让用户体验更好,特别是对于有这些及其他无障碍需求的用户。

在本 Codelab 中,我们将使用 TalkBack 手动测试我们的代码更改。TalkBack 是一款主要由视力障碍人士使用的无障碍服务。请务必也使用其他无障碍服务(例如 Switch Access)测试您对代码所做的任何更改。

TalkBack focus rectangle moving through the home screen of Jetnews. The text that TalkBack announces is shown at the bottom of the screen.

TalkBack 在 Jetnews 应用中的实际应用。

您将学习的内容

在本 Codelab 中,您将学习

  • 如何通过增加触摸目标大小来迎合灵活性障碍用户。
  • 语义属性是什么以及如何更改它们。
  • 如何向可组合项提供信息以提高其无障碍性。

您需要准备的内容

您将构建的内容

在本 Codelab 中,我们将改进新闻阅读应用的无障碍性。我们将从一个缺少重要无障碍功能的应用开始,并应用我们所学知识来使我们的应用更易于具有无障碍需求的人们使用。

2. 设置

在此步骤中,您将下载此 Codelab 的代码,它包含一个简单的新闻阅读器应用。

您需要准备的内容

获取代码

本 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。

这是一个截图,左侧是我们原始的应用,右侧是我们改进后的解决方案。

Comparison of a list item showing a small outline of cross icon on the left, large outline on the right.

让我们来看一下实现并检查此可组合项的大小。打开 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 应用中,用户可以点击各种帖子卡片来阅读完整帖子。默认情况下,这会读出可点击元素的内容,然后是文本“双击以激活”。相反,我们希望更具体一些,并使用“双击以阅读文章”。这就是原始版本的样子,与我们的理想解决方案相比

Two screen recordings with TalkBack enabled, tapping a post in a vertical list and a post in a horizontal carousel.

更改可组合项的点击标签。之前(左侧)与之后(右侧)。

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 的自定义操作中

Two screen recordings with TalkBack enabled. Screen on the left shows how the cross icon on the post item is selectable. Double tapping opens a dialog. Screen on the right shows using a three-tap gesture to open a custom Actions menu. Tapping action 'Show fewer of this' opens the same dialog.

向帖子项添加自定义操作。之前(左侧)与之后(右侧)。

让我们打开 PostCards.kt 并查看 PostCardHistory 可组合项的实现。请注意 RowIconButton 的可点击属性,使用 Modifier.clickableonClick

@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)
               )
           }
       }
   }
   // ...
}

默认情况下,RowIconButton 可组合项都是可点击的,因此 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. 可视化元素描述

并非所有应用程序用户都能看到或解释应用程序中显示的可视化元素,例如图标和插图。辅助功能服务也无法仅根据像素来理解可视化元素。这使得您作为开发者有必要将有关应用程序中可视化元素的更多信息传递给辅助功能服务。

诸如 ImageIcon 等可视化组件包含一个参数 contentDescription。在这里,您可以传递该可视化元素的**本地化**描述,如果该元素纯粹是装饰性的,则传递 null

在我们的应用程序中,文章屏幕缺少一些文本描述。让我们运行应用程序并选择顶部文章以导航到文章屏幕。

Two screen recordings with TalkBack enabled, tapping the back button in the article screen. Left calls out 'Button—double tap to activate'. Right calls out 'Navigate up—double tap to activate'.

添加可视化文本描述。之前(左侧)与之后(右侧)的对比。

如果我们不提供任何信息,左上角的导航图标只会简单地宣布“按钮,双击以激活”。这并没有告诉用户激活该按钮后将执行的操作。让我们打开 ArticleScreen.kt

@Composable
fun ArticleScreen(
   // ...
) {
   // ...
   Scaffold(
       topBar = {
           InsetAwareTopAppBar(
               title = {
                   // ...
               },
               navigationIcon = {
                   IconButton(onClick = onBack) {
                       Icon(
                           imageVector = Icons.Filled.ArrowBack,
                           contentDescription = null
                       )
                   }
               }
           )
       }
   ) { 
       // ...
   }
}

为图标添加有意义的文本描述

@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. 标题

当屏幕包含大量文本(例如我们的文章屏幕)时,视力有障碍的用户很难快速找到他们正在寻找的部分。为了解决这个问题,我们可以指示文本的哪些部分是标题。然后,用户可以通过向上或向下滑动来快速浏览这些不同的标题。

默认情况下,没有可组合项被标记为标题,因此无法进行导航。我们希望我们的文章屏幕提供逐标题导航。

Two screen recordings with TalkBack enabled, using swipe down to navigate through headings. Left screen reads out 'No next heading'. Right screen cycles through the headings and reads each of them out loud.

添加标题。之前(左侧)与之后(右侧)的对比。

我们文章中的标题在 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 语义属性,因此会获得焦点。

但是,屏幕上可聚焦元素过多会导致用户逐个导航时感到困惑。相反,可以使用 semantics 修饰符及其 mergeDescendants 属性将可组合项合并在一起。

让我们检查一下我们的文章屏幕。大多数元素都获得了正确的焦点级别。但是,文章的元数据目前被逐个单独朗读。可以通过将其合并为一个可聚焦实体来改进。

Two screen recordings with TalkBack enabled. Left sreen shows separate green TalkBack rectangles for Author and Metadata fields. Right screen shows one rectangle around both fields and reads the concatenated content.

合并可组合项。之前(左侧)与之后(右侧)的对比。

让我们打开 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(
                   // ..
               )
           }
       }
   }
}

我们可以告诉顶级行合并其子项,这将导致我们想要的行为。

@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. 开关和复选框

当 TalkBack 选择诸如 SwitchCheckbox 之类的可切换元素时,会大声朗读它们的选中状态。但是,如果没有上下文,很难理解这些可切换元素指的是什么。我们可以通过提升可切换状态来为可切换元素包含上下文,以便用户可以通过按下可组合项本身或描述它的标签来切换 SwitchCheckbox

我们在“兴趣”屏幕中可以看到一个示例。您可以通过从“主页”屏幕打开导航抽屉来导航到那里。“兴趣”屏幕上有一系列用户可以订阅的主题。默认情况下,此屏幕上的复选框与它们的标签分开聚焦,这使得难以理解它们的上下文。我们更希望整个 Row 可切换。

Two screen recordings with TalkBack enabled, showing the interests screen with a list of selectable topics. On the left screen, TalkBack separately selects each Checkbox. On the right screen, TalkBack selects the whole 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 状态读作“已勾选”或“未勾选”。我们可以用我们自己的自定义描述替换此描述。

Two screen recordings with TalkBack enabled, tapping a topic in the interests screen. Left screen announces 'Not ticked', while right screen announces 'Not subscribed'.

添加状态描述。之前(左侧)与之后(右侧)的对比。

我们可以继续使用我们在上一步中调整的 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。

文档

有关这些主题的更多信息和指导,请查看以下文档