Android Accessibility & Localization
Irbisa · cheatsheetSeptember 13, 2026

Android Accessibility & Localization

TalkBack, contentDescription, Compose semantics, plurals, per-app language, RTL, font scaling

Middle Developer16 itemscompressed for a skim
  1. 01

    TalkBack says only "button" on your Compose IconButton and calls the old screen's ImageButton unlabelled — what is missing, and what is the smallest fix for each?

    Easy

    Neither icon carries a label, and each needs exactly one string: a real contentDescription on the Icon, and android:contentDescription on the ImageButton.

    // Wrong: no label at all. TalkBack focuses it and says just "button".
    IconButton(onClick = ::share) {
      Icon(Icons.Default.Share, contentDescription = null)
    }
    
    // Right: the description goes on the Icon. IconButton's clickable modifier
    …
  2. 02

    TalkBack stops on a purely decorative header image, and your unread-count row gets read out twice. Which knob takes each of those out of the node tree?

    Easy

    contentDescription = null and importantForAccessibility are different tools — one says there is nothing to announce, the other removes nodes from the tree — and the double reading is neither: it is a grouping problem.

    // Decoration in Compose: no description means no node, so TalkBack skips it.
    Image(painterResource(R.drawable.header_gradient), contentDescription = null)
    
    // Hide one node from services but keep it addressable in tests
    Text("3", Modifier.semantics { hideFromAccessibility() }.testTag("unreadBadge"))
    …
  3. 03

    A rating row reads as five separate stars, and a switch says "on" where the copy should say "Subscribed" — which semantics modifiers fix each of those?

    Medium

    Merging collapses the five stars into one node, and stateDescription renames the spoken state without touching the label.

    // Five stars, five stops, five announcements
    Row {
      repeat(5) { i -> Icon(starFor(i), contentDescription = "Star ${i + 1}") }
    }
    
    // One stop, one sentence
    …
  4. 04

    TalkBack says "in list, item 3 of 40" on a RecyclerView row but reads your canvas-drawn chart as one blob — where do those nodes actually come from?

    Medium

    Nothing is a node until an accessibility service asks: the framework builds AccessibilityNodeInfo objects on demand from the View tree, and anything you painted rather than laid out has no node unless you make one.

    // A View exposes itself through a delegate — no subclassing required
    ViewCompat.setAccessibilityDelegate(chartView, object : AccessibilityDelegateCompat() {
      override fun onInitializeAccessibilityNodeInfo(
        host: View,
        info: AccessibilityNodeInfoCompat,
      ) {
    …
  5. 05

    Your list rows delete on swipe and reorder on long-press drag, and a TalkBack user can do neither. What do you add so both operations become reachable?

    Medium

    Custom accessibility actions: every gesture-only affordance needs a named action on the node, which TalkBack then offers from its actions menu.

    // Views: two actions the user reaches from TalkBack's actions menu
    ViewCompat.addAccessibilityAction(holder.itemView, getString(R.string.delete)) { _, _ ->
      onDelete(item.id)
      true
    }
    ViewCompat.addAccessibilityAction(holder.itemView, getString(R.string.move_up)) { _, _ ->
    …
  6. 06

    An upload finishes and a snackbar slides in. How does a TalkBack user hear about it without losing their place, and why is announceForAccessibility the wrong tool now?

    Medium

    A live region: the node speaks when its own text changes and accessibility focus never moves — which is exactly why Android 16 deprecated announceForAccessibility and the raw TYPE_ANNOUNCEMENT event.

    // Deprecated in Android 16 (API 36): it interrupts, cannot be replayed, and
    // leaves nothing on screen for the user to go back to.
    statusView.announceForAccessibility(getString(R.string.upload_finished))
    
    // A live region speaks when its own text changes; focus stays where it was
    ViewCompat.setAccessibilityLiveRegion(
    …
  7. 07

    A filter sheet opens over the list, TalkBack keeps swiping into the list behind it, and on close the focus jumps to the top of the screen. What do you fix?

    Medium

    Two independent bugs: the sheet is not modal as far as the node tree is concerned, and nothing hands focus back to the control that opened it.

    // A real modal window: the framework hides what is behind it and announces the
    // sheet as a new pane. Nothing else to do.
    ModalBottomSheet(onDismissRequest = ::close, sheetState = sheetState) { Filters() }
    
    // A sheet you drew yourself is just a Box on top — say both things explicitly
    Box(Modifier.fillMaxSize()) {
    …
  8. 08

    Your 24dp icon buttons sit 4dp apart and the brand blue on white measures 3.4:1. Which numbers must you actually hit, and how, without redrawing anything?

    Medium

    48x48dp of touch target per control with 8dp between them, and 4.5:1 for body text — and both can be met without changing a single drawn pixel.

    // Modifier order decides the target: padding AFTER clickable is inside it.
    Icon(
      Icons.Default.Close,
      contentDescription = stringResource(R.string.close),
      modifier = Modifier.size(24.dp).clickable(onClick = onClose),      // 24dp target
    )
    …
  9. 09

    At 200% font size a fixed 56dp row clips its label while the price beside it does not grow at all — what did that layout get wrong?

    Medium

    Text sized in dp opts out of the user's font preference entirely, and a container with a fixed dp height has nowhere to put the text that does scale.

    // WRONG — text sized in dp, in a box that cannot grow
    Row(Modifier.fillMaxWidth().height(56.dp)) {                  // clips at 200%
      Text(
        item.title,
        fontSize = with(LocalDensity.current) { 16.dp.toSp() },   // opts out of font scale
        maxLines = 1,
    …
  10. 10

    Your screen passes Accessibility Scanner and the Espresso accessibility checks, and a blind tester still cannot finish checkout — what do those tools not check?

    Medium

    Automated checks prove that a label, a touch target or a contrast ratio exists; only a person driving TalkBack can tell you whether the label means anything and whether the task can be completed.

    // View tests: one setup call turns ATF on for every Espresso interaction
    @Before fun enableChecks() {
      AccessibilityChecks.enable()
        .setRunChecksFromRootView(true)              // the whole screen, not just the view
        .setSuppressingResultMatcher(                // carry one tracked failure
          matchesViews(withId(R.id.legacy_banner)),
    …
  11. 11

    Your Russian build shows "5 товар" and a banner prints its two arguments in the wrong order — what is missing from strings.xml?

    Medium

    Both are the same mistake: the sentence was built for English grammar — a hand-picked suffix and a fixed argument order — instead of being handed whole to the resource system so the translation can decide.

    <!-- res/values/strings.xml -->
    <resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
    
      <!-- WRONG: two non-positional args (a build error), and a noun glued to a number
      <string name="moved">Moved %s to %s</string>
      <string name="cart_count">items in cart</string>
    …
  12. 12

    Your app ships English in values/ and a values-ru; a user whose language list is Ukrainian then Russian opens it and gets Russian — is that a bug?

    Medium

    No — since API 24 the user has an ordered LocaleList, and Android walks all of it before it ever falls back to the unqualified values/, so their second choice beats your default.

    // res/values/strings.xml         <- English, the source language, NO region qualifier
    // res/values-ru/strings.xml      <- ru, and every ru-XX that inherits from it
    // res/values-en-rGB/strings.xml  <- only the words that genuinely differ
    // res/values-b+sr+Latn/          <- BCP-47 form when a script is involved
    
    // The user's ordered preference list, not a single locale (API 24+)
    …
  13. 13

    A user picks Russian in your in-app language switcher, but after a cold start the app is English again and their push notifications never switched — what is missing?

    Medium

    Nothing persists an app locale unless you go through the per-app language API — AppCompatDelegate.setApplicationLocales() on top of a declared locales_config.xml — and the server that composes your push text has never heard of that choice at all.

    // res/xml/locales_config.xml
    // <locale-config xmlns:android="http://schemas.android.com/apk/res/android">
    //   <locale android:name="en"/>
    //   <locale android:name="ru"/>
    //   <locale android:name="de"/>
    // </locale-config>
    …
  14. 14

    The Arabic build mirrors everywhere except a progress bar you drew with Canvas and a chevron still pointing right — what does Android mirror for you, and what never will?

    Hard

    With android:supportsRtl="true" the framework mirrors layout attributes and auto-mirrored drawables, and that is the whole list — anything you positioned absolutely, drew yourself, or shipped as a plain directional asset keeps pointing the way you drew it.

    // Mirrors for free — start/end everywhere, never left/right
    Row(Modifier.padding(start = 16.dp, end = 8.dp)) {
      Icon(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.back))
      Text(order.title, Modifier.weight(1f))
    }
    …
  15. 15

    A Thai user's receipt shows the year 2569, an Egyptian user's amount fails to parse, and a German sees $1,234.50 on a euro price — what do those three share?

    Hard

    All three are strings the app assembled itself instead of asking a locale-aware formatter: a pattern with the default locale, a number parsed with toDouble(), and a currency symbol pasted next to String.format.

    // WRONG — three bugs in three lines
    val date = SimpleDateFormat("dd/MM/yyyy").format(order.date)  // follows the user's locale
    val total = "$" + String.format("%.2f", order.amount)         // always dollars
    val parsed = input.toDouble()                                 // throws on "1.234,50"
    
    // RIGHT — the locale formats; the data decides what is being formatted
    …
  16. 16

    Translations for twenty locales will land three days before the release branch cuts — what do you put in place now so that week is boring?

    Hard

    Everything except the translations themselves can be done before they arrive: pseudolocales prove the layouts survive, lint proves nothing is hard-coded, and a string freeze decides what the translators are actually given.

    // build.gradle.kts
    android {
      buildTypes {
        debug {
          isPseudoLocalesEnabled = true    // adds en-XA and ar-XB to the device language list
        }
    …