One card, four announcements — merging is the fix

A composite component produces one semantics node per child by default, so a card becomes four swipes. mergeDescendants, clearAndSetSemantics and invisibleToUser control the shape of the tree.

6 min read
androidcomposekotlinaccessibility

Day 68 — One card, four announcements — merging is the fix

Day 68 of 100. Yesterday's tree has a shape, and the shape is what decides whether your list takes twelve swipes to read or three.

The symptom

A product card that reads as four separate items:

Card(onClick = { openProduct(product.id) }) {
    Column(Modifier.padding(16.dp)) {
        AsyncImage(product.imageUrl, contentDescription = product.name)
        Text(product.name, style = MaterialTheme.typography.titleMedium)
        Text(product.price, style = MaterialTheme.typography.bodyMedium)
        Text("${product.rating} stars", style = MaterialTheme.typography.labelSmall)
    }
}

TalkBack announces the image, then the name, then the price, then the rating — four swipes for one product. A list of twenty products takes eighty swipes to skim.

Worse, none of those four nodes is the clickable one, so the user hears the content and then has to find the card itself to activate it.

Why the obvious fix fails

The obvious fix is to describe the card and hide the children:

Card(
    onClick = { … },
    modifier = Modifier.semantics {
        contentDescription = "${product.name}, ${product.price}, ${product.rating} stars"
    },
) { … }

This adds a fifth node rather than replacing the four. semantics { } contributes properties to a node; it doesn't remove the descendants, so now the card announces its description and the children still announce themselves.

You need to say something about the subtree, not just about the card.

Unmerged children are separate swipes; merging collapses them into one node

The actual mechanism

Three tools, in increasing order of how much they take away.

mergeDescendants = true — collapse the subtree into one node, concatenating the children's text:

Modifier.semantics(mergeDescendants = true) { }

The card becomes one node announcing "Wireless headphones, £79.99, 4.5 stars", and it's the clickable node, so activating it works from the same place.

Merging happens automatically wherever a component declares an action — clickable, toggleable, selectable, and Material components built on them. That's why a Button containing an icon and a label is already one node, and why Day 37's toggleable row worked without any explicit merge.

clearAndSetSemantics { } — discard everything from the subtree and state something new:

Modifier.clearAndSetSemantics {
    contentDescription = "Rating: 4.5 out of 5"
}

Right when the concatenation would be wrong. A five-star row would merge to "star, star, star, star, star"; clearing and replacing it says the useful thing instead. It's the heavier tool, and it removes information you might have wanted, so reach for merge first.

Modifier.semantics { invisibleToUser() } — remove a single node while leaving its siblings. For a decorative element inside an otherwise-meaningful subtree.

What merging does and doesn't concatenate

Worth knowing precisely, because the result surprises people.

Merging combines contentDescription and text from descendants, in layout order. It does not merge nodes that themselves declare a merge — a nested Button inside a merged card stays a separate node, because it has its own action and must remain independently reachable.

That last rule is the one that saves you: a card with a "Add to basket" button inside merges the card's text but leaves the button as its own node. Both are reachable, and the button's own label isn't swallowed into the card's announcement.

If a nested clickable should be swallowed — a decorative tappable area — remove its action rather than fighting the merge.

Order matters, and it's layout order

The concatenation follows layout order, so this:

Column {
    Text(product.price)      // "£79.99"
    Text(product.name)       // "Wireless headphones"
}

announces "£79.99, Wireless headphones" — price first, which is not how anyone would say it. Reordering the composables fixes the announcement and changes the visual layout, which is often not acceptable.

When the visual order and the spoken order genuinely need to differ, clearAndSetSemantics with an explicit description is the honest answer. Tomorrow's post covers traversalIndex for the cases where the order needs changing without replacing the content.

The list case

Putting it together for the most common structure in any app:

LazyColumn {
    items(products, key = { it.id }) { product ->
        Card(
            onClick = { open(product.id) },
            modifier = Modifier.semantics {
                // merged automatically by onClick; add what the visuals imply
                stateDescription = if (product.inBasket) "In basket" else null.orEmpty()
                customActions = listOf(
                    CustomAccessibilityAction("Add to basket") { add(product); true },
                    CustomAccessibilityAction("Save for later") { save(product); true },
                )
            },
        ) { ProductContent(product) }
    }
}

One node per product, its content in a sensible order, its swipe actions exposed as custom actions from Day 67. Twenty products, twenty swipes, every action reachable.

The testing consequence

Merging changes what your tests see, which is the other reason to care:

onNodeWithText("Wireless headphones")           // fails on a merged card
onNodeWithText("Wireless headphones", useUnmergedTree = true)   // finds it

The default test tree is the merged one, so a test looking for a child's text inside a merged component won't find it. useUnmergedTree = true opts into the raw tree.

Which of the two you should use is a real question: asserting against the merged tree tests what a user experiences, which is usually the more valuable assertion. Reaching for useUnmergedTree to make a selector work is worth a moment's thought — it sometimes means the test is checking an implementation detail.

How to prove it

Print both trees and compare:

composeRule.onRoot().printToLog("MERGED")
composeRule.onRoot(useUnmergedTree = true).printToLog("UNMERGED")

The merged dump should have roughly one node per thing a user would consider an item. If a card produces four nodes there, it isn't merging; if a five-star row produces one node saying "star star star star star", it needs clearing rather than merging.

On device, the swipe count is the metric. Skim a list with TalkBack and count — if reading ten items takes forty swipes, the tree is four times too detailed.

The testTag exception

One small thing that trips people once. testTag is normally not merged into a parent, so a tag on a child inside a merged card isn't findable in the merged tree.

Modifier.semantics(mergeDescendants = true) {
    testTagsAsResourceId = true      // for UIAutomator / cross-framework tools
}

For ordinary Compose tests, the answer is to put the tag where you want the test to grab — on the merged node itself for "the card", or query the unmerged tree for a child. Adding tags to every child and then wondering why the selectors fail is the usual path here, and the tree dump from above resolves it in seconds.

What this generalizes to

The principle is the structure of the description should match the structure of the content, not the structure of the code. A card is one thing to a user and four composables to you, and the semantics tree is where that difference gets reconciled.

It's the same instinct as Day 50's AnnotatedString: a sentence is one thing even when it's assembled from parts, and modelling it as its parts pushes the reassembly onto whoever consumes it. Here the consumer is a person listening.

And as with that post, the payoff is not confined to the audience you built it for. Merging a card correctly gives a screen-reader user one clear announcement, gives your tests a single node to assert on, and gives Switch Access one target instead of four. One change, three beneficiaries.

Tomorrow, Day 69: traversal order — controlling the sequence, for the cases where layout order is the wrong order.


Day 68 of a 100-day series on Jetpack Compose, working through the official documentation in order. Source: Merging and clearing semantics.