In lesson 46 you learned who needs accessibility, tried the system tools and found six problems in a deliberately bad screen. Now you fix them. Compose gives you a strong starting point: Material 3 components are accessible by default (minimum touch targets, roles, states, ripple focus), and the semantics system lets you describe anything custom.
This lesson covers Add accessibility features to your app, the Accessibility in Jetpack Compose codelab and Troubleshoot common accessibility issues from the Make your Android app accessible pathway (https://developer.android.com/courses/pathways/make-your-android-app-accessible) and Pathway 4 of Jetpack Compose for Android developers (https://developer.android.com/courses/pathways/jetpack-compose-for-android-developers-4).
By the end you will be able to:
- Add meaningful content descriptions and mark decorative images.
- Use the semantics API:
Modifier.semantics, roles,stateDescription,heading(),onClickLabel, live regions. - Merge related elements into one with
mergeDescendants, or hide children withclearAndSetSemantics. - Provide custom accessibility actions as alternatives to gestures.
- Meet the 48 dp touch target minimum and understand
minimumInteractiveComponentSize. - Calculate and fix colour contrast (4.5:1 and 3:1) and avoid colour-only meaning.
- Make layouts that survive large text and display size.
- Control focus and traversal order.
Big idea: if you use the standard Material 3 component, you get most accessibility for free. Where you build your own, you must say what it is, what it is called, what state it is in and what it can do.
- Accessibility fundamentals, TalkBack, common problems (lesson 46).
- Compose testing and the semantics tree (lesson 45).
- Material 3 theming and colour roles (lesson 40).
- Modifiers and layout (lessons 16 and 38).
Compose builds the semantics tree from composables. Many components add semantics for you; Modifier.semantics { ... } adds or changes them.
| Semantics property | Meaning | Example |
|---|---|---|
contentDescription |
A label for non-text content | An icon's purpose |
text |
Visible text (automatic for Text) |
|
role |
What kind of control it is | Role.Button, Checkbox, Switch, RadioButton, Tab, Image |
stateDescription |
A custom description of state | "3 of 5 steps done" |
heading() |
Marks a heading so users can jump between headings | Section titles |
onClick(label, action) |
A click action with a label | "Open details" |
customActions |
Extra actions in a menu | "Archive", "Delete" |
liveRegion |
Announce changes automatically | An error message |
disabled() / selected / toggleableState |
State | |
traversalIndex, isTraversalGroup |
Reading order | |
invisibleToUser() |
Hide from accessibility | Decorative shapes |
collectionInfo, collectionItemInfo |
Row/column positions in lists | Lists, grids |
You can read the full list in the SemanticsProperties and SemanticsPropertyReceiver reference (do not memorise it; know where to look).
Start from BadScreen of lesson 46. Fix problem B (an unlabelled icon) and problem C (a small target) together by using the right component.
import androidx.compose.foundation.layout.Row
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
@Composable
fun OrderLine(itemName: String, quantity: Int, onDelete: () -> Unit) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text("$itemName x $quantity")
IconButton(onClick = onDelete) {
Icon(
imageVector = Icons.Filled.Delete,
contentDescription = "Delete $itemName"
)
}
}
}
IconButton is a standard component: it gives the icon a 48 dp touch target, a ripple, a focus indication and the Button role. The description says what the button does and includes the item name, because five rows each saying only "Delete" are indistinguishable when read out of context. TalkBack announces "Delete Zobo, button, double tap to activate".
Use string resources for descriptions so they can be translated:
import androidx.compose.ui.res.stringResource
contentDescription = stringResource(R.string.delete_item, itemName)
with <string name="delete_item">Delete %1$s</string> in strings.xml.
Description rules of thumb
| Do | Do not |
|---|---|
| Describe the purpose ("Search") | Describe the picture ("magnifying glass") |
| Keep it short | Write a sentence for every icon |
| Leave out the control type ("button") | "Search button" (TalkBack already says "button") |
| Update it when state changes ("Pause" / "Play") | Keep one description for both states |
Use null for decorative images |
Describe every divider and background |
| Make descriptions unique in a list | Repeat "Edit" ten times |
Decorative images
Image(
painter = painterResource(R.drawable.divider_pattern),
contentDescription = null // decorative: skipped by screen readers
)
To hide any composable from accessibility services (for example a purely visual shape drawn on a Canvas), use Modifier.semantics { invisibleToUser() }. The API name and availability can change between versions, so check the reference. Also note that text that is only decoration should be avoided.
Headings
Screen readers let users jump from heading to heading, a huge time saver on long screens. Mark your section titles:
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.semantics
@Composable
fun SectionTitle(text: String) {
Text(
text,
style = MaterialTheme.typography.titleLarge,
modifier = Modifier.semantics { heading() }
)
}
Problem F in lesson 46 was a clickable Box that does not announce itself as a button. There are three fixes in order of preference.
1. Use a standard component.
import androidx.compose.material3.Button
import androidx.compose.material3.Text
Button(onClick = { /* pay */ }) { Text("Pay now") }
2. Add a role to a custom clickable.
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.Role
@Composable
fun PayChip(onPay: () -> Unit) {
Box(
Modifier.clickable(
onClickLabel = "Pay for order",
role = Role.Button,
onClick = onPay
)
) {
Text("Pay now")
}
}
onClickLabel customises the hint TalkBack speaks: "double tap to pay for order" instead of the generic "double tap to activate". Use it when the visible text alone does not say what the action will do.
3. Use the right interaction modifier for selection controls.
| Component type | Modifier | Role |
|---|---|---|
| On/off switch or checkbox | Modifier.toggleable(value, role = Role.Switch or Role.Checkbox, onValueChange) |
Switch / Checkbox |
| One choice in a group | Modifier.selectable(selected, role = Role.RadioButton, onClick) |
RadioButton |
| Tabs | Modifier.selectable(selected, role = Role.Tab, ...) |
Tab |
| Single action | Modifier.clickable(role = Role.Button, ...) |
Button |
Custom state description
import androidx.compose.foundation.layout.Column
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.stateDescription
@Composable
fun StepProgress(done: Int, total: Int) {
Column(
Modifier.semantics {
stateDescription = "$done of $total steps completed"
}
) {
Text("Registration")
LinearProgressIndicator(progress = { done / total.toFloat() })
}
}
Without this, TalkBack reads "Registration" and perhaps "25 percent" for the bar; with it, users hear a meaningful phrase.
Merge: one card, one announcement
A list row has a title, a subtitle, an icon and a price. Read separately that is four swipes per row. Merge them so that a user hears one item:
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Card
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
@Composable
fun SnackRow(name: String, description: String, price: String, onClick: () -> Unit) {
Card(
Modifier
.clickable(onClick = onClick)
.semantics(mergeDescendants = true) { }
) {
Column(Modifier.padding(16.dp)) {
Text(name)
Text(description)
Text(price)
}
}
}
Note that clickable already merges its descendants. For a non-clickable container use Modifier.semantics(mergeDescendants = true) { }. TalkBack reads "Chin chin, Crunchy fried dough, 500 naira" as one unit. Keep each merged group small: a whole screen merged into one node is useless.
Clear: replace the children's story
Sometimes the children's automatic description is awkward. clearAndSetSemantics removes all semantics of the subtree and lets you write your own:
import androidx.compose.foundation.layout.Row
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Star
import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.contentDescription
@Composable
fun StarRatingDisplay(stars: Int) {
Row(
Modifier.clearAndSetSemantics {
contentDescription = "Rated $stars out of 5 stars"
}
) {
repeat(5) { index ->
Icon(Icons.Filled.Star, contentDescription = null)
}
}
}
Without it, a screen reader would read five identical "star" icons. With it, one clear sentence. Caution: clearing semantics also removes actions and roles of children; do not use it on a container with buttons inside, or those buttons become unreachable.
Swipe-to-delete is a gesture. People using TalkBack, Switch Access or Voice Access cannot easily swipe a row (TalkBack uses swipes for navigation). Give the row custom accessibility actions, which appear in the TalkBack actions menu and are available to switch and voice users.
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Card
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.CustomAccessibilityAction
import androidx.compose.ui.semantics.customActions
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
@Composable
fun EmailRow(
subject: String,
onArchive: () -> Unit,
onDelete: () -> Unit,
modifier: Modifier = Modifier
) {
Card(
modifier
.fillMaxWidth()
.semantics {
customActions = listOf(
CustomAccessibilityAction("Archive") { onArchive(); true },
CustomAccessibilityAction("Delete") { onDelete(); true }
)
}
) {
Text(subject, Modifier.padding(16.dp))
}
}
Each action returns true when handled. You still keep the visual swipe for sighted touch users; the custom actions are the alternative. The same pattern works for drag-to-reorder ("Move up", "Move down"), long-press menus and double-tap shortcuts.
When content changes without the user moving focus (an error appears, a counter updates, a snackbar), a screen reader will not mention it unless you ask. Mark the region as live:
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.LiveRegionMode
import androidx.compose.ui.semantics.liveRegion
import androidx.compose.ui.semantics.semantics
@Composable
fun ErrorMessage(message: String?) {
if (message != null) {
Text(
text = "Error: $message",
color = MaterialTheme.colorScheme.error,
modifier = Modifier.semantics {
liveRegion = LiveRegionMode.Polite
}
)
}
}
| Mode | Behaviour |
|---|---|
Polite |
Waits until the current speech finishes, then announces |
Assertive |
Interrupts immediately; use rarely (urgent errors) |
Text inputs with isError = true and supportingText already expose their error message to services in Material 3; use a live region for custom messages. The standard Snackbar is announced by Material 3 itself.
The 48 dp rule. Material 3 interactive components (Button, IconButton, Checkbox, Switch, Chip, ...) apply Modifier.minimumInteractiveComponentSize() internally: if the visual is smaller than 48 dp, the touch area is expanded around it without changing the layout drawing. That is why a small Checkbox is easy to hit.
For your own clickables you must do it yourself:
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.Icon
import androidx.compose.material3.minimumInteractiveComponentSize
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.unit.dp
@Composable
fun SmallCloseIcon(onClose: () -> Unit) {
Icon(
imageVector = Icons.Filled.Close,
contentDescription = "Close",
modifier = Modifier
.minimumInteractiveComponentSize() // at least 48 x 48 dp touch area
.clickable(role = Role.Button, onClick = onClose)
.size(24.dp)
)
}
(Order matters: minimumInteractiveComponentSize() first so the clickable area includes the padding.) If you must show a larger gap between targets than 48 dp allows (dense toolbars), add spacing so neighbouring touch areas do not overlap. Also avoid targets at extreme screen edges where system gesture navigation takes over.
How contrast is measured
Contrast ratio compares the relative luminance of two colours. It runs from 1:1 (identical) to 21:1 (black on white).
| Content | Minimum (WCAG AA) |
|---|---|
| Normal text (under 18 sp regular or 14 sp bold) | 4.5 : 1 |
| Large text (18 sp+ regular, or 14 sp+ bold) | 3 : 1 |
| Icons, input borders, focus rings, chart lines that carry meaning | 3 : 1 against adjacent colours |
| Decorative or disabled elements | No requirement (but disabled should still be recognisable) |
Compose can compute contrast for you in a test or debug tool. Here is a small helper you can drop into a unit test (no Android dependencies needed):
package com.example.accessdemo
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.luminance
fun contrastRatio(foreground: Color, background: Color): Float {
val l1 = foreground.luminance()
val l2 = background.luminance()
val lighter = maxOf(l1, l2)
val darker = minOf(l1, l2)
return (lighter + 0.05f) / (darker + 0.05f)
}
Color.luminance() returns the relative luminance (0 black to 1 white) following the standard formula. Check your text pairs in a plain JUnit test:
package com.example.accessdemo
import androidx.compose.ui.graphics.Color
import org.junit.Assert.assertTrue
import org.junit.Test
class ContrastTest {
@Test
fun greyOnWhiteFails() {
val ratio = contrastRatio(Color(0xFFBBBBBB), Color.White)
assertTrue("ratio was $ratio", ratio < 4.5f) // about 1.9
}
@Test
fun darkGreyOnWhitePasses() {
val ratio = contrastRatio(Color(0xFF444444), Color.White)
assertTrue("ratio was $ratio", ratio >= 4.5f) // about 9.7
}
}
(Approximate values: #BBBBBB on white is about 1.9:1; #444444 on white about 9.7:1.) You can also use free online contrast checkers and the Accessibility Scanner (lesson 48).
Fixing the title (problem A)
The simplest way to get good contrast is to use colour roles in pairs (lesson 40): onSurface on surface, onPrimary on primary and so on. The Material 3 baseline pairs are designed to meet contrast requirements, and Material Theme Builder generates schemes that do. Problems appear when you hard-code colours or place text on a photo. Put a semi-opaque scrim behind text on images.
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
Text("Order summary", color = MaterialTheme.colorScheme.onSurface)
Colour is not enough (problem E)
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.heightIn
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.LiveRegionMode
import androidx.compose.ui.semantics.liveRegion
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
@Composable
fun PaymentError(message: String) {
Row(
Modifier
.heightIn(min = 48.dp) // problem D: grows with text
.background(MaterialTheme.colorScheme.errorContainer)
.padding(12.dp)
.semantics { liveRegion = LiveRegionMode.Polite },
verticalAlignment = Alignment.CenterVertically
) {
Icon(Icons.Filled.Warning, contentDescription = null,
tint = MaterialTheme.colorScheme.onErrorContainer)
Text(
"Error: $message",
color = MaterialTheme.colorScheme.onErrorContainer,
modifier = Modifier.padding(start = 8.dp)
)
}
}
The error now has an icon, the word "Error" and a colour, uses a theme pair that meets contrast, grows with large text and is announced when it appears.
Dark mode, high contrast text and dynamic colour
Dark theme needs its own contrast check, not just light. Android also has a high-contrast text setting and, on newer versions, contrast level settings that dynamic colour may follow; test with them on. Never assume that because light mode passes, dark mode does.
Rules that make layouts survive 200% text:
spfor text, always.- Do not fix the height of anything that contains text. Use
heightIn(min = ...)or wrap content. - Prefer
weightand wrapping over fixed widths. ARowwith three buttons may need to become aColumnat large sizes.FlowRowwraps items automatically (androidx.compose.foundation.layout.FlowRow; some versions require an experimental opt-in). - Scrollable screens: anything that can grow taller than the screen must scroll (
verticalScrollor a lazy list). maxLinesandoverflow: truncating important text is a barrier; only use it when the full text is available elsewhere.- Never disable zoom or lock orientation without a strong reason.
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.material3.Button
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.unit.dp
@Composable
fun ActionButtons(onSave: () -> Unit, onShare: () -> Unit, onDelete: () -> Unit) {
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Button(onClick = onSave) { Text("Save") }
Button(onClick = onShare) { Text("Share") }
Button(onClick = onDelete) { Text("Delete") }
}
}
At normal size the three buttons sit in one row. At 200% font scale they wrap onto two or three lines instead of being clipped.
Compose reads in a sensible default order: the order of the tree, left to right and top to bottom in LTR languages. Problems occur with overlapping layouts and with floating elements that are visually first but declared last. You can fix it with traversal semantics:
import androidx.compose.foundation.layout.Box
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.isTraversalGroup
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.traversalIndex
@Composable
fun ScreenWithBanner() {
Box(Modifier.semantics { isTraversalGroup = true }) {
Text(
"Welcome back!",
Modifier.semantics { traversalIndex = -1f } // read first
)
Text("Your orders", Modifier.semantics { traversalIndex = 0f })
}
}
Lower traversalIndex is read earlier within a traversal group. These APIs have been evolving, so check the documentation for the version you use. Always confirm the order by listening with TalkBack: you will notice order bugs in seconds.
Other focus tips: after a dialog or bottom sheet closes, return focus to the element that opened it (Material components do this); do not trap focus inside a screen with no way out; and make every gesture or hover-only action reachable by a visible control.
Exercise 1: Fix the whole BadScreen
Rewrite the BadScreen from lesson 46 so that all six problems are fixed: contrast (use theme colours), delete icon label and 48 dp target, large-text-safe error banner with icon and text, and a proper Pay button. Add a heading for the title.
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
@Composable
fun GoodScreen(
onDelete: () -> Unit,
onPay: () -> Unit,
error: String?
) {
Column(Modifier.padding(16.dp)) {
Text(
"Order summary",
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.semantics { heading() }
)
Row(verticalAlignment = Alignment.CenterVertically) {
Text("Zobo x 2", Modifier.weight(1f))
IconButton(onClick = onDelete) {
Icon(Icons.Filled.Delete, contentDescription = "Delete Zobo")
}
}
if (error != null) PaymentError(error) // from Build 7
Button(onClick = onPay) { Text("Pay now") }
}
}
Fixes: A theme colours; B and C IconButton with a description and 48 dp target; D and E PaymentError (min height, icon + text, live region); F a real Button; plus a heading.
Exercise 2: Accessible star rating input
Build a 5-star input where the user taps a star to set a rating. Make it accessible: each star has a description ("1 star", "2 stars" ...), the selected state is announced, and each star is a 48 dp target. Use Modifier.selectable and Role.RadioButton.
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.selectableGroup
import androidx.compose.foundation.selection.selectable
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Star
import androidx.compose.material.icons.outlined.StarBorder
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.minimumInteractiveComponentSize
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.Role
@Composable
fun StarInput(rating: Int, onRatingChange: (Int) -> Unit) {
Row(Modifier.selectableGroup()) {
for (star in 1..5) {
val selected = star == rating
Icon(
imageVector = if (star <= rating) Icons.Filled.Star else Icons.Outlined.StarBorder,
contentDescription = if (star == 1) "1 star" else "$star stars",
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier
.minimumInteractiveComponentSize()
.selectable(
selected = selected,
role = Role.RadioButton,
onClick = { onRatingChange(star) }
)
)
}
}
}
selectableGroup() tells services the stars are a single group of exclusive choices; Role.RadioButton plus selected lets TalkBack announce "3 stars, radio button, selected". (Icons.Outlined.StarBorder is in the extended icons library; if you do not have it, use Icons.Filled.Star with a dimmer tint.)
Exercise 3: Contrast detective
Using the contrastRatio helper, test these text/background pairs and say which pass for normal text: #767676 on white, #777777 on white, #FFFFFF on #1B6B3A, #FFFFFF on #3DDC84.
Approximate results: #767676 on white is about 4.54:1 (passes, the well-known borderline grey); #777777 on white is about 4.48:1 (just fails for normal text, though passes for large text); white on #1B6B3A (dark green) is about 6.4:1 (passes); white on #3DDC84 (Android green) is roughly 1.7:1 (fails badly; use dark text on that green). Always run the helper rather than trusting your eye, since light-on-light greens look fine on a good screen but fail outdoors.
Mistake #1: A description on every icon inside a labelled button. Symptom: TalkBack reads the label twice ("Save, Save button"). Fix: when text already labels the button, give the icon contentDescription = null.
Mistake #2: clickable without role on a custom control. Fix: standard component or role = Role.Button.
Mistake #3: clearAndSetSemantics on a container with buttons. Symptom: buttons inside unreachable. Fix: use it only on non-interactive groups.
Mistake #4: Merging an entire screen. Symptom: one giant announcement. Fix: merge per item.
Mistake #5: Gesture-only actions. Fix: add visible buttons or customActions.
Mistake #6: Hard-coded colours in custom components. Symptom: contrast fails in dark mode. Fix: theme role pairs, tested in both modes.
Mistake #7: size(24.dp) on a clickable. Fix: minimumInteractiveComponentSize() or IconButton.
Mistake #8: Fixed-height text containers. Fix: heightIn(min).
Mistake #9: No announcement for dynamic errors. Fix: liveRegion or Material's error support.
Mistake #10: Testing with the screen reader off only. Fix: include a 10-minute TalkBack pass per feature.
Question 1. When should contentDescription be null?
When the image or icon is decorative or when its meaning is already given by adjacent text (for example an icon inside a button that has a text label), so it should be skipped by screen readers.
Question 2. What does Modifier.semantics(mergeDescendants = true) do?
It combines the semantics of the node's children into the parent node, so a screen reader treats the group as one item and reads their text together.
Question 3. How do you provide an accessible alternative to a swipe-to-delete gesture?
Add customActions with CustomAccessibilityAction("Delete") { ...; true } (and/or a visible delete button) so TalkBack, Switch Access and Voice Access users can trigger the same operation.
Question 4. What are the minimum contrast ratios for normal text and for large text?
4.5:1 for normal text and 3:1 for large text (and 3:1 for meaningful icons and component boundaries).
Question 5. What does minimumInteractiveComponentSize() do?
It ensures a composable's touch area is at least 48 × 48 dp, expanding the touch target around a smaller visual without changing how it is drawn.
Go deeper: official resources
- Make your Android app accessible pathway
- Jetpack Compose for Android developers, Pathway 4
- Jetpack Compose accessibility codelab
- Build more accessible apps (developer guide)
Project goal: Build a complete order screen that passes a TalkBack pass, large text and contrast checks.
Requirements:
- A list of three order items; each row is a merged item (name, price) with a delete
IconButton(description includes the item name) and custom actions "Increase quantity" and "Decrease quantity". - A section heading marked with
heading(). - A quantity display with
stateDescription("2 of 5 allowed"). - An error banner (icon, text, live region) that appears when the quantity limit is reached.
- A Pay
Buttonand an optional gift-wrapSwitchwith a visible label (toggleable role). - All colours from theme role pairs; a contrast unit test on any custom colours.
- Previews at font scale 1.0 and 2.0.
Stretch goal: write Compose tests (lesson 45) that find the delete button by content description and assert the error banner has the text "Error".
package com.example.accessdemo
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.selection.toggleable
import androidx.compose.foundation.background
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.CustomAccessibilityAction
import androidx.compose.ui.semantics.LiveRegionMode
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.customActions
import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.liveRegion
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.stateDescription
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
private const val MAX_QTY = 5
data class OrderItem(val id: Int, val name: String, val price: Int, val qty: Int)
@Composable
fun OrderScreen() {
var items by remember {
mutableStateOf(listOf(OrderItem(1, "Zobo", 250, 1), OrderItem(2, "Akara", 400, 2), OrderItem(3, "Suya", 1500, 5)))
}
var gift by remember { mutableStateOf(false) }
val atLimit = items.any { it.qty >= MAX_QTY }
fun change(id: Int, delta: Int) {
items = items.map { if (it.id == id) it.copy(qty = (it.qty + delta).coerceIn(1, MAX_QTY)) else it }
}
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text(
"Your order",
style = MaterialTheme.typography.headlineSmall,
modifier = Modifier.semantics { heading() }
)
LazyColumn(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(8.dp)) {
items(items, key = { it.id }) { item ->
Card(
Modifier
.fillMaxWidth()
.semantics(mergeDescendants = true) {
stateDescription = "${item.qty} of $MAX_QTY allowed"
customActions = listOf(
CustomAccessibilityAction("Increase quantity") { change(item.id, +1); true },
CustomAccessibilityAction("Decrease quantity") { change(item.id, -1); true }
)
}
) {
Row(
Modifier.padding(start = 16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text("${item.name}, ${item.qty} x ₦${item.price}", Modifier.weight(1f))
IconButton(onClick = { items = items.filterNot { it.id == item.id } }) {
Icon(Icons.Filled.Delete, contentDescription = "Delete ${item.name}")
}
}
}
}
}
if (atLimit) {
Row(
Modifier
.fillMaxWidth()
.heightIn(min = 48.dp)
.background(MaterialTheme.colorScheme.errorContainer)
.padding(12.dp)
.semantics { liveRegion = LiveRegionMode.Polite },
verticalAlignment = Alignment.CenterVertically
) {
Icon(Icons.Filled.Warning, contentDescription = null,
tint = MaterialTheme.colorScheme.onErrorContainer)
Text(
"Error: maximum $MAX_QTY of one item",
color = MaterialTheme.colorScheme.onErrorContainer,
modifier = Modifier.padding(start = 8.dp)
)
}
}
Row(
Modifier
.fillMaxWidth()
.heightIn(min = 48.dp)
.toggleable(value = gift, role = Role.Switch, onValueChange = { gift = it }),
verticalAlignment = Alignment.CenterVertically
) {
Text("Gift wrap", Modifier.weight(1f))
Switch(checked = gift, onCheckedChange = null) // the row handles the toggle
}
Button(onClick = { }, Modifier.fillMaxWidth()) { Text("Pay now") }
}
}
@Preview(name = "Normal", fontScale = 1.0f, showBackground = true)
@Preview(name = "Largest", fontScale = 2.0f, showBackground = true)
@Composable
private fun OrderScreenPreview() {
MaterialTheme { OrderScreen() }
}
What to check: with TalkBack on, each row is read as one item with its quantity state, "Actions available" appears and offers Increase and Decrease, the error banner is announced when quantity reaches 5, and the gift-wrap row is read as "Gift wrap, switch, off". At font scale 2.0 nothing is clipped and the page scrolls. In the switch row the Switch has onCheckedChange = null so the whole row is a single 48 dp+ target instead of two nested controls.
Open your finished project in Android Studio. Check that the .gitignore file the wizard created is in the project root. It keeps local.properties, build folders and keystores out of Git. Never commit passwords or signing keys.
Choose Git > Create Git Repository… (or VCS > Enable Version Control Integration in older versions), select the project folder, then open Git > Commit, tick every file, and write the message “Add Android lesson 47 project”.
On github.com click New repository, name it android-lesson-47-accessibility-in-compose-labels-touch-targ, set it to Public, leave Add a README unticked, and click Create repository.
Back in Android Studio choose Git > Manage Remotes…, add the repository URL as origin, then choose Git > Push… and push your main branch. Prefer the terminal? Run git remote add origin <your-url> then git push -u origin main.
Add a README.md with a screenshot of your app running and a short description. Employers and clients read READMEs first. Your project is now part of your public portfolio. 🎉
Lesson 47 complete! 🎉
You covered:
- Lesson Introduction
- Prerequisite Concepts
- Conceptual Understanding: Semantics Properties
- Step-by-Step Build 1: Fix the Labels
- Step-by-Step Build 2: Roles, State and Click Labels
- Step-by-Step Build 3: Merging and Clearing Semantics
- Step-by-Step Build 4: Custom Actions Instead of Gestures
- Step-by-Step Build 5: Live Regions and Announcements
- Step-by-Step Build 6: Touch Targets
- Step-by-Step Build 7: Colour Contrast
- Step-by-Step Build 8: Large Text and Display Size (Problems D and 5)
- Step-by-Step Build 9: Focus and Reading Order
- Guided Practice Exercises
- Common Mistakes (and How to Fix Them)
- Knowledge Check