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
| Rule | Why |
|---|---|
contentDescription for all images | TalkBack reads it aloud; null for decorative images |
mergeDescendants = true | Group related content into one accessible item |
| 48dp minimum touch targets | Motor-impaired users need larger targets |
heading() semantic | Enables TalkBack heading navigation (swipe up/down) |
liveRegion for dynamic updates | Announces changes without user focus moving |
| Custom actions | Expose swipe/long-press gestures as accessible named actions |