Irbisa · Cheatsheet

Android Accessibility & Localization

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

Middle Developer · 21 questions

  1. 01

    What does making an Android app accessible involve?

    Accessibility means every task in the app can be finished without seeing the screen, without precise touch and at large text sizes: labelled controls, a sensible focus order, 48dp touch targets, enough contrast and text that scales.

  2. 02

    What is contentDescription, and how do you label an icon-only button in Compose and in Views?

    contentDescription is the text TalkBack reads for an element that has no visible text.

    // Wrong: no label at all. TalkBack focuses it and says just "button".IconButton(onClick = ::share) {  Icon(Icons.Default.Share, contentDescription = null)} // Right: IconButton merges its children, so the icon's label becomes the button's…
  3. 03

    How do you localize an Android app, and what does a translation-ready app need from the start?

    Keep every user-facing string in res/values/strings.xml, put translations into values-<language> folders, and let resources handle the grammar: positional placeholders, <plurals>, start/end layouts, locale-aware formatting and per-app language support.

  4. 04

    How do you hide a decorative element or a whole subtree from TalkBack, in Compose and in Views?

    Mark decoration with contentDescription = null in Compose or importantForAccessibility="no" in Views; drop a whole subtree with Modifier.clearAndSetSemantics { } or noHideDescendants.

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

    What touch-target size and contrast ratios should Android UI meet, and how do you reach 48dp without redrawing the icon?

    Give every control a 48x48dp touch target with 8dp between targets, and keep body text at 4.5:1 contrast: the target can grow around a 24dp icon without redrawing it, while contrast depends on the real colours behind the pixel.

    // Modifier order decides the target: padding AFTER clickable is inside itIcon(  Icons.Default.Close,  contentDescription = stringResource(R.string.close),  modifier = Modifier    .clickable(onClick = onClose)…
  6. 06

    In Compose, what do mergeDescendants, clearAndSetSemantics, stateDescription and role each change in what TalkBack says?

    mergeDescendants turns a subtree into one focus stop, clearAndSetSemantics replaces it outright, stateDescription renames the spoken state ("Subscribed" instead of "on"), and role says what kind of control it is.

    // literals for brevity; use stringResource in real code// One stop, one sentence instead of five starsRow(  Modifier.semantics(mergeDescendants = true) {    contentDescription = "Rating: 4 out of 5"  },…
  7. 07

    What does TalkBack announce for this settings row, and how do you fix it?

    @Composablefun WifiRow(checked: Boolean, onToggle: (Boolean) -> Unit) {  Row(    Modifier      .fillMaxWidth()      .clickable { onToggle(!checked) }      .padding(16.dp),    verticalAlignment = Alignment.CenterVertically,  ) {    Text("Wi-Fi", Modifier.weight(1f))    Switch(checked = checked, onCheckedChange = onToggle)  }}

    TalkBack makes two stops: the row ("Wi-Fi", no state) and the Switch ("On, switch", no label).

  8. 08

    How do you make swipe-to-delete and drag-to-reorder usable for TalkBack and Switch Access users?

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

    // Views: actions the user reaches from TalkBack's actions menuViewCompat.addAccessibilityAction(holder.itemView, getString(R.string.delete)) { _, _ ->  onDelete(item.id); true}ViewCompat.addAccessibilityAction(holder.itemView, getString(R.string.move_up)) { _, _ ->  onMove(item.id, offset = -1); true…
  9. 09

    How do you tell a TalkBack user that something changed, like an upload finishing, and why is announceForAccessibility deprecated?

    Use a live region: the node speaks when its own text changes and focus never moves.

    // Deprecated in Android 16 (API 36): interrupts and leaves nothing to go back tostatusView.announceForAccessibility(getString(R.string.upload_finished)) // A live region speaks when its own text changes; focus stays where it wasViewCompat.setAccessibilityLiveRegion(  binding.status,…
  10. 10

    A custom filter sheet opens, TalkBack keeps reading the list behind it, and on close focus jumps to the top. How do you fix it?

    The sheet is not modal in the node tree, and nothing returns focus to its opener: use a real modal window (ModalBottomSheet, Dialog) or hide the content behind it, then put focus back on the opener when it closes.

    // A real modal window hides what is behind it and is announced as a new pane.// On dismiss, hand focus back to the control that opened it.var open by remember { mutableStateOf(false) }val filterButton = remember { FocusRequester() }IconButton(onClick = { open = true }, modifier = Modifier.focusRequester(filterButton)) {  Icon(Icons.Default.FilterList, contentDescription = stringResource(R.string.filters))…
  11. 11

    Why must text sizes be in sp, and what changed with non-linear font scaling in Android 14?

    Only sp text follows the user's font-size setting.

    // Wrong: text sized in dp, in a box that cannot growRow(Modifier.fillMaxWidth().height(56.dp)) {                  // clips at 200%  Text(    item.title,    fontSize = with(LocalDensity.current) { 16.dp.toSp() },   // cancels the font scale    maxLines = 1,…
  12. 12

    How do you test accessibility on Android, and what do Accessibility Scanner and automated checks miss?

    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)),…
  13. 13

    Find the bug: the like button works with a finger, but when a TalkBack user double-taps it, nothing happens.

    // Like button: shrinks while pressed, toggles the like on releasebinding.likeButton.setOnTouchListener { view, event ->  when (event.actionMasked) {    MotionEvent.ACTION_DOWN -> view.animate().scaleX(0.85f).scaleY(0.85f)    MotionEvent.ACTION_UP -> {      view.animate().scaleX(1f).scaleY(1f)      viewModel.toggleLike(post.id)    }    MotionEvent.ACTION_CANCEL -> view.animate().scaleX(1f).scaleY(1f)  }  true}

    The like is toggled only inside an OnTouchListener that consumes every event, but TalkBack, Switch Access and a keyboard send no touches; they call performClick(), which has no click listener, so for them the button does nothing.

  14. 14

    How does TalkBack get its node tree, and how do you expose the parts of a canvas-drawn chart?

    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.

    // Ten bars on one canvas are ONE node until you publish virtual onesclass ChartTouchHelper(private val view: ChartView) : ExploreByTouchHelper(view) {   override fun getVirtualViewAt(x: Float, y: Float): Int =    view.barIndexAt(x, y) ?: HOST_ID…
  15. 15

    What is wrong with these string resources once the app ships in Russian, and how do you fix them?

    <!-- res/values/strings.xml --><string name="cart_count">%d items in cart</string><string name="moved">Moved %s to %s</string> <!-- res/values-ru/strings.xml --><string name="cart_count">%d товаров в корзине</string><string name="moved">В папку %s перемещён файл %s</string> <!-- call sites  getString(R.string.cart_count, count)  getString(R.string.moved, file.name, folder.name)-->

    cart_count has to be a <plurals> resource, because Russian needs different word forms for 1, 2–4 and 5+, and moved needs positional placeholders: two bare %s is an aapt2 build error, and the Russian sentence puts the folder first.

  16. 16

    You ship English in values/ and a values-ru; a user's language list is Ukrainian, then Russian. Which strings do they get, and why?

    Russian. Since API 24 the user has an ordered LocaleList, and Android walks all of it before falling back to the unqualified values/, so their second choice beats your default.

  17. 17

    How do you implement an in-app language switcher with per-app language preferences on Android 13+ and on older versions?

    Declare the shipped locales in locales_config.xml and change the language only through AppCompatDelegate.setApplicationLocales(): on Android 13+ it goes to the system LocaleManager, below 13 AppCompat stores and re-applies the choice itself.

    // res/xml/locales_config.xml lists the shipped locales and is referenced by// <application android:localeConfig="@xml/locales_config">,// or generated by AGP: androidResources { generateLocaleConfig = true } // One call, every API level. 33+ hands it to LocaleManager and recreates the// activities; below 33 AppCompat stores and re-applies it (autoStoreLocales).…
  18. 18

    Find the bug: nearby search works on English phones but returns nothing when the phone language is Russian or German.

    class PlacesRepository(private val http: HttpClient) {   suspend fun nearby(lat: Double, lon: Double, radiusKm: Double): List<Place> {    val url = "https://api.example.com/places?lat=%.6f&lon=%.6f&radius=%.1f"      .format(lat, lon, radiusKm)    return http.get(url).body()  }}

    .format uses the device's default locale, so on a Russian or German phone the URL carries lat=55,755800, a comma the server cannot parse, and the search fails.

  19. 19

    How do you support right-to-left languages, and what does Android not mirror for you?

    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/rightRow(Modifier.padding(start = 16.dp, end = 8.dp)) {  Icon(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.back))  Text(order.title, Modifier.weight(1f))}…
  20. 20

    How do you format dates, numbers and currency for the user's locale, and what goes wrong with hand-written patterns and toDouble()?

    Always go through a locale-aware formatter (DateTimeFormatter.ofLocalizedDate, NumberFormat.getCurrencyInstance with an explicit Currency, NumberFormat.parse), because hand-written patterns, pasted currency symbols and toDouble() break on other digits, separators and conventions.

    // Wrong: three bugs in three linesval date = SimpleDateFormat("dd/MM/yyyy").format(order.date)  // hand-made patternval total = "$" + String.format("%.2f", order.amount)         // always dollarsval parsed = input.toDouble()                                 // throws on "1.234,50" // Right: the locale formats; the data decides what is being formatted…
  21. 21

    How would you set up a localization pipeline so that translations landing days before a release do not break it?

    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.