语义

除了可组合项携带的主要信息(例如 Text 可组合项的文本字符串)之外,提供有关界面元素的更多补充信息也很有帮助。

有关 Compose 中组件的含义和角色的信息称为语义,这是一种向无障碍、自动填充和测试等服务提供有关可组合项的额外上下文信息的方式。例如,相机图标在视觉上可能只是一张图片,但其语义可能是“拍照”。

通过将适当的语义与适当的 Compose API 结合使用,您可以向无障碍服务提供尽可能多的组件相关信息,然后由无障碍服务决定如何向用户呈现这些信息。

Material 和 Compose 界面以及 Foundation API 附带内置语义,这些语义遵循其特定角色和功能,但您也可以根据具体要求修改现有 API 的这些语义,或为自定义组件设置新语义。

语义属性

语义属性传达了对应可组合项的含义。例如,Text 可组合项包含语义属性 text,因为这是该可组合项的含义。Icon 包含一个 contentDescription 属性(如果由开发者设置),该属性以文字形式传达图标的含义。

思考语义属性如何传达可组合项的含义。假设有一个 Switch。以下是用户看到的内容:

图 1. 处于“开启”和“关闭”状态的 Switch。

如需描述此元素的含义,您可以这样说:“这是一个 Switch,它是可切换元素,目前处于‘开启’状态。您可以点击它以进行交互。”

这正是语义属性的用途。此 Switch 元素的语义节点包含以下属性,由布局检查器直观呈现:

显示 Switch 可组合项的语义属性的布局检查器
图 2. 显示 Switch 可组合项的语义属性的布局检查器。

Role 表示元素类型。StateDescription 描述应如何引用“开启”状态。默认情况下,这是“On”一词的本地化版本,但可以根据上下文使用更具体的内容(例如,“启用”)。ToggleableState 是 Switch 的当前状态。OnClick 属性引用了用于与此元素进行交互的方法。

跟踪应用中每个可组合项的语义属性可以释放出诸多强大的可能性:

  • 无障碍服务使用一些属性来表示屏幕上显示的界面,并允许用户与界面进行交互。对于 Switch 可组合项,TalkBack 可能会播报:“开启 Switch;点按两次即可切换”。用户可以点按两次屏幕切换为关闭 Switch。
  • 测试框架使用这些属性来查找节点、与节点进行交互并做出声明:
    val mySwitch = SemanticsMatcher.expectValue(
        SemanticsProperties.Role, Role.Switch
    )
    composeTestRule.onNode(mySwitch)
        .performClick()
        .assertIsOff()

在 Compose 基础库基础上构建的可组合项和修饰符默认已经为您设置了相关属性。(可选)您可以手动更改这些属性,以改进对特定用例的无障碍支持,或更改可组合项的合并或清除策略。

如需向无障碍服务指明组件的具体内容类型,您可以应用各种不同的语义。这些新增内容将支持现有的主要语义信息,并帮助无障碍服务微调组件的表示、朗读或交互方式。

如需查看语义属性的完整列表,请参阅 SemanticsProperties 对象。如需查看可能的无障碍操作的完整列表,请参阅 SemanticsActions 对象。

标题

应用通常包含具有丰富文本内容的界面,例如长文章或新闻页面,这些内容通常会通过标题划分为不同的子部分:

一篇博文,其文章文本位于可滚动容器中。
图 3. 一篇博文,其文章文本位于可滚动容器中。

有无障碍需求的用户可能很难轻松浏览此类屏幕。为了改善导航体验,某些无障碍服务允许用户更轻松地直接在各个部分或标题之间导航。如需启用此功能,请通过定义组件的语义属性来指明组件是 heading:

@Composable
private fun Subsection(text: String) {
    Text(
        text = text,
        style = MaterialTheme.typography.headlineSmall,
        modifier = Modifier.semantics { heading() }
    )
}

提醒和弹出式窗口

如果您的组件是提醒或弹出式窗口(例如 Snackbar),您可能需要向无障碍服务发出信号,以便向用户传达新结构或内容更新。

类似提醒的组件可以使用 liveRegion 语义属性进行标记。这样,无障碍服务就可以自动向用户通知此组件或其子组件的更改:

PopupAlert(
    message = "You have a new message",
    modifier = Modifier.semantics {
        liveRegion = LiveRegionMode.Polite
    }
)

在大多数情况下,如果用户只需短暂关注屏幕上的提醒或重要变化内容,您应使用 liveRegionMode.Polite。

您应谨慎使用 liveRegion.Assertive,以免造成干扰性反馈。 在以下情况下,必须让用户了解时效性内容:

PopupAlert(
    message = "Emergency alert incoming",
    modifier = Modifier.semantics {
        liveRegion = LiveRegionMode.Assertive
    }
)

动态区域不应在频繁更新的内容(例如倒计时器)上使用,以免用户因不断收到反馈而感到不知所措。

类似窗口的组件

类似于 ModalBottomSheet 的窗口式自定义组件需要额外的信号来与周围内容区分开来。为此,您可以使用 paneTitle 语义,以便无障碍服务可以适当表示任何相关的窗口或窗格更改,以及其主要语义信息:

ShareSheet(
    message = "Choose how to share this photo",
    modifier = Modifier
        .fillMaxWidth()
        .align(Alignment.TopCenter)
        .semantics { paneTitle = "New bottom sheet" }
)

如需参考,请参阅 Material 3 如何为其组件使用 paneTitle。

错误组件

对于其他内容类型(例如类似错误的组件),您可能需要为有无障碍需求的用户展开主要语义信息。 定义错误状态时,您可以向无障碍服务告知其 error 语义,并提供展开后的出错提示。

在此示例中,TalkBack 会先朗读主要错误文本信息,然后朗读其他展开的消息:

Error(
    errorText = "Fields cannot be empty",
    modifier = Modifier
        .semantics {
            error("Please add both email and password")
        }
)

进度跟踪组件

对于跟踪进度的自定义组件,您可能需要向用户通知其进度变化,包括当前进度值、其范围和步长。您可以使用 progressBarRangeInfo 语义来实现此目的,这可确保无障碍服务能够感知进度变化,并相应地更新用户。不同的辅助技术也可能有独特的方式来提示进度增加和减少。

ProgressInfoBar(
    modifier = Modifier
        .semantics {
            progressBarRangeInfo =
                ProgressBarRangeInfo(
                    current = progress,
                    range = 0F..1F
                )
        }
)

清单和内容信息

在包含许多项的自定义列表和网格中,无障碍服务可能还需要接收更详细的信息(例如项总数和索引),这会很有帮助。

通过分别在列表和列表项上使用 collectionInfo 和 collectionItemInfo 语义,在此长列表中,无障碍服务除了可以告知用户文本语义信息之外,还可以告知用户他们位于总集合中的哪个列表项索引:

MilkyWayList(
    modifier = Modifier
        .semantics {
            collectionInfo = CollectionInfo(
                rowCount = milkyWay.count(),
                columnCount = 1
            )
        }
) {
    milkyWay.forEachIndexed { index, text ->
        Text(
            text = text,
            modifier = Modifier.semantics {
                collectionItemInfo =
                    CollectionItemInfo(index, 0, 0, 0)
            }
        )
    }
}

状态说明

可组合项可以为语义定义 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() }
            )
    ) {
        /* ... */
    }
}

自定义操作

自定义操作可用于更复杂的触摸屏手势,例如滑动关闭或拖放,因为这些手势对于有运动障碍或其他残疾的用户来说可能很难完成。

为了让滑动关闭手势更易于使用,您可以将其关联到自定义操作,并在其中传递关闭操作和标签:

SwipeToDismissBox(
    modifier = Modifier.semantics {
        // Represents the swipe to dismiss for accessibility
        customActions = listOf(
            CustomAccessibilityAction(
                label = "Remove article from list",
                action = {
                    removeArticle()
                    true
                }
            )
        )
    },
    state = rememberSwipeToDismissBoxState(),
    backgroundContent = {}
) {
    ArticleListItem()
}

然后,TalkBack 等无障碍服务会突出显示该组件,并提示其菜单中提供了更多操作,其中就包括滑动关闭操作:

TalkBack 操作菜单的可视化效果
图 4. TalkBack 操作菜单的可视化。

自定义操作的另一个用例是包含更多可用操作的项的长列表,因为用户可能很难单独遍历每个项的每个操作:

=屏幕上“开关控制”导航的可视化
图 5. 屏幕上的“开关控制”导航功能示意图。

为了改善导航体验(这对于基于互动的辅助技术(例如“开关控制”或“语音操控”)尤其有用),您可以在容器上使用自定义操作,将操作从单独的遍历中移出,并移入单独的操作菜单:

ArticleListItemRow(
    modifier = Modifier
        .semantics {
            customActions = listOf(
                CustomAccessibilityAction(
                    label = "Open article",
                    action = {
                        openArticle()
                        true
                    }
                ),
                CustomAccessibilityAction(
                    label = "Add to bookmarks",
                    action = {
                        addToBookmarks()
                        true
                    }
                ),
            )
        }
) {
    Article(
        modifier = Modifier.clearAndSetSemantics { },
        onClick = openArticle,
    )
    BookmarkButton(
        modifier = Modifier.clearAndSetSemantics { },
        onClick = addToBookmarks,
    )
}

在这些情况下,请务必使用 clearAndSetSemantics 修饰符手动清除原始子级的语义,因为您要将它们移至自定义操作中。

以“开关控制”为例,其菜单会在选择容器后打开,并列出其中可用的嵌套操作:

文章列表项的“开关控制”突出显示
图 6. 文章列表项的“开关控制”突出显示。
“开关控制”操作菜单的可视化。
图 7. “开关控制”操作菜单的可视化效果。

语义树

组合将描述应用的界面,并通过运行可组合项来生成。组合是描述界面的可组合项的树结构。

组合旁边存在一个名为语义数的并行树。此树以无障碍服务和测试框架可以理解的替代方式描述您的界面。无障碍服务使用该树向有特定需求的用户描述应用。测试框架使用该树与您的应用进行交互并作出相关声明。语义树不包含有关如何绘制可组合项的 信息,而是包含关于 可组合项的语义含义的信息。

典型的界面层次结构及其语义树
图 8. 典型的界面层次结构及其语义树。

如果您的应用由 Compose 基础和 Material 库中的可组合项和修饰符组成,系统会自动为您填充并生成语义树。但是,在添加自定义低级别可组合项时,您必须手动提供其语义。有时,您的树无法正确或完全表示屏幕上元素的含义,在这种情况下,您可以调整树。

例如,请考虑该自定义日历可组合项:

具有可选日期元素的自定义日历可组合项
图 9. 具有可选日期元素的自定义日历可组合项。

在此示例中,整个日历实现为单个低级可组合项,使用 Layout 可组合项并直接绘制为 Canvas。如果您不执行任何其他操作,无障碍服务将无法接收到足够的有关可组合项内容以及用户在日历中所做选择的信息。例如,如果用户点击包含 17 的日期,则无障碍服务框架只会接收整个日历控件的说明信息。在这种情况下,TalkBack 无障碍服务只会读出“日历”,稍微好点儿的话,或许会读出“四月日历”,而用户可能会好奇究竟选中了哪一天。如需使此可组合项更没有障碍,您需要手动添加语义信息。

合并和未合并的树

如前文所述,界面树中的每个可组合项都可能设置了零个或多个语义属性。如果可组合项未设置语义属性,那么它不会包含在语义树中。这样一来,语义树便仅包含实际包含语义含义的节点。然而,有时为了传达屏幕上所显示内容的正确含义,合并某些节点树并将它们视为一个树也十分有用。这样,您就可以对一组节点进行整体推断,而不是单独处理每个后代节点。一般来讲,该树中的每个节点都代表使用无障碍服务时可聚焦的一个元素。

Button 就属于这种可组合项。您可以将按钮作为单个元素进行推断,即使它可能包含多个子节点:

Button(onClick = { /*TODO*/ }) {
    Icon(
        imageVector = Icons.Filled.Favorite,
        contentDescription = null
    )
    Spacer(Modifier.size(ButtonDefaults.IconSpacing))
    Text("Like")
}

在语义树中,合并了按钮后代节点的属性,并将按钮作为树中的单个叶节点呈现:

合并的单叶语义表示
图 10. 合并的单叶语义表示。

可组合项和修饰符可通过调用 Modifier.semantics (mergeDescendants = true) {} 指示它们希望合并其后代节点的语义属性。将此属性设置为 true 指示应合并语义属性。在 Button 示例中,Button 可组合项在内部使用包含此 semantics 修饰符的 clickable 修饰符。因此,按钮的后代节点会合并。 您可以阅读无障碍服务文档,详细了解应在何时更改可组合项中的合并行为。

基础库和 Material Compose 库中的几个修饰符和可组合项已设置此属性。例如,clickable 和 toggleable 修饰符会自动合并其后代节点。此外,ListItem 可组合项也会合并其后代节点。

检查树

语义树实际上是两个不同的树。有一个合并的语义树,它会在 mergeDescendants 设置为 true 时合并后代节点。此外,还有一个未合并的语义树,它不会应用合并,但会保持每个节点不变。无障碍服务使用未合并的树并应用自己的合并算法,还会考虑 mergeDescendants 属性。默认情况下,测试框架使用合并的树。

您可以使用 printToLog() 方法检查这两个树。默认情况下,与前面的示例一样,系统会记录合并的树。如需改为输出未合并的树,请将 onRoot() 匹配器的 useUnmergedTree 参数设置为 true:

composeTestRule.onRoot(useUnmergedTree = true).printToLog("MY TAG")

借助布局检查器,您可以通过在视图过滤器中选择首选的树,同时显示合并和未合并的语义树:

布局检查器视图选项,允许同时显示合并和未合并的语义树
图 11. 布局检查器视图选项,允许同时显示合并和未合并的语义树。

对于树中的每个节点,布局检查器会在属性面板中显示合并语义以及在该节点上设置的语义:

语义属性已合并和设置
图 12. 合并并设置了语义属性。

默认情况下,测试框架中的匹配器会使用合并的语义树。因此,您可以通过匹配 Button 中显示的文字与之进行交互:

composeTestRule.onNodeWithText("Like").performClick()

您可以通过将匹配器的 useUnmergedTree 参数设置为 true 来替换此行为,就像使用 onRoot 匹配器一样。

调整树

如前所述,您可以替换或清除某些语义属性,或更改树的合并行为。在您创建自己的自定义组件时,尤为如此。如果没有设置正确的属性和合并行为,则应用可能无法访问,并且测试的行为可能与预期有所不同。如果您想详细了解测试,请参阅测试指南。