语义

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

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

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

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

语义属性

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

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

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

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

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

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

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

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

  • 无障碍服务使用这些属性来表示屏幕上显示的界面,并允许用户与其互动。对于 Switch 可组合项,TalkBack 可能会读出:“开启;开关;点按两次即可切换”。用户可以点按两次屏幕切换为关闭 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
                )
        }
)

列表和内容信息

在包含许多项的自定义列表和网格中,辅助功能服务可能还需要接收更详细的信息,例如项和索引的总数。

通过分别对列表和项使用 collectionInfocollectionItemInfo 语义,在长列表中,无障碍服务除了提供文本语义信息之外,还可以告知用户他们在整个集合中的项编号:

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 库中的几个修饰符和可组合项已设置此属性。例如,clickabletoggleable 修饰符会自动合并其后代节点。此外,ListItem 可组合项也会合并其后代节点。

检查树

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

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

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

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

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

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

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

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

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

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

调整树

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