Skip to content

Instantly share code, notes, and snippets.

@Digipom
Created May 22, 2026 16:08
Show Gist options
  • Select an option

  • Save Digipom/d1dc1aef7623d8f58ff6b57cb6935d5b to your computer and use it in GitHub Desktop.

Select an option

Save Digipom/d1dc1aef7623d8f58ff6b57cb6935d5b to your computer and use it in GitHub Desktop.
compose-accessibility-semantics
This Compose skill is specifically to help with some issues we noticed around Talkback, progress semantics, and slider animations.
This is a workaround for a specific bug: https://issuetracker.google.com/issues/480332636
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.

Compose Accessibility (Talkback Semantics)

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.

The bug

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.

Where it reproduces

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

What is dangerous, what is not

Dangerous: progress/range semantics whose float value changes rapidly

  • progressSemantics(value = …) driven by playback position, animation, fling, or any other non-user-driven source.
  • Material Slider's built-in value parameter driven by the same.
  • setProgress is 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, not setProgress itself.

NOT dangerous: descriptions derived from already-quantized state

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.

The fix recipe

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.

Don't quantize the float value

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.

Wrapping Material components whose built-in semantics you cannot avoid

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

Anti-patterns to flag in code review

  1. Per-frame float state wired directly to a progress/range semantics value without throttling or a clearAndSetSemantics wrapper. Targets: progressSemantics(value = …), Slider(value = …), or progress/range info paired with setProgress, where the value source is an animation, playback position, animated scroll offset, or fling.
  2. Trying to fix this freeze by rounding/quantizing the value to discrete steps — this can break Talkback swipe behaviour. Use the throttle.
  3. Throttling a stateDescription/contentDescription that's already quantized at its source — unnecessary; the freeze does not fire from re-evaluation, only from genuine string changes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment