Android Accessibility & Localization
TalkBack, contentDescription, Compose semantics, plurals, per-app language, RTL, font scaling
- 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?
EasyNeither icon carries a label, and each needs exactly one string: a real
contentDescriptionon theIcon, andandroid:contentDescriptionon theImageButton.// 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 … - 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?
EasycontentDescription = nullandimportantForAccessibilityare 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")) … - 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?
MediumMerging collapses the five stars into one node, and
stateDescriptionrenames 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 … - 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?
MediumNothing is a node until an accessibility service asks: the framework builds
AccessibilityNodeInfoobjects 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, ) { … - 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?
MediumCustom 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)) { _, _ -> … - 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?
MediumA live region: the node speaks when its own text changes and accessibility focus never moves — which is exactly why Android 16 deprecated
announceForAccessibilityand the rawTYPE_ANNOUNCEMENTevent.// 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( … - 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?
MediumTwo 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()) { … - 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?
Medium48x48dp 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 ) … - 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?
MediumText sized in
dpopts out of the user's font preference entirely, and a container with a fixeddpheight 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
Your screen passes Accessibility Scanner and the Espresso accessibility checks, and a blind tester still cannot finish checkout — what do those tools not check?
MediumAutomated 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
Your Russian build shows "5 товар" and a banner prints its two arguments in the wrong order — what is missing from strings.xml?
MediumBoth 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
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?
MediumNo — since API 24 the user has an ordered
LocaleList, and Android walks all of it before it ever falls back to the unqualifiedvalues/, 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
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?
MediumNothing persists an app locale unless you go through the per-app language API —
AppCompatDelegate.setApplicationLocales()on top of a declaredlocales_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
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?
HardWith
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
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?
HardAll 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 toString.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
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?
HardEverything 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 } …