RizTech Academy logo
RizTech Academy
Layout and ThemingLesson 7 of 730 min

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

  1. Add PriceDescription.kt, its test, the semantics on PriceCard, the darker green and the split market and date lines. Run the tests: there are now twelve.
  2. Dump the accessibility tree before and after the change, and compare the nodes for one card.
  3. 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.
  4. Set the font scale to 1.5 and to 2.0, look at every screen, and restore it to 1.0.
  5. Break it on purpose: replace clearAndSetSemantics { … } with semantics(mergeDescendants = true) {} and listen to a card with TalkBack. Then put it back.
  6. 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

Next: module 5, State and Lifecycle.


That is module 4. You can now:

  • lay out a screen with Column, Row and Box, 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 with imePadding and consumeWindowInsets, and catch the mistakes with lint;
  • choose between ConstraintLayout and nested layouts on the evidence, and wrap a row of filter chips with FlowRow;
  • 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