Accessibility: TalkBack, touch targets and contrast
Some of Mandi Bhav's users cannot see the screen well and listen to it with TalkBack, Android's screen reader. Many more have turned the font size up, because the person checking onion prices is often in their fifties or sixties and reading in bright sun. Others are tapping with a thumb on a moving bus. None of this is a special mode you add at the end: it is the same screen, used differently. This lesson makes TalkBack read a price card as one sensible sentence, checks touch targets and colour contrast with real numbers, and finally fixes the large-font bug module 3 found.
What TalkBack meets on a price card
TalkBack moves focus through the screen one element at a time: swipe right for the next, double tap to activate. What counts as "one element" is decided by the semantics tree, a parallel description of the UI that Compose builds alongside the layout, and that accessibility services read.
You can see what accessibility services are given without turning TalkBack on, by dumping the tree from the emulator:
adb shell uiautomator dump /sdcard/a11y.xml
adb pull /sdcard/a11y.xml
Search the file for text= and content-desc=. For the module 3 build, every card was four
separate text nodes:
text="Onion"
text="Lasalgaon APMC · 7 Nov 2026"
text="₹2,450 / quintal"
text="Range ₹1,200 – ₹2,851"
Five cards, twenty swipes. Worse, each fragment is read without its context ("₹2,450 / quintal",
then a swipe, then "Range…"), and how the speech engine says ₹, ·, / and – depends on the
engine and its language. You are trusting a text-to-speech voice to turn punctuation into a
sentence.
Merging, and replacing
Compose gives you two ways to make the card one stop:
// Merge: one focus stop that reads the children's texts in order.
Card(modifier = modifier.semantics(mergeDescendants = true) {}) { … }
// Clear and set: one focus stop that reads exactly what you say.
Card(modifier = modifier.clearAndSetSemantics { contentDescription = description }) { … }
semantics(mergeDescendants = true) |
clearAndSetSemantics { … } |
|
|---|---|---|
| Focus stops per card | One | One |
| What is read | Each child's text, in layout order, symbols and all | Only what you set |
| Children's actions (buttons inside) | Kept | Removed; you must add them back |
| When to use it | The texts already read well in order | The visual layout does not make a good sentence |
clickable and Material's Button merge their children for you; a plain Card does not.
A price card read aloud should sound like a person saying it, so Mandi Bhav writes the sentence
itself. Create ui/PriceDescription.kt:
package com.riztechacademy.mandibhav.ui
import com.riztechacademy.mandibhav.model.CommodityPrice
import java.time.format.DateTimeFormatter
import java.util.Locale
private val spokenDateFormat = DateTimeFormatter.ofPattern("d MMMM yyyy", Locale.ENGLISH)
/** The grouped digits of [formatRupees] without the rupee sign: "2,450". */
private fun groupedDigits(amount: Long): String = formatRupees(amount).replace("₹", "")
/**
* An amount as a screen reader should say it: "2,450 rupees", not
* "rupee sign 2,450".
*/
fun spokenRupees(amount: Long): String = "${groupedDigits(amount)} rupees"
/** What TalkBack reads for a whole price card: one sentence, in reading order. */
fun priceCardDescription(price: CommodityPrice): String =
"${price.commodity} at ${price.market}, " +
"${spokenRupees(price.modalPrice)} per quintal. " +
"Range ${groupedDigits(price.minPrice)} to ${spokenRupees(price.maxPrice)}. " +
spokenDateFormat.format(price.date) + "."
It reuses formatRupees, so the spoken digits are grouped exactly like the visible ones, and it
writes the month in full, because "Nov" read aloud is a guess. It is plain Kotlin, so it gets a
test, PriceDescriptionTest.kt beside the others:
package com.riztechacademy.mandibhav.ui
import com.riztechacademy.mandibhav.data.SamplePrices
import org.junit.Assert.assertEquals
import org.junit.Test
class PriceDescriptionTest {
@Test
fun spokenRupeesPutsTheUnitAfterTheNumber() {
assertEquals("2,450 rupees", spokenRupees(2_450))
assertEquals("2,45,000 rupees", spokenRupees(2_45_000))
}
@Test
fun cardDescriptionIsOneSentenceInReadingOrder() {
assertEquals(
"Onion at Lasalgaon APMC, 2,450 rupees per quintal. " +
"Range 1,200 to 2,851 rupees. 7 November 2026.",
priceCardDescription(SamplePrices.lasalgaonOnion),
)
}
}
In PriceCard, compute the description and set it on the card:
@Composable
fun PriceCard(price: CommodityPrice, modifier: Modifier = Modifier) {
val description = priceCardDescription(price)
Card(
// TalkBack reads the card as one sentence, not a string of fragments.
modifier = modifier.clearAndSetSemantics { contentDescription = description },
) {
with the imports androidx.compose.ui.semantics.clearAndSetSemantics and
androidx.compose.ui.semantics.contentDescription. The caller's modifier still comes first, as
module 3 taught. After this change, the dump showed each card as a single node and no text nodes
inside it:
content-desc="Onion at Lasalgaon APMC, 2,450 rupees per quintal. Range 1,200 to 2,851 rupees. 7 November 2026."
What it costs. clearAndSetSemantics throws away everything inside the card. If the card
later gains a "Compare markets" button, TalkBack users will not find it unless you add it back as a
custom action (practice 6). And the sentence is English, built in Kotlin; when the app is
translated it has to move into string resources with placeholders, as module 2 showed for
visible text.
contentDescription on its own is mainly for things that have no text: an Icon or an Image.
Module 3's rule still holds: describe an icon that is the only label of a button, and pass null
for one that only decorates text that already says the same thing.
Touch targets: 48 dp
Material's minimum for anything tappable is 48 × 48 dp. Module 3 measured what a missing margin of tappable padding costs. Look at the filter chips: they are drawn 32 dp tall, and the dump reported each one's tappable node as 48 dp tall. Material components reserve the extra space themselves, centred on what they draw.
Your own small clickables get some help too. The Compose documentation says a clickable smaller than the minimum still receives taps a little outside its edge, and on the emulator it did: a tap 20 dp below the centre of a custom 28 dp-tall chip still reached it. But that expansion does not reserve space. Two small targets side by side have overlapping expanded areas, and a tap between them goes to whichever is nearer, which is not always the one the user meant. So reserve the space for anything you build yourself:
Box(
modifier = modifier
.minimumInteractiveComponentSize()
.clip(shape)
.border(1.dp, MaterialTheme.colorScheme.outline, shape)
.clickable { onClick() }
.padding(horizontal = 12.dp, vertical = 4.dp),
)
minimumInteractiveComponentSize() (from material3) keeps the chip's drawn size and reserves at
least 48 dp round it, so neighbours move out of the way. Module 3's MarketChip used
heightIn(min = 48.dp), which also works but makes the visible chip itself 48 dp tall.
Contrast: measure it
WCAG, the web accessibility standard Android's guidance follows, asks for a contrast ratio of at least 4.5:1 for normal text and 3:1 for large text. The ratio compares the relative luminance of the two colours and runs from 1:1 (identical) to 21:1 (black on white). You can compute it, or use Google's Accessibility Scanner app on a phone, which flags low contrast on whatever screen you point it at.
These were measured for Mandi Bhav's theme, text on the card colour:
| Pair | Light | Dark |
|---|---|---|
onSurface on card |
13.28:1 | 9.58:1 |
onSurfaceVariant on card (market, date) |
7.21:1 | 7.28:1 |
primary on card (the modal price) |
3.98:1 | 7.35:1 |
onPrimary on primary |
5.13:1 | 7.85:1 |
The light theme's green price, lesson 4's #2E7D32, measured 3.98:1 on the card. The price is big
enough to count as large text, so it scraped through; but primary is the colour every button
label and link in the app will use at normal size, and Mandi Bhav is read outdoors. In
Color.kt, change the light green and record why:
// Mandi Bhav's palette: a field green for the brand, a turmeric accent, and
// neutrals with a slight green tint so surfaces belong to the same family.
// Every on-colour was checked against its background for WCAG AA contrast.
// Light scheme
val Green40 = Color(0xFF1B6D24)
#1B6D24 measures 5.0:1 on the card and 6.44:1 with white text on it, so it passes at any size.
The cost: the app's green is now a shade darker than the launcher icon's #2E7D32. Nobody
will notice two greens; somebody would notice a price they cannot read in sunlight.
Font scale: the bug from module 3
Module 3's large-text preview showed the market and date line breaking in the middle of the date. Reproduce it on the emulator with the module 3 card. Set the system font scale to 1.5:
adb shell settings put system font_scale 1.5
On the 411 dp-wide emulator, "Lasalgaon APMC · 7 Nov 2026" still fitted on one line. "Pune (Gultekdi) APMC · 7 Nov 2026" did not: it broke as "Pune (Gultekdi) APMC · 7 Nov", with "2026" alone on the next line. The preview in module 3, at 320 dp, broke it before "Nov" instead. Same bug, different width, different break: which is why a fix that only adjusted the text to suit one screen would not be a fix.
The fix is the one module 3's answers recommended: market and date on separate lines. In
PriceCard, replace the combined Text with two:
// Market and date on separate lines: at a large font size a
// shared line wrapped in the middle of the date.
Text(
text = price.market,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = priceDateFormat.format(price.date),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
At 1.5 the fixed card showed "Pune (Gultekdi) APMC" on one line and "7 Nov 2026" on the next, for
every card. The column's spacedBy(4.dp) from lesson 1 spaces the extra line without a change.
A long market name can still wrap, but now it wraps within the name, which reads naturally.
At 2.0, the largest scale on recent Android versions, the screen still worked: the chips took
three lines, the search label wrapped to two, and nothing was cut off. The price did not grow to
twice its size: since Android 14, font scaling is non-linear, so text that is already large grows
less than small text. That is one more reason to use sp for every text size and never put a
fixed height on anything containing text.
Put the emulator back when you finish:
adb shell settings put system font_scale 1.0
Check your work
What TalkBack reads, and where it comes from. The semantics tree that Compose builds alongside the layout.
How many focus stops the module 3 card had. Four per card: one for each Text.
The difference between mergeDescendants and clearAndSetSemantics. Merging keeps the
children and reads their texts in order; clearing replaces them with exactly what you set, actions
included.
Why Mandi Bhav writes its own sentence. The visible text relies on symbols and abbreviations, and the order of fragments does not make a sentence.
How tall the filter chips are, drawn and for touch. 32 dp drawn, 48 dp tappable.
What minimumInteractiveComponentSize() adds over Compose's automatic touch expansion. It
reserves the space in the layout, so neighbouring targets cannot overlap.
The two WCAG AA thresholds. 4.5:1 for normal text, 3:1 for large text.
Why the light green changed. #2E7D32 measured 3.98:1 on the card, below 4.5:1, and
primary will be used for normal-size text.
Why the module 3 card broke at 1.5×. Market and date shared one line, and the line wrapped inside the date. On the emulator it left "2026" alone on the second line.
Practice 5. The card is one focus stop, and TalkBack reads its texts in order, as the speech engine pronounces them: the commodity, the market, the date, the price with its rupee sign, "per quintal", then min and max. Better than four stops; not as clear as the written sentence.
Practice 6.
Card(
modifier = modifier.clearAndSetSemantics {
contentDescription = description
customActions = listOf(
CustomAccessibilityAction(label = "Compare markets") {
onCompare()
true
},
)
},
) { … }
TalkBack then offers "Compare markets" in its actions menu for the card. Imports:
CustomAccessibilityAction and customActions from androidx.compose.ui.semantics.
Practice
- Add
PriceDescription.kt, its test, the semantics onPriceCard, the darker green and the split market and date lines. Run the tests: there are now twelve. - Dump the accessibility tree before and after the change, and compare the nodes for one card.
- Learn how to turn TalkBack off before you turn it on: on most phones it is in Settings > Accessibility > TalkBack, and while it is on, scrolling needs two fingers and activating needs a double tap. Then turn it on, swipe through Mandi Bhav, and listen to a card. Turn it off.
- Set the font scale to 1.5 and to 2.0, look at every screen, and restore it to 1.0.
- Break it on purpose: replace
clearAndSetSemantics { … }withsemantics(mergeDescendants = true) {}and listen to a card with TalkBack. Then put it back. - Harder: module 3's practice added a "Compare markets" button to each card. With
clearAndSetSemantics, TalkBack users cannot reach it. Give the card a custom accessibility action instead, so the button stays for touch and the action is offered to TalkBack.
Official documentation
- Accessibility in Compose — The overview: semantics, TalkBack and the testing tools.
- Semantics — The semantics tree, merging and clearing, and custom actions.
- Accessibility defaults in Compose APIs — Touch target sizes, automatic expansion and
minimumInteractiveComponentSize. - WCAG: contrast minimum — The 4.5:1 and 3:1 rule, what counts as large text, and how the ratio is computed.
- Test your app's accessibility — TalkBack, Accessibility Scanner and automated checks.
Next: module 5, State and Lifecycle.
That is module 4. You can now:
- lay out a screen with
Column,RowandBox, put the weight on the child that can give way, and align text by its baseline; - handle edge-to-edge properly:
innerPadding, the display cutout, and the keyboard withimePaddingandconsumeWindowInsets, and catch the mistakes with lint; - choose between
ConstraintLayoutand nested layouts on the evidence, and wrap a row of filter chips withFlowRow; - give an app its own Material theme, with colour roles, a type scale and no hex values outside
Color.kt; - support dark mode down to the launch splash, and argue for or against dynamic colour;
- switch layouts by window size class and verify them on a resized emulator;
- make a screen work with TalkBack, at 48 dp touch targets, at measured contrast and at large font sizes.
Mandi Bhav now looks like itself in light and dark, filters by commodity and by search, uses two
columns on a foldable or a tablet, and reads each price aloud as one sentence. It also forgets your
search and your chosen commodity every time the phone rotates, because both live in remember.
Module 5, State and Lifecycle, starts with remember and mutableStateOf properly, and ends
with an app that keeps the user's work through rotation and through Android killing the process.
Stuck on this lesson?
Being stuck is part of it — but being stuck alone for three days is not. Selected students who finish a course can join our internship, with real-world projects and code review from a working engineer.
About the internship