DEV Community

Cover image for Part 4: When the Screen Changes Silently: Live Regions, Headings and Focus in Compose
Android Expert
Android Expert

Posted on Originally published at Medium

Part 4: When the Screen Changes Silently: Live Regions, Headings and Focus in Compose

Build for Everyone · Part 4 of 5

Previously: Parts 1 to 3 made static screens accessible and kept them that way with tests. This part is about everything that happens after the screen first appears.

TL;DR Sighted users notice change for free: a badge ticks up, an error turns red, results slide in. TalkBack users only notice what is spoken or what receives focus. Three tools close that gap:

  • Live regions announce changes without moving focus.
  • Headings let people jump between sections instead of swiping through everything.
  • Deliberate focus management puts the user where the next action is, but only at moments they’ll expect.

Each one is easy to add and just as easy to overdo, so this post spends as much time on restraint as on code.

The silent cart counter

A user is on a product page with TalkBack running. They double-tap Add to cart. On screen, the badge on the cart icon in the top bar ticks from 3 to 4. That’s the confirmation.

TalkBack says nothing. Its focus is still on the button, and the change happened somewhere else. The user doesn’t know whether the tap worked, so they tap again. Now there are five items in the cart, and they only wanted one more.

silent-cart

No crash, no visual bug, and the code from Part 1 passes every check in Part 3. The screen changed, and only some users were told.

Live regions: announce without moving

A live region is a node that asks accessibility services to announce it whenever its content changes, wherever focus happens to be. In Compose it’s one semantics property:

@Composable
fun CartAction(itemCount: Int, onOpenCart: () -> Unit) {
    val description = if (itemCount == 0) {
        stringResource(R.string.cd_cart_empty)
    } else {
        pluralStringResource(R.plurals.cd_cart_items, itemCount, itemCount)
    }

    IconButton(
        onClick = onOpenCart,
        modifier = Modifier.semantics {
            contentDescription = description
            liveRegion = LiveRegionMode.Polite
        },
    ) {
        BadgedBox(
            badge = {
                if (itemCount > 0) {
                    Badge(Modifier.clearAndSetSemantics { }) { Text(itemCount.toString()) }
                }
            },
        ) {
            Icon(Icons.Outlined.ShoppingCart, contentDescription = null)
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

This is Part 1’s cart button with one structural change: the description moved from the Icon up to the IconButton, onto the same node as the live region. That's deliberate. Keep the live region on the node that owns the changing text, rather than relying on changes inside merged children to bubble up to a live parent. It's the most predictable arrangement across Compose and TalkBack versions. Now adding an item produces "Shopping cart, 4 items" after TalkBack finishes whatever it was saying.

Polite vs assertive

The two modes differ in when TalkBack speaks:

  • Polite waits until current speech finishes. That's the right choice for nearly everything: counters, "Saved," "3 results," "Back online."
  • Assertive interrupts current speech. Use it only when the user must react now and waiting would cost them something.

polite-vs-assertive

An assertive alternative for the cart is justified for exactly one case, a failure that changes what the user should do next:

@Composable
fun AddToCartError(message: String?, modifier: Modifier = Modifier) {
    // The node stays composed; the live region is only switched on while there is
    // something to say, so clearing the error can't fire an empty interruption.
    Text(
        text = message.orEmpty(),
        color = MaterialTheme.colorScheme.error,
        modifier = modifier.semantics {
            if (!message.isNullOrEmpty()) liveRegion = LiveRegionMode.Assertive
        },
    )
}
Enter fullscreen mode Exit fullscreen mode

“Couldn’t add to cart: only 2 left in stock” deserves to cut in. “Added to cart” never does. If you find yourself reaching for Assertive more than once or twice per app, step back. Each interruption trains users to stop trusting what TalkBack is about to say.

Notice two details. First, the Text is always in the composition and only its content changes, which is more predictable than a node that appears and disappears with its message. An empty Text has nothing to speak, so TalkBack skips it during normal navigation. Second, the assertive flag is switched on only while there's a message. Otherwise clearing the error would itself count as a change, and on some TalkBack versions an empty assertive update can interrupt current speech with nothing. Polite regions, like FormStatus below, don't need this guard, since an empty polite update interrupts nothing.

Where live regions fit

  • Counters that change because of something the user did elsewhere: cart, notification count, “2 items selected.”
  • Form submission results: “Order placed” (polite) or “Payment declined” (a case for assertive).
  • Network status: “You’re offline. Changes will sync later” and “Back online.” Polite, and only on transitions, never on a timer.
  • Async results: “12 recipes found” after a search finishes.

You don’t need one when the change happens to the node that already has focus. Part 2’s rating control, for example, announces its own new state description.

The announcement spam trap

Live regions fire on every change. Put one on a search result count, and a user typing “carbonara” hears “120 results… 48 results… 12 results… 3 results” while trying to listen to their own typing echo. The same happens with countdown timers, progress percentages, and live prices.

announcement-spam

The fix is to announce the value only once it settles:

import androidx.compose.runtime.*
import kotlinx.coroutines.delay

/** Returns [value] only after it has stopped changing for [settleMillis]. */
@Composable
fun <T> rememberSettled(value: T, settleMillis: Long = 750L): T {
    var settled by remember { mutableStateOf(value) }
    LaunchedEffect(value) {
        delay(settleMillis)   // cancelled and restarted on every change
        settled = value
    }
    return settled
}
Enter fullscreen mode Exit fullscreen mode
@Composable
fun ResultCount(count: Int, modifier: Modifier = Modifier) {
    val settledCount = rememberSettled(count)
    val spoken = pluralStringResource(R.plurals.search_results, settledCount, settledCount)

    Text(
        // Sighted users see the live value immediately...
        text = pluralStringResource(R.plurals.search_results, count, count),
        // ...TalkBack hears only the settled one.
        modifier = modifier.clearAndSetSemantics {
            contentDescription = spoken
            liveRegion = LiveRegionMode.Polite
        },
    )
}
Enter fullscreen mode Exit fullscreen mode

Because LaunchedEffect(value) restarts on every change, the delay only completes once typing pauses. A screen reader user hears one sentence instead of four. For countdowns and progress, announce milestones ("1 minute left," "Upload complete") rather than every tick.

A note on View.announceForAccessibility(). It still shows up in older code, but it's deprecated as of Android 16 (API 36). Prefer live regions and pane titles, which describe state rather than firing one-off speech that services can't revisit.

Headings: a table of contents for the ear

Sighted users skim. They glance at bold section titles and jump straight to “Reviews.” TalkBack users without headings have one option: swipe through every element in order. On a long recipe screen, that can mean dozens of swipes past ingredients and steps just to reach the reviews.

Marking section titles as headings gives them the equivalent of skimming. TalkBack lets users switch navigation to “Headings” and jump section to section. The exact gesture or menu depends on the TalkBack version, but every recent version supports it.

heading-navigation

@Composable
fun SectionHeader(text: String, modifier: Modifier = Modifier) {
    Text(
        text = text,
        style = MaterialTheme.typography.titleLarge,
        modifier = modifier
            .padding(top = 24.dp, bottom = 8.dp)
            .semantics { heading() },
    )
}
Enter fullscreen mode Exit fullscreen mode

Build heading semantics into your design system’s section header component, so nobody has to remember it at each call site. Just as important, don’t build it into a general-purpose typography component like TitleText. If you do, every card title in every list becomes a heading.

What deserves heading status:

  • The screen’s title. Check whether your top app bar already exposes it as a heading; if not, add it.
  • Section titles that divide a long screen: Ingredients, Steps, Nutrition, Reviews.
  • Group titles in settings: Notifications, Privacy, Account.

What doesn’t:

  • Every card or list-item title. Forty “headings” in a product grid make heading navigation as slow as swiping.
  • Bold labels, buttons, and decorative large text.
  • A word that’s styled big for visual emphasis but doesn’t start a section.

A useful test: read only the headings out loud. If they sound like a table of contents for the screen, you’ve got it right.

Focus management: move people only when they’d expect it

Live regions tell users about change. Focus management moves them to it. That’s more powerful, and much more disruptive when it’s wrong.

One distinction first. FocusRequester moves input focus: the element that receives keyboard input and shows the soft keyboard. TalkBack keeps its own accessibility focus, the element it's reading. TalkBack usually follows input focus into text fields, but the two aren't the same thing. Design so that moving input focus helps, and verify on a device that TalkBack goes along with it.

focus-decisions

On screen entry: when it helps and when it hurts

Auto-focus helps when a screen exists for typing: search, a one-time-code entry, “rename playlist.” The user arrives wanting to type.

@Composable
fun SearchField(query: String, onQueryChange: (String) -> Unit, modifier: Modifier = Modifier) {
    val focusRequester = remember { FocusRequester() }
    // Saveable: rotating the device or coming back from a result must not
    // refocus the field and pop the keyboard up again.
    var hasAutoFocused by rememberSaveable { mutableStateOf(false) }

    LaunchedEffect(Unit) {
        if (!hasAutoFocused) {
            focusRequester.requestFocus()
            hasAutoFocused = true
        }
    }

    OutlinedTextField(
        value = query,
        onValueChange = onQueryChange,
        label = { Text(stringResource(R.string.label_search_recipes)) },
        singleLine = true,
        modifier = modifier.fillMaxWidth().focusRequester(focusRequester),
    )
}
Enter fullscreen mode Exit fullscreen mode

Two failure modes ship with this pattern all the time:

  • Refocusing on return. With a plain LaunchedEffect(Unit) and no saved flag, the keyboard pops up every time the user navigates back to search or rotates the phone. That's annoying for everyone and disorienting for TalkBack users, whose focus is yanked away from the result they just came back to.
  • Requesting focus before the target exists. If the field is composed conditionally, say after a loading state, calling requestFocus() before its focusRequester modifier is attached throws IllegalStateException. Only request focus for a node you know is in the composition.

Auto-focus hurts on long forms and content screens. A TalkBack user dropped straight into field three of a checkout form skips the page title, the instructions, and the order summary above it. They have to swipe backwards to find out where they are. On those screens, let focus start at the top as usual.

After failed validation: go to the problem

When a submit fails, moving focus to the first invalid field is the single most helpful thing you can do. Keyboard users land where they need to type, sighted users see the field scroll into view, and TalkBack usually reads the field together with its error. Part 1’s error() semantics pay off here.

enum class CheckoutField { Name, Email, Phone }

data class CheckoutFormState(
    val values: Map<CheckoutField, String> = emptyMap(),
    val errors: Map<CheckoutField, String> = emptyMap(),
    val submitAttempt: Int = 0,
)

@Composable
fun CheckoutForm(state: CheckoutFormState, onEvent: (CheckoutEvent) -> Unit) {
    val requesters = remember { CheckoutField.entries.associateWith { FocusRequester() } }

    // Keyed on the attempt counter, not the field: a second failed submit
    // on the same field must still move focus.
    LaunchedEffect(state.submitAttempt) {
        if (state.submitAttempt == 0) return@LaunchedEffect
        val firstInvalid = CheckoutField.entries.firstOrNull { it in state.errors } ?: return@LaunchedEffect
        requesters.getValue(firstInvalid).requestFocus()
    }

    Column {
        CheckoutField.entries.forEach { field ->
            ValidatedTextField( // Part 1's EmailField, generalized: icon + "Error:" text + error()
                field = field,
                value = state.values[field].orEmpty(),
                errorMessage = state.errors[field],
                onValueChange = { onEvent(CheckoutEvent.Changed(field, it)) },
                modifier = Modifier
                    .focusRequester(requesters.getValue(field))
                    .testTag(TestTags.field(field)),
            )
        }
        FormStatus(state) // see below
        Button(onClick = { onEvent(CheckoutEvent.Submit) }) {
            Text(stringResource(R.string.action_place_order))
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

The submitAttempt counter matters. If you key the effect on firstInvalid, the second failed submit on the same field produces the same key. The effect doesn't re-run, focus doesn't move, and the user hears nothing.

Pair the focus move with an announcement

Moving focus is not a guaranteed announcement. If input focus is already on the invalid field, which happens when the user typed in it and then double-tapped Submit, requesting focus changes nothing, and some TalkBack versions say nothing either. So combine the focus move with a short polite summary near the submit button:

@Composable
fun FormStatus(state: CheckoutFormState, modifier: Modifier = Modifier) {
    val message = if (state.submitAttempt > 0 && state.errors.isNotEmpty()) {
        pluralStringResource(R.plurals.form_errors_summary, state.errors.size, state.errors.size)
        // "Couldn't place order. %d field needs attention." / "%d fields need attention."
    } else {
        ""
    }
    Text(message, modifier = modifier.semantics { liveRegion = LiveRegionMode.Polite })
}
Enter fullscreen mode Exit fullscreen mode

Keep the summary short and counted rather than repeating every error. The field-level errors are read when the user reaches each field. The summary’s only job is to make sure nobody is left wondering whether the button did anything.

Focus traps and stolen focus

  • Fake dialogs. An overlay drawn with a Box on top of the screen isn't a dialog to TalkBack. Users can swipe straight past it into the content behind. Use Dialog, AlertDialog, or ModalBottomSheet. They open their own window, so accessibility focus stays inside until they're dismissed.
  • Moving focus on background updates. A LaunchedEffect(items) that requests focus whenever the list refreshes pulls the user away from whatever they were reading, every time new data arrives. Focus should only move in response to the user's own action.
  • Unstable list keys. When a LazyColumn reloads without stable keys, items can be recreated as new nodes, and TalkBack can lose its place and jump to the top. Give list items key = { it.id }.
  • Forgetting where to return. When a dialog or sheet closes, check on a device that focus goes back to the control that opened it, not to the top of the screen.

A design checklist for dynamic UI

Most dynamic moments in an app fall into four patterns. Each has a small set of checks:

dynamic-ui-checklist

  • Dialogs: Use Material’s dialog components. The title should be the first thing spoken. Every action needs a clear label: “Delete recipe,” not just “OK.” Focus should return to the trigger when the dialog closes.
  • Bottom sheets: Use ModalBottomSheet for anything modal. Give a custom sheet a paneTitle so its appearance is announced. Make sure dismissing doesn't depend only on dragging: offer a labelled close action too.
  • Snackbars: Material’s SnackbarHost already announces politely and stretches its timeout based on the user's accessibility timeout setting. Your job is never to put the only path to something important in a snackbar action that disappears. If "Undo" matters, offer it somewhere persistent as well.
  • Loading states: Give progress indicators a label (“Loading recipes”). Announce the outcome (“12 recipes found,” “Couldn’t load recipes”), not every percentage. Don’t announce the start of a load the user didn’t trigger.

Testing what you just built

Everything in this post is visible in the semantics tree, so Part 3’s tools apply directly:

@Test
fun cartAction_isPoliteLiveRegion() {
    rule.setContent { CartAction(itemCount = 4, onOpenCart = {}) }
    rule.onNodeWithContentDescription(context.resources.getQuantityString(R.plurals.cd_cart_items, 4, 4))
        .assert(SemanticsMatcher.expectValue(SemanticsProperties.LiveRegion, LiveRegionMode.Polite))
}

@Test
fun recipeScreen_exposesExactlyItsSectionHeadings() {
    rule.setContent { AppTheme { RecipeScreen(recipe = sampleRecipe) } }
    // Catches both forgotten headings and heading inflation from a shared component.
    rule.onAllNodes(isHeading()).assertCountEquals(5) // title + 4 sections
}

@Test
fun failedSubmit_movesFocusToFirstInvalidField() {
    var state by mutableStateOf(CheckoutFormState())
    rule.setContent { CheckoutForm(state = state, onEvent = {}) }

    state = state.copy(errors = mapOf(CheckoutField.Email to "Enter a complete email address"), submitAttempt = 1)

    rule.onNodeWithTag(TestTags.field(CheckoutField.Email)).assertIsFocused()
}
Enter fullscreen mode Exit fullscreen mode

Testing the timing of a live region announcement belongs in the manual routine from Part 3. Automated tests can prove the property is set. Only a person with TalkBack running can tell you whether the announcement arrives at a sensible moment.

Common mistakes

  • Assertive for everything. “Saved,” “Added,” and “Loaded” are not emergencies. Interruptions should be rare enough that users never learn to ignore them.
  • Heading inflation. Building heading() into a shared title style turns every card into a heading, and heading navigation becomes as slow as swiping. Test with assertCountEquals.
  • Stealing focus after the user has started. Auto-focus on return, focus moves on data refresh, and dialogs that pop up mid-task all yank TalkBack users away from what they were doing. Move focus only in response to the user’s own action, or once on first entry to a screen built for typing.
  • Announcing every tick. Live regions on raw, fast-changing values (search counts, timers, progress) turn TalkBack into a stream of numbers. Settle first, then announce.

Up next

There’s one more dimension of inclusion left, and it has nothing to do with screen readers: direction. Hundreds of millions of people read right to left. Part 5, *Going Global*, covers:

  • what Compose mirrors for you automatically, and what it can’t
  • why some icons must flip and others must never flip
  • how to audit every screen in a single pass

It closes the series with one combined release checklist covering accessible, testable, and RTL-ready.

📱 Crack Your Next Android Interview

If you’re targeting a senior Android role at a FAANG or top-tier MNC, syntax knowledge alone won’t get you through the loop. I highly recommend “Cracking Top MNC Android Interviews” — a senior-level playbook covering framework internals, Jetpack Compose, coroutines, Android-specific system design, and DSA reframed around real mobile constraints, to help you walk into bar-raiser rounds ready.


Originally published on Medium.

Top comments (0)