控制遍历顺序

默认情况下,Compose 应用中的无障碍功能屏幕阅读器行为会按照预期的阅读顺序(通常是从左到右,然后从上到下)实现。不过,对于某些类型的应用布局,如果没有额外的提示,算法就无法确定实际的阅读顺序。在基于视图的应用中,您可以使用 traversalBeforetraversalAfter 属性修复此类问题。从 Compose 1.5 开始,Compose 提供了一个同样灵活的 API,但采用了新的概念模型。

isTraversalGrouptraversalIndex 是语义属性,可让您在默认排序算法不适用的情况下控制无障碍功能和 TalkBack 焦点顺序。isTraversalGroup 用于标识语义上重要的组,而 traversalIndex 用于调整这些组中各个元素的顺序。您可以单独使用 isTraversalGroup,也可以与 traversalIndex 一起使用以进行进一步自定义。

在应用中使用 isTraversalGrouptraversalIndex 来控制屏幕阅读器的遍历顺序。

使用 isTraversalGroup 为元素分组

isTraversalGroup 是一个布尔值属性,用于定义 semantics 节点是否为遍历组。这种类型的节点的作用是在组织节点的子节点时充当边界或边界。

在节点上设置 isTraversalGroup = true 意味着,系统会先访问该节点的所有子节点,然后再移至其他元素。您可以在非屏幕阅读器可聚焦节点(例如 Column、Row 或 Box)上设置 isTraversalGroup

以下示例使用 isTraversalGroup。它会发出四个文本元素。左侧两个元素属于一个 CardBox 元素,而右侧两个元素属于另一个 CardBox 元素:

// CardBox() function takes in top and bottom sample text.
@Composable
fun CardBox(
    topSampleText: String,
    bottomSampleText: String,
    modifier: Modifier = Modifier
) {
    Box(modifier) {
        Column {
            Text(topSampleText)
            Text(bottomSampleText)
        }
    }
}

@Composable
fun TraversalGroupDemo() {
    val topSampleText1 = "This sentence is in "
    val bottomSampleText1 = "the left column."
    val topSampleText2 = "This sentence is "
    val bottomSampleText2 = "on the right."
    Row {
        CardBox(
            topSampleText1,
            bottomSampleText1
        )
        CardBox(
            topSampleText2,
            bottomSampleText2
        )
    }
}

代码会生成类似于以下内容的输出:

包含两列文本的布局,左列显示“This sentence is in the left column”(这个句子位于左列),右列写为“This sentence is the right.”。
图 1. 包含两个句子(一个位于左列,一个位于右列)的布局。

由于未设置语义,因此屏幕阅读器的默认行为是从左到右、从上到下遍历元素。由于存在此默认情况,TalkBack 会以错误的顺序读出句子片段:

“This sentence is in”→“This sentence is”→“the left column”。→“位于右侧”。

如需对 fragment 正确排序,请修改原始代码段,将 isTraversalGroup 设置为 true

@Composable
fun TraversalGroupDemo2() {
    val topSampleText1 = "This sentence is in "
    val bottomSampleText1 = "the left column."
    val topSampleText2 = "This sentence is"
    val bottomSampleText2 = "on the right."
    Row {
        CardBox(
//      1,
            topSampleText1,
            bottomSampleText1,
            Modifier.semantics { isTraversalGroup = true }
        )
        CardBox(
//      2,
            topSampleText2,
            bottomSampleText2,
            Modifier.semantics { isTraversalGroup = true }
        )
    }
}

由于 isTraversalGroup 是针对每个 CardBox 专门设置的,因此在对其元素排序时,CardBox 边界会适用。在这种情况下,先读取左侧的 CardBox,然后读取右侧的 CardBox

现在,TalkBack 以正确顺序读出句子片段:

“This sentence is in”→“the left column”。→“This sentence is”→“on the right.”

进一步自定义遍历顺序

traversalIndex 是一个浮点属性,可让您自定义 TalkBack 遍历顺序。如果将元素分组不足以使 TalkBack 正常运行,请将 traversalIndexisTraversalGroup 结合使用,以进一步自定义屏幕阅读器的排序。

traversalIndex 属性具有以下特征:

  • traversalIndex 值较低的元素优先。
  • 可以是正面的,也可以是负面的。
  • 默认值为 0f
  • 仅影响屏幕阅读器可聚焦的节点,例如文本或按钮等屏幕上的元素。例如,对某个列仅设置 traversalIndex 不会产生任何影响,除非该列也设置了 isTraversalGroup

以下示例展示了如何将 traversalIndexisTraversalGroup 结合使用。

示例:遍历钟面

钟面是标准遍历排序不起作用的常见场景。本部分中的示例是一个时间选择器,用户可以在其中浏览钟面上的数字,并选择小时和分钟时段的数字位。

上面有一个时间选择器的钟面。
图 2. 钟面的图片。

在下面的简化代码段中,有一个 CircularLayout,其中绘制了 12 个数字,从 12 开始,围绕圆圈顺时针移动:

@Composable
fun ClockFaceDemo() {
    CircularLayout {
        repeat(12) { hour ->
            ClockText(hour)
        }
    }
}

@Composable
private fun ClockText(value: Int) {
    Box(modifier = Modifier) {
        Text((if (value == 0) 12 else value).toString())
    }
}

由于使用默认的从左到右和从上到下排序,系统无法以逻辑方式读取钟面,因此 TalkBack 会不按顺序读出数字。如需解决此问题,请使用递增计数器值,如以下代码段所示:

@Composable
fun ClockFaceDemo() {
    CircularLayout(Modifier.semantics { isTraversalGroup = true }) {
        repeat(12) { hour ->
            ClockText(hour)
        }
    }
}

@Composable
private fun ClockText(value: Int) {
    Box(modifier = Modifier.semantics { this.traversalIndex = value.toFloat() }) {
        Text((if (value == 0) 12 else value).toString())
    }
}

如需正确设置遍历顺序,请先将 CircularLayout 设为遍历组,并设置 isTraversalGroup = true。然后,当每个时钟文本被绘制到布局上时,将其对应的 traversalIndex 设置为计数器值。

由于计数器值会不断增加,因此每个时钟值的 traversalIndex 随着数字的增加而变大 - 时钟值 0 的 traversalIndex 为 0,时钟值 1 的 traversalIndex 为 1。这样,TalkBack 读取这些消息的顺序就设置好了。现在,系统会按照预期顺序读取 CircularLayout 内的数字。

由于已设置的 traversalIndexes 仅相对于同一分组中的其他索引,因此保留了屏幕排序的其余部分。换言之,上述代码段中显示的语义更改仅修改设置了 isTraversalGroup = true 的钟面中的顺序。

请注意,如果不将 CircularLayout's 语义设置为 isTraversalGroup = truetraversalIndex 更改仍然适用。不过,如果没有 CircularLayout 进行绑定,在访问屏幕上的所有其他元素之后,系统会读取钟面的 12 位数。这是因为所有其他元素的默认 traversalIndex0f,并且时钟文本元素是在所有其他 0f 元素之后读取的。

示例:自定义悬浮操作按钮的遍历顺序

在此示例中,traversalIndexisTraversalGroup 用于控制 Material Design 悬浮操作按钮 (FAB) 的遍历顺序。此示例的基础布局如下:

包含顶部应用栏、示例文本、悬浮操作按钮和底部应用栏的布局。
图 3. 包含顶部应用栏、示例文本、悬浮操作按钮和底部应用栏的布局。

默认情况下,此示例中的布局具有以下 TalkBack 顺序:

顶部应用栏 → 示例文本 0 至 6 → 悬浮操作按钮 (FAB) → 底部应用栏

您可能需要先让屏幕阅读器聚焦在 FAB 上。如需在 Material 元素(例如 FAB)上设置 traversalIndex,请执行以下操作:

@Composable
fun FloatingBox() {
    Box(modifier = Modifier.semantics { isTraversalGroup = true; traversalIndex = -1f }) {
        FloatingActionButton(onClick = {}) {
            Icon(imageVector = Icons.Default.Add, contentDescription = "fab icon")
        }
    }
}

在此代码段中,创建一个框并将 isTraversalGroup 设为 true 并在同一框上设置 traversalIndex-1f 小于默认值 0f)意味着浮动框位于屏幕上所有其他元素之前。

接下来,您可以将浮动框和其他元素放入基架中,从而实现 Material Design 布局:

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ColumnWithFABFirstDemo() {
    Scaffold(
        topBar = { TopAppBar(title = { Text("Top App Bar") }) },
        floatingActionButtonPosition = FabPosition.End,
        floatingActionButton = { FloatingBox() },
        content = { padding -> ContentColumn(padding = padding) },
        bottomBar = { BottomAppBar { Text("Bottom App Bar") } }
    )
}

TalkBack 会按以下顺序与这些元素互动:

FAB → 顶部应用栏 → 示例文本 0 到 6 → 底部应用栏

其他资源