Techbase Academy
Android · Lesson 47

Accessibility in Compose: Labels, Touch Targets and Contrast

15 phasesAccessible Order ScreenAndroid Studio
Phase 1 of 15
Lesson Introduction

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 with clearAndSetSemantics.
  • 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.

✏️ Your Task
Practise what you just learned about Lesson Introduction. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 2 of 15
Prerequisite Concepts
  • 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).
✏️ Your Task
Practise what you just learned about Prerequisite Concepts. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 3 of 15
Conceptual Understanding: Semantics Properties

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).

✏️ Your Task
Practise what you just learned about Conceptual Understanding: Semantics Properties. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 4 of 15
Step-by-Step Build 1: Fix the Labels

Start from BadScreen of lesson 46. Fix problem B (an unlabelled icon) and problem C (a small target) together by using the right component.

kotlin
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:

kotlin
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

kotlin
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:

kotlin
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() }
 )
}
✏️ Your Task
Practise what you just learned about Step-by-Step Build 1: Fix the Labels. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 5 of 15
Step-by-Step Build 2: Roles, State and Click Labels

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.

kotlin
import androidx.compose.material3.Button
import androidx.compose.material3.Text
Button(onClick = { /* pay */ }) { Text("Pay now") }

2. Add a role to a custom clickable.

kotlin
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

kotlin
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.

✏️ Your Task
Practise what you just learned about Step-by-Step Build 2: Roles, State and Click Labels. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 6 of 15
Step-by-Step Build 3: Merging and Clearing Semantics

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:

kotlin
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:

kotlin
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.

✏️ Your Task
Practise what you just learned about Step-by-Step Build 3: Merging and Clearing Semantics. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 7 of 15
Step-by-Step Build 4: Custom Actions Instead of Gestures

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.

kotlin
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.

✏️ Your Task
Practise what you just learned about Step-by-Step Build 4: Custom Actions Instead of Gestures. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 8 of 15
Step-by-Step Build 5: Live Regions and Announcements

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:

kotlin
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.

✏️ Your Task
Practise what you just learned about Step-by-Step Build 5: Live Regions and Announcements. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 9 of 15
Step-by-Step Build 6: Touch Targets

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:

kotlin
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.

✏️ Your Task
Practise what you just learned about Step-by-Step Build 6: Touch Targets. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 10 of 15
Step-by-Step Build 7: Colour Contrast

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):

Contrast.kt
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:

ContrastTest.kt
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.

kotlin
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
Text("Order summary", color = MaterialTheme.colorScheme.onSurface)

Colour is not enough (problem E)

kotlin
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.

✏️ Your Task
Practise what you just learned about Step-by-Step Build 7: Colour Contrast. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 11 of 15
Step-by-Step Build 8: Large Text and Display Size (Problems D and 5)

Rules that make layouts survive 200% text:

  1. sp for text, always.
  2. Do not fix the height of anything that contains text. Use heightIn(min = ...) or wrap content.
  3. Prefer weight and wrapping over fixed widths. A Row with three buttons may need to become a Column at large sizes. FlowRow wraps items automatically (androidx.compose.foundation.layout.FlowRow; some versions require an experimental opt-in).
  4. Scrollable screens: anything that can grow taller than the screen must scroll (verticalScroll or a lazy list).
  5. maxLines and overflow: truncating important text is a barrier; only use it when the full text is available elsewhere.
  6. Never disable zoom or lock orientation without a strong reason.
kotlin
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.

✏️ Your Task
Practise what you just learned about Step-by-Step Build 8: Large Text and Display Size (Problems D and 5). Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 12 of 15
Step-by-Step Build 9: Focus and Reading Order

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:

kotlin
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.

✏️ Your Task
Practise what you just learned about Step-by-Step Build 9: Focus and Reading Order. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 13 of 15
Guided Practice Exercises
🎯 Your Challenge

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.

kotlin
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.

🎯 Your Challenge

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.

kotlin
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.)

🎯 Your Challenge

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.

✏️ Your Task
Practise what you just learned about Guided Practice Exercises. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 14 of 15
Common Mistakes (and How to Fix Them)

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.

✏️ Your Task
Practise what you just learned about Common Mistakes (and How to Fix Them). Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 15 of 15
Knowledge Check

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

✏️ Your Task
Practise what you just learned about Knowledge Check. Open Android Studio, type the examples by hand, change a value, and observe what happens.
🏗️ Build It — Mini Project
Accessible Order Screen

Project goal: Build a complete order screen that passes a TalkBack pass, large text and contrast checks.

Requirements:

  1. 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".
  2. A section heading marked with heading().
  3. A quantity display with stateDescription ("2 of 5 allowed").
  4. An error banner (icon, text, live region) that appears when the quantity limit is reached.
  5. A Pay Button and an optional gift-wrap Switch with a visible label (toggleable role).
  6. All colours from theme role pairs; a contrast unit test on any custom colours.
  7. 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".

OrderScreen.kt
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.

1

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.

2

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”.

3

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.

4

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.

5

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
All 72 lessons in Google Android Studio ·Techbase Academy ·© 2025–2026 Techbase Consultant Services, Ibadan
Esc
↑ ↓ to moveEnter to openEsc to close