| name | compose-accessibility-semantics |
|---|---|
| description | Triggers when writing, reviewing, or modifying Compose accessibility/semantics code. Use whenever you see `semantics { }`, `progressSemantics`, `setProgress`, `stateDescription`, `contentDescription`, `mergeDescendants`, `hideFromAccessibility`, `clearAndSetSemantics`, or `Slider` — and any time a Compose value driven by an animation, fling, playback position, or timer is being wired into an accessibility node. Covers the Talkback freeze workaround (issuetracker.google.com/issues/480332636) and the throttle/clearAndSetSemantics recipe. |
Talkback can freeze on real Android devices when a Compose accessibility node's value changes at the device frame rate. This skill covers the diagnosis, the recipe, and the anti-patterns.
Talkback freezes when an accessibility node's value actually changes at the device frame rate. The freeze does not fire from re-evaluating a semantics modifier — only from per-frame value-change notifications reaching the Talkback service.
- Hard freeze: long-running animations, such as playback position driving a progress value, can wedge Talkback completely.
- Partial freeze: even short fling animations, such as a snap-fling on a picker wheel, can cause Talkback to briefly stop accepting input — a user can't tap a sibling control while one is still flinging.
- Not seen: brief fully-user-driven touch state, such as a single tap or a static drag with active finger contact.
Real devices only — not seen on the Android emulator. Observed on physical Android 16 devices. Suspected, but unconfirmed, to be Android-16-only. If you cannot reproduce, double-check you're on a physical device on Android 16 before assuming the issue is gone.
Upstream tracker: https://issuetracker.google.com/issues/480332636
progressSemantics(value = …)driven by playback position, animation, fling, or any other non-user-driven source.- Material
Slider's built-invalueparameter driven by the same. setProgressis the action that lets Talkback adjust the value — it's typically paired with progress/range semantics in the same modifier. Flag the rapidly-changing value, notsetProgressitself.
stateDescription and contentDescription are only at risk when the string itself changes rapidly. That requires deriving the string from per-frame state without quantization. In practice this is rare:
- Time strings keyed on
inWholeSeconds— change at 1 Hz, fine. - Picker descriptions keyed on a snapped index — change discretely, fine.
If you find yourself wondering whether to throttle a description: check what the string is keyed on. If the key is already discrete/quantized, leave it alone.
Use a throttled progress helper:
@Composable
internal fun rememberThrottledProgressValue(
value: Float,
key: Any?,
): Float {
val latestValue by rememberUpdatedState(value)
var throttledValue by remember { mutableFloatStateOf(latestValue) }
LaunchedEffect(key) {
snapshotFlow { latestValue }
.throttleLatest(AccessibilityProgressThrottleIntervalMs)
.collect { throttledValue = it }
}
return throttledValue
}
internal const val AccessibilityProgressThrottleIntervalMs = 500L
private fun <T> Flow<T>.throttleLatest(delayMs: Long): Flow<T> = conflate().transform {
emit(it)
delay(delayMs)
}The throttle is "emit first, suppress middle, emit final". First and last values are preserved, so the initial state and the settled value both reach Talkback; intermediate values within a 500 ms window are dropped.
key is the LaunchedEffect invalidation key: pass something that should force an immediate re-emission when it changes. For waveform scrubbing, the key might be duration. For a picker wheel, the key might be the size of the value list.
Rounding the progress value to coarser discrete steps can break Talkback swipe-to-scrub in worse ways than just looking choppy. Observed failures can include swipe actions becoming completely unresponsive in some cases, and swipe behaviour becoming inconsistent between backward and forward. Throttle, don't quantize.
Slider's built-in value semantics are unavoidable if you use the component as-is. The pattern: wrap the Slider in a Box, apply your throttled semantics on the Box, and use Modifier.clearAndSetSemantics { } on the Box to suppress the Slider's descendant semantics from reaching the accessibility tree.
Box(
modifier = Modifier
.progressSemantics(
value = throttledValue,
valueRange = valueRange,
steps = steps,
)
.clearAndSetSemantics {
// Block the inner Slider's per-frame value semantics from leaking through.
}
) {
Slider(
value = value,
onValueChange = onValueChange,
valueRange = valueRange,
steps = steps,
)
}- Per-frame float state wired directly to a progress/range semantics value without throttling or a
clearAndSetSemanticswrapper. Targets:progressSemantics(value = …),Slider(value = …), or progress/range info paired withsetProgress, where the value source is an animation, playback position, animated scroll offset, or fling. - Trying to fix this freeze by rounding/quantizing the value to discrete steps — this can break Talkback swipe behaviour. Use the throttle.
- Throttling a
stateDescription/contentDescriptionthat's already quantized at its source — unnecessary; the freeze does not fire from re-evaluation, only from genuine string changes.