Lesson 50 of 83 intermediate

Accessibility on Android: TalkBack, Semantics & Touch Targets

Build apps that work for every user — and pass accessibility audits in senior interviews

Open interactive version (quiz + challenge)

Real-world analogy

Accessibility is the curb cut. Cutting curbs into sidewalks was mandated for wheelchair users, but skateboarders, cyclists, and parents with strollers use them every day. Making your app accessible benefits everyone — users with motor impairments, users in bright sunlight, and users multitasking.

What is it?

Android accessibility is the set of APIs, guidelines, and tools that make apps usable by people with visual, motor, auditory, or cognitive impairments. It is implemented through the AccessibilityNodeInfo system in Views and the Semantics system in Jetpack Compose, surfaced to assistive technologies like TalkBack, Switch Access, and Voice Access.

Real-world relevance

Google Maps, Gmail, and WhatsApp invest heavily in accessibility. WhatsApp labels every voice message play button with a content description including the contact name and duration. Google Maps announces 'Directions to [destination], 12 minutes' as a live region update. Apps that skip this lose 15% of the global population who have some form of disability.

Key points

Code example

// ── Compose: accessible product card ─────────────────────────
@Composable
fun ProductCard(
    product: Product,
    onDelete: () -> Unit,
    modifier: Modifier = Modifier
) {
    Row(
        modifier = modifier
            .fillMaxWidth()
            .semantics(mergeDescendants = true) {}  // merge child semantics
            .clickable { /* open detail */ }
            .padding(16.dp)
    ) {
        AsyncImage(
            model = product.imageUrl,
            contentDescription = null,  // decorative; card semantics cover it
            modifier = Modifier.size(48.dp)
        )
        Spacer(Modifier.width(12.dp))
        Column(Modifier.weight(1f)) {
            Text(
                text = product.name,
                style = MaterialTheme.typography.titleMedium,
                modifier = Modifier.semantics { heading() }
            )
            Text(
                text = product.price,
                style = MaterialTheme.typography.bodySmall,
                color = MaterialTheme.colorScheme.onSurfaceVariant
            )
        }
        IconButton(
            onClick = onDelete,
            modifier = Modifier
                .minimumInteractiveComponentSize()  // enforces 48dp touch target
                .semantics {
                    contentDescription = "Delete ${product.name}"
                    customActions = listOf(
                        CustomAccessibilityAction("Delete") { onDelete(); true }
                    )
                }
        ) {
            Icon(Icons.Default.Delete, contentDescription = null)
        }
    }
}

// ── Live region for status updates ───────────────────────────
@Composable
fun SyncStatus(message: String) {
    Text(
        text = message,
        modifier = Modifier.semantics {
            liveRegion = LiveRegionMode.Polite
        }
    )
}

Line-by-line walkthrough

  1. 1. semantics(mergeDescendants = true) on the Row collapses all child semantic nodes into one — TalkBack reads the card as a single focused item, not a dozen separate elements
  2. 2. AsyncImage with contentDescription = null marks the image as decorative — the merged Row semantics already describe the product
  3. 3. Modifier.semantics { heading() } on the product name Text marks it as a heading — TalkBack users can swipe to jump between headings without traversing every element
  4. 4. minimumInteractiveComponentSize() on the IconButton enforces the 48dp touch target rule even when the visible icon is 24dp
  5. 5. contentDescription on the IconButton semantics block overrides the default 'button' announcement with the specific product name — critical for lists with many delete buttons
  6. 6. customActions exposes 'Delete' as an accessibility action — TalkBack users in 'explore by touch' mode can activate it from the actions menu instead of needing to precisely tap the icon
  7. 7. SyncStatus Text with liveRegion = LiveRegionMode.Polite announces status changes (e.g. 'Sync complete') to TalkBack after the current speech finishes — no extra user gesture needed
  8. 8. Icon inside the IconButton has contentDescription = null — the parent button's semantics already carry the description; double-announcing would confuse TalkBack

Spot the bug

@Composable
fun FavoriteButton(isFavorite: Boolean, onClick: () -> Unit) {
    IconButton(onClick = onClick) {
        Icon(
            imageVector = if (isFavorite) Icons.Filled.Favorite
                          else Icons.Outlined.FavoriteBorder,
            contentDescription = "Favorite",
            tint = if (isFavorite) Color.Red else Color.Gray
        )
    }
}
Need a hint?
TalkBack will always say 'Favorite' regardless of state. How should the description communicate the current state to the user?
Show answer
Two bugs: (1) The contentDescription is always 'Favorite' — TalkBack announces the same label whether the item is favorited or not. A blind user cannot tell the current state. Fix: contentDescription = if (isFavorite) 'Remove from favorites' else 'Add to favorites'. This makes the label action-oriented and state-aware. (2) State is communicated only by color (Red vs Gray) — color alone is insufficient for users with color blindness or low vision. The description fix above also resolves this. Optionally add Modifier.semantics { stateDescription = if (isFavorite) 'Favorited' else 'Not favorited' } to expose state separately from the action label, which some screen readers announce as '[label], [state]'.

Explain like I'm 5

TalkBack is like a friend who reads everything on the screen out loud for people who can't see it. If you don't label your pictures and buttons, your friend has to guess — and they usually get it wrong. Adding labels is like writing a description on every sticky note so your friend can read it correctly.

Fun fact

The AccessibilityNodeInfo tree in Android was introduced in API 14 (Android 4.0) — the same release that introduced the 48dp touch target guideline. That guideline is derived from the average adult fingertip width of approximately 9mm, which maps to ~48dp at 160dpi.

Hands-on challenge

Audit one screen of an app you own using Accessibility Scanner. Fix the top three issues it reports. Then write a Compose test using onNode(hasContentDescription('Delete Apple')) and verify the IconButton is reachable and clickable via the semantic tree.

More resources

Open interactive version (quiz + challenge) ← Back to course: Android Interview Mastery