androidengineers.Book a session

Android Platform Services

Accessibility APIs & Semantics

article20 minMedium

Accessibility ensures your app works for users with disabilities — visual, motor, and cognitive. Android's accessibility services (TalkBack, Switch Access) rely on semantic information you provide through APIs.

Why Accessibility Matters

  • ~15% of users have some form of disability
  • Required by law in many jurisdictions (ADA, EN 301 549)
  • Accessibility improvements benefit all users (better touch targets, clear labels)
  • Google Play Surface quality score includes accessibility criteria

View System: Content Descriptions

// ImageView: always set contentDescription
imageView.contentDescription = "Profile photo of ${user.name}"

// For decorative images (no information), mark as unimportant
decorativeImageView.importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO

// Custom click action description
button.accessibilityDelegate = object : View.AccessibilityDelegate() {
    override fun onInitializeAccessibilityNodeInfo(host: View, info: AccessibilityNodeInfo) {
        super.onInitializeAccessibilityNodeInfo(host, info)
        info.addAction(AccessibilityNodeInfo.AccessibilityAction(
            AccessibilityNodeInfo.ACTION_CLICK,
            "Submit order"  // instead of generic "double tap to activate"
        ))
    }
}

Compose: Semantics

// Content description for images
Image(
    painter = painterResource(R.drawable.logo),
    contentDescription = "Company logo"  // null for decorative images
)

// Custom semantic properties
Box(
    modifier = Modifier.semantics {
        contentDescription = "Shopping cart, 3 items"
        role = Role.Button
        onClick(label = "Open cart") { navigateToCart(); true }
    }
)

// Merge children semantics (accessibility sees the whole card, not individual elements)
Card(
    modifier = Modifier.semantics(mergeDescendants = true) {}
) {
    Row {
        Image(/* product thumbnail */)
        Column {
            Text(product.name)
            Text(product.price.format())
        }
    }
}
// TalkBack reads: "Product name, $29.99" as a single item

Minimum Touch Targets

Google's accessibility guidelines require 48dp minimum touch targets:

// Custom Modifier
fun Modifier.minimumTouchTarget(minSize: Dp = 48.dp): Modifier = this.then(
    Modifier.layout { measurable, constraints ->
        val minPx = minSize.roundToPx()
        val placeable = measurable.measure(constraints)
        val width = maxOf(placeable.width, minPx)
        val height = maxOf(placeable.height, minPx)
        layout(width, height) {
            val offsetX = (width - placeable.width) / 2
            val offsetY = (height - placeable.height) / 2
            placeable.placeRelative(offsetX, offsetY)
        }
    }
)

// For small icons, use padding instead
IconButton(
    onClick = { /* */ },
    modifier = Modifier.size(48.dp)  // minimum touch target
) {
    Icon(Icons.Default.Close, contentDescription = "Close dialog")
}

Live Regions: Announce Dynamic Updates

// Announce changes to screen reader users without focus moving
statusText.accessibilityLiveRegion = View.ACCESSIBILITY_LIVE_REGION_POLITE

// In Compose
Text(
    text = statusMessage,
    modifier = Modifier.semantics {
        liveRegion = LiveRegionMode.Polite  // announces when text changes
    }
)

// Example: announce upload progress
if (uploadProgress == 100) {
    Text(
        "Upload complete",
        modifier = Modifier.semantics { liveRegion = LiveRegionMode.Polite }
    )
}

Heading Hierarchy

@Composable
fun ArticleScreen(article: Article) {
    Column {
        Text(
            article.title,
            style = MaterialTheme.typography.headlineLarge,
            modifier = Modifier.semantics { heading() }  // marks as heading for TalkBack navigation
        )

        Text(
            "Author",
            style = MaterialTheme.typography.titleMedium,
            modifier = Modifier.semantics { heading() }
        )

        Text(article.authorName)

        Text(
            "Content",
            style = MaterialTheme.typography.titleMedium,
            modifier = Modifier.semantics { heading() }
        )

        Text(article.body)
    }
}

Custom Accessibility Actions

// Let TalkBack users swipe to delete without needing precise touch control
LazyColumn {
    items(messages) { message ->
        MessageRow(
            message = message,
            modifier = Modifier.semantics {
                customActions = listOf(
                    CustomAccessibilityAction("Delete message") {
                        deleteMessage(message.id)
                        true
                    },
                    CustomAccessibilityAction("Mark as read") {
                        markRead(message.id)
                        true
                    }
                )
            }
        )
    }
}

Testing Accessibility

// Compose UI test
@Test
fun `all images have content descriptions`() {
    composeTestRule.setContent { FeedScreen(posts = testPosts) }

    // Find nodes without content descriptions (should be none for informational images)
    composeTestRule.onAllNodes(hasNoSemantics())
        .fetchSemanticsNodes()
        .filter { node -> node.config.contains(SemanticsProperties.Role) }
        .forEach { node ->
            fail("Node ${node.id} has no content description")
        }
}

Key Takeaways

RuleWhy
contentDescription for all imagesTalkBack reads it aloud; null for decorative images
mergeDescendants = trueGroup related content into one accessible item
48dp minimum touch targetsMotor-impaired users need larger targets
heading() semanticEnables TalkBack heading navigation (swipe up/down)
liveRegion for dynamic updatesAnnounces changes without user focus moving
Custom actionsExpose swipe/long-press gestures as accessible named actions

YOUR LEARNING JOURNEY

0 of 177 available lessons completed

Progress saved in this browser. No account needed.
Accessibility APIs & Semantics | Android System Design | Android Engineers