Compose 中的无障碍服务

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

语义

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

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

常见用例

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

考虑最小触摸目标尺寸

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

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

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

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

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

实现 SwitchRadioButtonCheckbox 等选择控件时,您通常需要将可点击行为向上传给父级容器,将对可组合项的点击回调设置为 null,并为父级可组合项添加一个 toggleableselectable 修饰符。

@Composable
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
fun DefaultPreview() {
   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
fun DefaultPreview() {
   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
fun ArticleListItem(openArticle: () -> Unit) {
   Row(
       Modifier.clickable(
           // R.string.action_read_article = "read article"
           onClickLabel = stringResource(R.string.action_read_article),
           onClick = openArticle
       )
   ) {
       // ..
   }
}

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

@Composable
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
fun ShareButton(onClick: () -> Unit) {
  IconButton(onClick = onClick) {
    Icon(
      imageVector = Icons.Filled.Share,
      contentDescription = stringResource(R.string.label_share)
    )
  }
}

有一些视觉元素纯粹只起装饰效果,您可能不想向用户传达。将 contentDescription 参数设置为 null 时,您需要向 Android 框架说明此元素没有关联的操作或状态。

@Composable
fun PostImage(post: Post, modifier: Modifier = Modifier) {
  val image = post.imageThumb ?: imageResource(R.drawable.placeholder_1_1)

  Image(
    bitmap = 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
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.h5,
    modifier = Modifier.semantics { heading() }
  )
}

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

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

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

了解详情

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