Control traversal order

By default, accessibility screen reader behavior in a Compose app is implemented in expected reading order, which is usually left-to-right, then top-to-bottom. However, there are some types of app layouts where the algorithm can't determine the actual reading order without additional hints. In view-based apps, you can fix such issues using the traversalBefore and traversalAfter properties. Starting in Compose 1.5, Compose provides an equally flexible API, but with a new conceptual model.

isTraversalGroup and traversalIndex are semantic properties that let you control accessibility and TalkBack focus order in scenarios where the default sorting algorithm is not appropriate. isTraversalGroup identifies semantically important groups, while traversalIndex adjusts the order of individual elements within those groups. You can use isTraversalGroup alone, or with traversalIndex for further customization.

Use isTraversalGroup and traversalIndex in your app to control screen reader traversal order.

Group elements with isTraversalGroup

isTraversalGroup is a boolean property that defines whether a semantics node is a traversal group. This type of node is one whose function is to serve as a boundary or border in organizing the node's children.

Setting isTraversalGroup = true on a node means that all children of that node are visited before moving to other elements. You can set isTraversalGroup on non-screen reader focusable nodes, such as Columns, Rows, or Boxes.

The following example uses isTraversalGroup. It emits four text elements. The left two elements belong to one CardBox element, while the right two elements belong to another CardBox element:

// 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
        )
    }
}

The code produces output similar to the following:

Layout with two columns of text, with the left column reading 'This
  sentence is in the left column' and the right column reading 'This sentence is on the right.'
Figure 1. A layout with two sentences (one in the left column and one in the right column).

Because no semantics have been set, the default behavior of the screen reader is to traverse elements from left to right and top to bottom. Because of this default, TalkBack reads out the sentence fragments in the wrong order:

"This sentence is in" → "This sentence is" → "the left column." → "on the right."

To order the fragments correctly, modify the original snippet to set isTraversalGroup to 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 }
        )
    }
}

Because isTraversalGroup is set specifically on each CardBox, the CardBox boundaries apply when sorting their elements. In this case, the left CardBox is read first, followed by the right CardBox.

Now, TalkBack reads out the sentence fragments in the correct order:

"This sentence is in" → "the left column." → "This sentence is" → "on the right."

Further customize traversal order

traversalIndex is a float property that lets you customize TalkBack traversal order. If grouping elements together is not enough for TalkBack to work correctly, use traversalIndex in conjunction with isTraversalGroup to further customize screen reader ordering.

The traversalIndex property has the following characteristics:

  • Elements with lower traversalIndex values are prioritized first.
  • Can be positive or negative.
  • The default value is 0f.
  • Only affects screen reader-focusable nodes, such as on-screen elements like text or buttons. For example, setting only traversalIndex on a column would have no effect, unless the column has isTraversalGroup set on it as well.

The following example shows how you can use traversalIndex and isTraversalGroup together.

Example: Traverse clock face

A clock face is a common scenario where standard traversal ordering does not work. The example in this section is a time picker, where a user can traverse through the numbers on a clock face and select digits for the hour and minute slots.

A clock face with a time picker above it.
Figure 2. An image of a clock face.

In the following simplified snippet, there is a CircularLayout in which 12 numbers are drawn, starting with 12 and moving clockwise around the circle:

@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())
    }
}

Because the clock face is not read logically with the default left-to-right and top-to-bottom ordering, TalkBack reads the numbers out of order. To rectify this, use the incrementing counter value, as shown in the following snippet:

@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())
    }
}

To properly set the traversal ordering, first make the CircularLayout a traversal group and set isTraversalGroup = true. Then, as each clock text is drawn onto the layout, set its corresponding traversalIndex to the counter value.

Because the counter value continually increases, each clock value's traversalIndex is larger as numbers are added to the screen—the clock value 0 has a traversalIndex of 0, and the clock value 1 has a traversalIndex of 1. In this way, the order that TalkBack reads them in is set. Now, the numbers inside the CircularLayout are read in the expected order.

Because the traversalIndexes that have been set are only relative to other indexes within the same grouping, the rest of the screen ordering has been preserved. In other words, the semantic changes shown in the preceding code snippet only modify the ordering within the clock face that has isTraversalGroup = true set.

Note that, without setting CircularLayout's semantics to isTraversalGroup = true, the traversalIndex changes still apply. However, without the CircularLayout to bind them, the twelve digits of the clock face are read last, after all other elements on the screen have been visited. This occurs because all other elements have a default traversalIndex of 0f, and the clock text elements are read after all other 0f elements.

Example: Customize traversal order for floating action button

In this example, traversalIndex and isTraversalGroup control the traversal ordering of a Material Design floating action button (FAB). The basis of this example is the following layout:

A layout with a top app bar, sample text, a floating action button, and
  a bottom app bar.
Figure 3. Layout with a top app bar, sample text, a floating action button, and a bottom app bar.

By default, the layout in this example has the following TalkBack order:

Top App Bar → Sample texts 0 through 6 → floating action button (FAB) → Bottom App Bar

You may want the screen reader to first focus on the FAB. To set a traversalIndex on a Material element like a FAB, do the following:

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

In this snippet, creating a box with isTraversalGroup set to true and setting a traversalIndex on the same box (-1f is lower than the default value of 0f) means that the floating box comes before all other on-screen elements.

Next, you can put the floating box and other elements into a scaffold, which implements a Material Design layout:

@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 interacts with the elements in the following order:

FAB → Top App Bar → Sample texts 0 through 6 → Bottom App Bar

Additional resources