Mobile Core Concepts
Accessibility and Internationalization
Why a screen that ignores semantics and locale reads as broken to a chunk of your users, and the parts an interviewer expects you to name when "accessibility" or "i18n" comes up.
Accessibility and internationalization sit on the same axis: both are about the UI being correct for users who are not the engineer who wrote it. On Android they share a mechanism - the platform reads metadata that lives parallel to the visible pixels, then projects it through TalkBack for assistive tech or through the resource resolver for locale and configuration. Get the metadata wrong and a screen that looks fine to a sighted English speaker is unusable for a screen-reader user, an RTL reader, or a Slavic-language plural rule. This page covers what TalkBack consumes, how Compose builds its semantic tree, and the i18n machinery (qualifiers, RTL mirroring, CLDR plurals, locale-aware formatters) that ships in the platform.
Accessibility on Android
TalkBack is the system screen reader. It walks an accessibility tree - a parallel projection of the UI where each node carries a role, label, state, and supported actions. The user swipes to move focus, TalkBack reads the focused node aloud; the user double-taps and TalkBack dispatches the node's primary action through the accessibility API. The view never sees a raw touch event.
The pixels are not the input. A button is accessible because the framework knows it is a Button with a label and an onClick, not because it looks like one. A Box styled to look tappable is invisible to TalkBack unless marked clickable; an icon is anonymous without a contentDescription.
TalkBack is one of several assistive surfaces that read the same tree:
| Tool | What it consumes | Failure mode if metadata is missing |
|---|---|---|
| TalkBack | Labels, roles, state, actions | Reads "unlabeled button" or skips silently |
| Switch Access | Focusable + actionable nodes | User cannot reach control via switch |
| Voice Access | Labels (matched against speech) | "Tap submit" does nothing - no node named submit |
| Large text / display scaling | sp units, density-pixel sizes | Text clips, hit targets shrink, layout overlaps |
Large-text scaling multiplies sp text by up to ~2x. Fixed-dp containers clip or truncate at the largest scale. Use sp for text, wrapContentHeight where text lives, and test at 200%.
Sources: Android accessibility overview · TalkBack documentation
Content descriptions and roles
contentDescription is the label TalkBack reads. In XML: android:contentDescription; in code: view.contentDescription; in Compose: the semantics modifier.
// View - the icon button is named "Delete photo".
ImageButton(
contentDescription = "Delete photo",
setImageResource(R.drawable.ic_delete),
)
// Compose - semantics sets the label; Role.Button announces the role.
Box(
Modifier
.clickable(onClick = onDelete)
.semantics { contentDescription = "Delete photo"; role = Role.Button },
) { Icon(Icons.Default.Delete, contentDescription = null) }Decorative images take contentDescription = null (or importantForAccessibility = "no"). The empty string is wrong - some readers announce "image", others read the resource name. null is the explicit "decorative" contract.
Roles tell TalkBack what kind of thing the user is touching: Role.Button adds "button"; Role.Switch adds on/off; Role.Checkbox adds checked/unchecked. Without a role, TalkBack falls back to a generic "double tap to activate".
One label per logical control. An inner Icon { contentDescription = "Delete" } and an outer "Delete photo" produce "Delete, Delete photo, button". Label the wrapper and null the icon, or merge descendants (next section).
Compose semantics in depth
Every composable has a SemanticsNode. By default, Compose merges descendants - a Button { Icon(...); Text("Submit") } becomes one node "Submit, button" rather than three. The merge is what makes the semantics tree work out of the box; it is also where most "TalkBack reads the wrong thing" bugs originate.
Two modifiers control tree shape:
Modifier.semantics(mergeDescendants = true) { ... }declares a merge boundary; children's properties pull up and the children flatten away.Modifier.clearAndSetSemantics { ... }drops every descendant property and replaces them with the lambda's. Use when the default merge is wrong (e.g. a star-rating where you want one label "Rating: 4 stars", not five stars).
@Composable
fun StarRating(rating: Int) {
Row(Modifier.clearAndSetSemantics {
contentDescription = "Rating: $rating out of 5 stars"
role = Role.Image
}) {
repeat(5) { i ->
Icon(if (i < rating) Filled.Star else Outlined.Star, contentDescription = null)
}
}
}Custom semantic actions extend the tree with affordances TalkBack can dispatch directly. A swipeable list item should expose customActions = listOf(CustomAccessibilityAction("Archive", onArchive), CustomAccessibilityAction("Delete", onDelete)) - TalkBack users then reach the same actions via the local context menu that gesture users get via swipe.
The tree is built from the Modifier chain, so order matters: clickable itself contributes semantics. Declare role and contentDescription on the same chain as the click behavior and let merge handle the rest.
Focus order
Focus order is how D-pad, keyboard tab, and TalkBack swipe-next traverse the UI. By default it follows the semantic tree in document order (top-to-bottom, start-to-end). For non-linear layouts the default order can disagree with visual order: a card with a title on the left, a button on the right, and a subtitle below reads "title, subtitle, button" because subtitle is structurally before button.
Compose overrides via Modifier.focusProperties { next = ...; previous = ... } and explicit focusRequester references. Views use android:nextFocusForward / nextFocusDown / nextFocusRight.
The pitfall is conflating visual order with focus order. A right-aligned button is visually after the subtitle even if it is structurally before; layout does not move it in the semantic tree. Either reorder the source or set explicit focus hints. This couples directly to How Rendering Works: layout produces visual order; the semantic tree is built alongside but independently.
Touch target sizing
The minimum interactive target is 48dp x 48dp. A 24dp icon inside a 48dp tap area is correct; a 24dp icon that is the click target fails accessibility scans and is hard to hit for users with motor impairments.
// Compose - explicit minimum interactive size.
IconButton(onClick = onClose, modifier = Modifier.minimumInteractiveComponentSize()) {
Icon(Icons.Default.Close, contentDescription = "Close")
}Modifier.minimumInteractiveComponentSize() pads the touch target to 48dp without changing the visual icon size. Material's IconButton, Checkbox, Switch, and RadioButton already do this; custom click targets need it explicitly.
Internationalization - resource qualifiers
Android resolves resources via qualifiers on the directory name. values/strings.xml is the default; values-fr/strings.xml overrides for French, values-fr-rCA/ for Canadian French, values-night/ for dark mode, values-w600dp/ for screens at least 600dp wide.
At runtime the platform picks the best match by walking qualifiers in a fixed precedence:
| Precedence | Qualifier | Example |
|---|---|---|
| 1 | MCC + MNC | values-mcc310-mnc004 |
| 2 | Locale | values-fr-rCA |
| 3 | Layout direction | values-ldrtl |
| 4 | Smallest width | values-sw600dp |
| 5 | Width | values-w820dp |
| 6 | Orientation | values-land |
| 7 | Night mode | values-night |
| 8 | Density | drawable-xxhdpi |
Fallback is the unqualified base. Two consequences: every resource must exist there (a values-fr/-only string crashes on Spanish devices), and combined qualifiers like values-w600dp-night match only when both apply.
Sources: Android localization · Resource qualifiers reference
RTL mirroring
About 7% of the world reads right-to-left (Arabic, Hebrew, Persian, Urdu). On RTL devices the entire layout flips horizontally - back arrows point right, drawers slide in from the right, list rows read right-to-start. Android does this automatically if the layout uses RTL-aware primitives.
The rule: use start / end everywhere, never left / right. Modifier.padding(start, end) mirrors; padding(left, right) does not. XML paddingStart / paddingEnd mirror; paddingLeft / paddingRight do not. textAlignment="viewStart" mirrors; textAlignment="left" does not.
Drawables need android:autoMirrored="true" to flip. Back arrows, chevrons, and directional iconography should set it; brand logos and symmetric icons should not.
In Compose, LocalLayoutDirection.current returns Ltr or Rtl; CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Rtl) { ... } forces a subtree to RTL for previews and tests. Test without speaking Arabic via the pseudo-locales in developer options: en-XA doubles every string with accented characters (catches truncation); ar-XB is fake-RTL English (catches mirroring).
Plurals and ICU MessageFormat
The phrase " items" is wrong in most languages. English has two grammatical numbers (1 / not-1); Russian has three (1, 2-4, 5+); Arabic has six. CLDR codifies them as six categories: zero, one, two, few, many, other. Not every language uses every category - English uses one and other, Russian uses one, few, many, other - but every language uses other as the fallback.
Android's plural resource maps quantity to category at runtime via the device locale:
<plurals name="photos_selected">
<item quantity="one">%d photo selected</item>
<item quantity="other">%d photos selected</item>
</plurals>val text = resources.getQuantityString(R.plurals.photos_selected, count, count)
// On a Russian device the framework picks `one` / `few` / `many` / `other`
// from the same resource by the device locale's CLDR rules.Never branch on count == 1 in app code - that hardcodes English plural rules into your UI.
For messages with multiple variables, ICU MessageFormat handles plurals, gender, and ordinals in one template:
val template = "{count, plural, " +
"=0 {No photos uploaded} " +
"one {# photo uploaded} " +
"other {# photos uploaded}}"
val out = MessageFormat.format(template, mapOf("count" to count))=0 is a literal match (for "no items" copy that reads better than "0 items"); # substitutes the locale-formatted number. ICU is the standard CLDR consumes; Android is moving toward ICU-native string resources in newer XML schemas.
Per-locale formatting
Numbers, dates, currencies, percentages, and lists all format differently by locale. 1234.5 is 1,234.5 (en-US), 1.234,5 (de-DE), 1 234,5 (fr-FR). Hardcoding the format is a localization bug.
val nf = NumberFormat.getInstance(Locale.getDefault())
nf.format(1234.5) // "1,234.5" / "1.234,5" / "1 234,5"
val cf = NumberFormat.getCurrencyInstance(Locale.getDefault())
cf.format(9.99) // "$9.99" / "9,99 €" / "¥10"
val df = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(Locale.getDefault())
df.format(LocalDate.now()) // "May 12, 2026" / "12 mai 2026" / "2026年5月12日"DateTimeFormatter.ofPattern("MM/dd/yyyy") is not locale-aware - it hardcodes US date order. Use ofLocalizedDate(FormatStyle.MEDIUM). Same for time: ofLocalizedTime respects locale and the 12 / 24-hour system preference.
List formatting (A, B, and C vs A, B et C vs A, B și C) is handled by android.icu.text.ListFormatter from API 26+. getCurrencyInstance formats in the locale's display convention; it does not convert dollars to yen - currency conversion is a separate problem.
Per-locale gotchas worth knowing:
- Turkish dotless i:
"INSTAGRAM".lowercase()in Turkish produces"ınstagram"(dotless ı), breaking case-insensitive compare. Uselowercase(Locale.ROOT)for identifiers, locale-specific only for display. - Arabic-Indic digits: some Arabic locales render ٠١٢٣٤٥٦٧٨٩ rather than 0123456789.
NumberFormathandles it; concatenating digits by hand does not. - Locale-aware sorting:
Collator.getInstance(locale)knows ä sorts with a in Swedish but as a separate letter in German. - Hindi / Thai line breaking: word boundaries are not whitespace. Trust the text-layout engine; do not split on
" ".
See also
- Compose - the semantic tree lives in the Modifier chain; that entry covers how
rememberand recomposition build it. - How Rendering Works - visual order and semantic order are produced by the same composition but can disagree.
- Testing - Espresso accessibility checks, Compose
SemanticsNodeInteraction, and the Accessibility Scanner all assert against the tree this page defines. - Platform Knowledge - qualifier resolution intersects with API levels (e.g.
ListFormatteris API 26+, ICU expanded in API 24).
Used in
- Photo Gallery App - grid items need per-photo
contentDescription; " photos selected" is a plurals resource; swipe-to-delete needs a custom accessibility action. - Messenger App - message bubbles need role + label; the typing indicator should be a polite live region; time stamps go through
DateTimeFormatter.ofLocalizedTime. - Newsfeed App - reaction counts use plurals; "X, Y, and Z liked your post" uses
ListFormatter; RTL mirroring affects the feed layout. - Music Player App - play / pause / skip need labels and minimum interactive sizing; non-Latin track titles depend on locale-aware text rendering and sorting.
Done reading? Mark it so it sticks in your dashboard.