合并和清除

当无障碍服务在屏幕上浏览元素时,请务必以正确的粒度对这些元素进行分组、分离或甚至隐藏。如果屏幕上每一个低级别可组合项均独立突出显示,用户必须进行大量互动才能在屏幕上移动。但如果元素过度合并,用户可能不知道哪些元素在逻辑上属于同一类别。如果屏幕上有纯粹起装饰作用的元素,则可以将其隐藏起来,以免无障碍服务识别到。在这些情况下,您可以使用 Compose API 合并、清除和隐藏语义。

合并语义

当您将 clickable 修饰符应用于父级可组合项时,Compose 会自动合并其下的所有子元素。如需了解交互式 Compose Material 和 Foundation 组件默认如何使用合并策略,请参阅交互式元素部分。

组件通常由多个可组合项组成。这些可组合项可以组成一个逻辑组,并且每个可组合项都可能包含重要信息,但您可能仍希望无障碍服务将它们视为一个元素。

例如,假设有一个可组合项,用于显示用户的头像、名称以及一些额外信息:

一组包含用户名的界面元素。选择名称。
图 1. 一组包含用户名的界面元素。选择名称。

您可以在语义修饰符中使用 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")
        }
    }
}

无障碍服务现在会立即将焦点集中在整个容器上,合并其内容:

一组包含用户名的界面元素。所有元素均同时选中。
图 2. 一组包含用户名的界面元素。所有元素均同时选中。

每个语义属性都有定义的合并策略。例如,ContentDescription 属性会将所有 ContentDescription 后代值添加到列表中。您可以通过检查语义属性在 SemanticsProperties.kt 中的 mergePolicy 实现来检查语义属性的合并策略。属性可以采用父项或子项值,将值合并到列表或字符串中,完全不允许合并并抛出异常,也可以选择任何其他自定义合并策略。

在其他情况下,您希望将子级语义合并到父级语义中,但这不会发生。在以下示例中,我们有一个包含子元素的 clickable 列表项父项,并且我们可能希望父项合并所有这些元素:

包含图片、一些文字和书签图标的列表项
图 3. 包含图片、一些文字和书签图标的列表项。

@Composable
private fun ArticleListItem(
    openArticle: () -> Unit,
    addToBookmarks: () -> Unit,
) {

    Row(modifier = Modifier.clickable { openArticle() }) {
        // Merges with parent clickable:
        Icon(
            painter = painterResource(R.drawable.ic_logo),
            contentDescription = "Article thumbnail"
        )
        ArticleDetails()

        // Defies the merge due to its own clickable:
        BookmarkButton(onClick = addToBookmarks)
    }
}

当用户按 clickableRow 时,系统会打开相应文章。嵌套在其中的是用于为文章添加书签的 BookmarkButton。此嵌套按钮显示为未合并,而行中的其余子内容会合并:

合并的树在 Row 节点内的列表中包含许多文字。未合并的树包含每个 Text 可组合项的单独节点。
图 4. 合并的树在 Row 节点内的列表中包含许多文字。未合并的树包含每个 Text 可组合项的单独节点。

某些可组合项不会按设计自动合并到父级下。当子项也合并时,父项无法合并其子项,无论是通过明确设置 mergeDescendants = true,还是通过自身合并的组件(例如按钮或可点击项)。了解某些 API 如何合并或拒绝合并,有助于您调试某些可能意外的行为。

当子元素在其父元素下构成逻辑上合理的组时,请使用合并。不过,如果嵌套的子项需要手动调整或移除自己的语义,其他 API 可能更适合您的需求(例如 clearAndSetSemantics)。

清除和设置语义

如果需要完全清除或覆盖语义信息,可以使用强大的 clearAndSetSemantics API。

当组件需要清除自己的语义及其子孙语义时,请将此 API 与空 lambda 搭配使用。当必须覆盖其语义时,请在 lambda 中添加新内容。

请注意,使用空 lambda 进行清除时,系统不会将已清除的语义发送给使用此类信息的任何使用方,例如无障碍功能、自动填充或测试。使用 clearAndSetSemantics{/*semantic information*/} 覆盖内容时,新语义会取代该元素及其后代之前的所有语义。

以下是自定义切换开关组件的示例,由包含图标和文本的可交互行表示:

// Developer might intend this to be a toggleable.
// Using `clearAndSetSemantics`, on the Row, a clickable modifier is applied,
// a custom description is set, and a Role is applied.

@Composable
fun FavoriteToggle() {
    val checked = remember { mutableStateOf(true) }
    Row(
        modifier = Modifier
            .toggleable(
                value = checked.value,
                onValueChange = { checked.value = it }
            )
            .clearAndSetSemantics {
                stateDescription = if (checked.value) "Favorited" else "Not favorited"
                toggleableState = ToggleableState(checked.value)
                role = Role.Switch
            },
    ) {
        Icon(
            imageVector = Icons.Default.Favorite,
            contentDescription = null // not needed here

        )
        Text("Favorite?")
    }
}

虽然图标和文本具有一些语义信息,但它们并不能表明此组件是可切换的。合并是不够的,因为您必须提供有关该组件的其他信息。

由于上面的代码段会创建自定义切换开关组件,因此您需要添加切换开关功能以及 stateDescriptiontoggleableStaterole 语义。这样,系统就会提供组件状态和关联的操作。例如,TalkBack 会读出“点按两次即可切换”,而不是“点按两次即可启用”。

通过清除原始语义并设置新的、更具描述性的语义,无障碍服务现在可以看到这是一个可切换组件,可以切换状态。

使用 clearAndSetSemantics 时,请考虑以下事项:

  • 由于设置此 API 后服务不会收到任何信息,因此最好谨慎使用。
    • AI 客服人员和类似服务可能会使用语义信息来理解屏幕,因此应仅在必要时清除这些信息。
  • 您可以在 API lambda 中设置自定义语义。
  • 修饰符的顺序很重要:无论其他合并策略如何,此 API 都会清除应用其位置之后的所有语义。

隐藏语义

在某些情况下,无需将元素发送到无障碍服务,因为它们的额外信息对于无障碍功能来说可能多余,或者只是纯粹的视觉装饰性元素,不具互动性。在这些情况下,您可以使用 hideFromAccessibility API 隐藏元素。

以下示例中显示了可能需要隐藏的组件:跨组件的冗余水印,以及用于装饰性地分隔信息的字符。

@Composable
fun WatermarkExample(
    watermarkText: String,
    content: @Composable () -> Unit,
) {
    Box {
        WatermarkedContent()
        // Mark the watermark as hidden to accessibility services.
        WatermarkText(
            text = watermarkText,
            color = Color.Gray.copy(alpha = 0.5f),
            modifier = Modifier
                .align(Alignment.BottomEnd)
                .semantics { hideFromAccessibility() }
        )
    }
}

@Composable
fun DecorativeExample() {
    Text(
        modifier =
        Modifier.semantics {
            hideFromAccessibility()
        },
        text = "A dot character that is used to decoratively separate information, like •"
    )
}

在此处使用 hideFromAccessibility 可确保水印和装饰元素对无障碍服务不可见,但仍保留其语义以供其他用例(例如测试)使用。

应用场景细分

以下是使用场景摘要,可帮助您了解如何明确区分之前的 API:

  • 如果内容不打算供无障碍服务使用,请执行以下操作:
    • 如果内容可能是装饰性或冗余内容,但仍必须进行测试,请使用 hideFromAccessibility
    • 当需要为所有服务清除父级和子级语义时,请将 clearAndSetSemantics{} 与空 lambda 搭配使用。
    • 当需要手动设置组件的语义时,请将 clearAndSetSemantics{/*content*/} 与 lambda 内的内容搭配使用。
  • 当内容应被视为一个实体且需要其所有子项的信息才能完整时:
    • 使用合并语义后代。
列出了不同 API 用例的表格。
图 5. 表格:API 的差异化用例。