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 的代码可在 android-compose-codelabs 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(LocalContentAlpha provides ContentAlpha.medium) {
           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(LocalContentAlpha provides ContentAlpha.medium) {
           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(LocalContentAlpha provides ContentAlpha.medium) {
           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 修饰符来设置点击标签

@OptIn(ExperimentalMaterialApi::class)
@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(LocalContentAlpha provides ContentAlpha.medium) {
           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(LocalContentAlpha provides ContentAlpha.medium) {
            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(LocalContentAlpha provides ContentAlpha.medium) {
            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(LocalContentAlpha provides ContentAlpha.medium) {
               Text(
                   // ..
               )
           }
       }
   }
}

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

@Composable
private fun PostMetadata(metadata: Metadata) {
   // ...
   Row(Modifier.semantics(mergeDescendants = true) {}) {
       Image(
           // ...
       )
       Spacer(Modifier.width(8.dp))
       Column {
           Text(
               // ...
           )

           CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.medium) {
               Text(
                   // ..
               )
           }
       }
   }
}

9. 开关和复选框

SwitchCheckbox 等可切换元素会在 TalkBack 选择它们时大声朗读其选中状态。但是,在没有上下文的情况下,很难理解这些可切换元素指的是什么。我们可以通过提升可切换状态来为可切换元素包含上下文,以便用户可以通过按下可组合项本身或描述它的标签来切换 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. 恭喜!

恭喜,您已成功完成此代码实验室,并了解了 Compose 中的可访问性。您了解了触摸目标、视觉元素描述和状态描述。您添加了点击标签、标题、自定义操作。您知道如何添加自定义合并以及如何使用开关和复选框。将这些学习成果应用到您的应用中将大大提高其可访问性!

查看 Compose 路线 上的其他代码实验室。以及其他 代码示例,包括 Jetnews。

文档

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