Accessibility (Views and Compose)

This content was generated with the assistance of AI and should be verified against the official documentation of Android before being relied on in production.

See the section bibliography for the reference material consulted while preparing these pages.

Accessibility is not a checklist item to bolt on before a release; it is a property of every screen TrailMate ships, in both its legacy View-based flows and its Compose screens. This page covers the platform’s assistive technologies — TalkBack, Switch Access and Select to Speak — and how each UI toolkit exposes information to them: contentDescription and importantForAccessibility in the View system, and the semantics tree (Modifier.semantics, mergeDescendants, clearAndSetSemantics, stateDescription, role, heading, customActions and traversal order) in Compose. It also covers cross-cutting concerns that apply to both toolkits — minimum touch target size, color contrast, sp-based non-linear font scaling, live regions and focus — and closes with how to test for regressions with Accessibility Scanner, Espresso’s AccessibilityChecks and Compose’s own semantics-based checks. It targets this section’s baseline — Android 17 (API 37), Jetpack Compose (BOM 2026.09.00), AndroidX Core 1.17.0 (AccessibilityDelegateCompat, AccessibilityNodeInfoCompat) — and builds on Views, XML Layouts, View Binding and Material Components and Jetpack Compose Fundamentals. This is an Android-specific, implementation-level page: for the underlying standard (WCAG, the POUR principles and conformance levels) that Android’s own accessibility guidance is built to serve, see Web Accessibility.

Why Accessibility Matters, and What "Accessible" Means Here

An accessible app can be perceived, operated and understood by people who cannot see the screen, cannot use a touchscreen with fine motor control, cannot hear audio cues, or need larger text or higher contrast to read comfortably. On Android this is not abstract: TalkBack alone has hundreds of millions of users worldwide, and every user who enables a larger system font size or a high-contrast theme is exercising the same accessibility surface that assistive technology relies on. TrailMate’s hiking-trail list, difficulty badges, favorite toggle and photo capture flow all need to work for someone who never sees the screen and for someone who never touches it, not just for the sighted, precise-tap user the layout was designed around first.

Two ideas recur throughout this page. First, both UI toolkits build an accessibility tree — a live, parallel structure to the visual layout, made of roles, labels, states and actions — that TalkBack, Switch Access and other assistive services read and act on instead of the rendered pixels. Second, most accessibility work is not writing separate "accessible" UI; it is describing the same UI correctly: giving an icon-only button a label, marking a decorative image as unimportant, grouping a compound row into one announcement, and sizing a tap target so it can be hit reliably. See Android Developers — Accessibility overview and Android Developers — Principles for improving app accessibility.

Assistive Technologies: TalkBack, Switch Access and Select to Speak

Three built-in Android services cover most of the assistive-technology surface an app needs to consider:

Service What it does

TalkBack

Google’s screen reader. Swiping right/left moves a floating focus rectangle between accessibility-tree nodes, reading each one’s label, role and state aloud; a double-tap "clicks" the focused node instead of a direct tap. Users who are blind or have low vision rely on it as their primary way of operating the device.

Switch Access

Lets a user operate the device with one or more external switches (or head movement, or a limited set of keys) instead of the touchscreen, by scanning through focusable elements and selecting one on a switch press. It depends on the same focus order and grouping that TalkBack does — an element TalkBack cannot reach, Switch Access cannot reach either.

Select to Speak

Lets a sighted user tap or circle text or UI elements to have them read aloud, without turning on full screen-reader navigation. Useful for users with reading difficulties or situational needs (e.g. eyes-busy hiking). It reads the same contentDescription/semantics text TalkBack does.

All three are driven by the same underlying accessibility tree, so fixing that tree once (a correct label, a sane grouping, a sensible order) benefits every service, not just TalkBack specifically. See Android Developers — Make apps more accessible for the end-to-end guidance this page’s per-toolkit sections implement.

Accessibility in the View System

contentDescription

Every View that conveys information but has no visible text of its own — an ImageView icon, an ImageButton, a custom favorite-star toggle — needs a contentDescription so TalkBack has something to announce. TrailMate’s trail-list row sets one on the favorite toggle and leaves the thumbnail’s decorative background untouched:

<!-- feature/trails/src/main/res/layout/item_trail.xml -->
<ImageButton
    android:id="@+id/favoriteButton"
    android:layout_width="48dp"
    android:layout_height="48dp"
    android:background="?attr/selectableItemBackgroundBorderless"
    android:src="@drawable/ic_favorite"
    android:contentDescription="@string/cd_toggle_favorite" />

The description is a string resource, not a hardcoded literal, so it is localized like any other user-facing text — see Resources, Configuration, Localization and Themes. Because a favorite toggle’s meaning changes with its state, TrailMate updates the description whenever the icon changes, so TalkBack always announces the current state rather than the icon’s fixed label:

// feature/trails/src/main/kotlin/com/example/trailmate/feature/trails/TrailViewHolder.kt
fun bind(trail: Trail) {
    binding.favoriteButton.apply {
        setImageResource(if (trail.isFavorite) R.drawable.ic_favorite_filled else R.drawable.ic_favorite)
        contentDescription = context.getString(
            if (trail.isFavorite) R.string.cd_remove_favorite else R.string.cd_add_favorite,
        )
    }
}

A contentDescription should describe what the element is or does, not restate its visual appearance ("Star icon" is useless; "Add to favorites" is not) and should never include the control’s role ("button") — TalkBack appends the role itself from the view’s class or AccessibilityNodeInfo. A purely decorative ImageView (a background flourish with no informational content) should instead declare android:contentDescription="@null" explicitly, so linting and Accessibility Scanner recognize it was omitted on purpose rather than forgotten.

importantForAccessibility

android:importantForAccessibility controls whether a View is exposed to the accessibility tree at all, and takes four values: auto (the default; the platform decides based on whether the view is clickable, focusable or has text), yes, no, and noHideDescendants (hide this view and everything under it). TrailMate sets no on a purely decorative divider View between list sections that has no semantic meaning, and noHideDescendants on the entire elevation-chart legend when it is redundant with a spoken summary already provided elsewhere on the screen:

<!-- feature/trails/src/main/res/layout/fragment_trail_detail.xml -->
<View
    android:layout_width="match_parent"
    android:layout_height="1dp"
    android:background="?attr/colorOutlineVariant"
    android:importantForAccessibility="no" />

Getting this wrong in the other direction is the more common mistake: marking a container no also hides every interactive descendant from TalkBack, silently making buttons inside it untappable by screen-reader users even though they remain visually present and touchable. noHideDescendants is for genuinely redundant subtrees only, never as a shortcut to silence a noisy container that still holds real controls — group those instead (see the android:labelFor and merged-semantics discussion below).

An EditText that only makes sense next to its label uses android:labelFor on the label TextView so TalkBack announces the two together instead of as two disconnected nodes:

<!-- feature/trails/src/main/res/layout/fragment_settings.xml -->
<TextView
    android:id="@+id/unitsLabel"
    android:text="@string/units_label"
    android:labelFor="@+id/unitsValue" />
<Spinner
    android:id="@+id/unitsValue"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content" />

See Android Developers — Make apps more accessible for the full importantForAccessibility and grouping guidance this section follows.

Accessibility in Compose: the Semantics Tree

Compose has no contentDescription attribute on a layout node the way a View does; instead, every composable that needs to expose (or suppress) information to assistive technology does so through the semantics tree, a parallel tree Compose builds from Modifier.semantics { …​ } blocks and from the semantics some components (Text, Button, Checkbox, …​) already attach automatically. TalkBack, Switch Access and Compose’s own testing APIs all read this same tree — it is the Compose-world equivalent of the AccessibilityNodeInfo tree the View system builds under the hood. See Android Developers — Accessibility in Compose and Android Developers — Semantics in Compose.

An icon-only button in TrailListScreen sets its label the same way the View version did, through Modifier.semantics, though most of the time the higher-level contentDescription parameter that Icon already exposes is enough and no raw semantics block is needed:

// feature/trails/src/main/kotlin/com/example/trailmate/feature/trails/TrailListScreen.kt
IconButton(onClick = { onToggleFavorite(trail.id) }) {
    Icon(
        imageVector = if (trail.isFavorite) Icons.Filled.Favorite else Icons.Outlined.FavoriteBorder,
        contentDescription = stringResource(
            if (trail.isFavorite) R.string.cd_remove_favorite else R.string.cd_add_favorite,
        ),
    )
}

mergeDescendants

A TrailListItem row in Compose is built from several composables — a thumbnail AsyncImage, a name Text, a difficulty Text, and a favorite IconButton — and without any grouping TalkBack announces each of those as a separate stop, forcing four swipes to hear one trail’s information. Modifier.semantics(mergeDescendants = true) merges an entire subtree’s semantics into a single node with one combined announcement, matching how a View-based compound view naturally reads as one unit:

// feature/trails/src/main/kotlin/com/example/trailmate/feature/trails/TrailListItem.kt
@Composable
fun TrailListItem(trail: Trail, onClick: () -> Unit, onToggleFavorite: () -> Unit) {
    Row(
        modifier = Modifier
            .fillMaxWidth()
            .clickable(onClick = onClick)
            .semantics(mergeDescendants = true) {},
    ) {
        AsyncImage(
            model = trail.photoUrl,
            contentDescription = null, // decorative: the merged row already names the trail
        )
        Column {
            Text(trail.name)
            Text(stringResource(trail.difficulty.labelRes))
        }
        IconButton(onClick = onToggleFavorite) {
            Icon(
                imageVector = if (trail.isFavorite) Icons.Filled.Favorite else Icons.Outlined.FavoriteBorder,
                contentDescription = stringResource(
                    if (trail.isFavorite) R.string.cd_remove_favorite else R.string.cd_add_favorite,
                ),
            )
        }
    }
}

clickable already applies mergeDescendants = true to the row it decorates by default (that is why whole rows in LazyColumn`s so often already read as one TalkBack stop without extra work), so the explicit `semantics(mergeDescendants = true) {} above is shown mainly to make the mechanism visible; in TrailMate’s real code the clickable modifier’s own merging is normally sufficient and no additional semantics block is needed. Nesting is important: mergeDescendants merges everything in the subtree rooted at that modifier, so placing it too high (e.g. on an entire LazyColumn) would collapse the whole list into one unusable announcement — it belongs on each row, not on the list container.

clearAndSetSemantics

Modifier.clearAndSetSemantics { …​ } discards whatever semantics a composable’s descendants would otherwise contribute and replaces them entirely with what is written inside the block. It is the tool for when merging is not enough because the automatically merged text would be wrong or redundant — for example, TrailMate’s distance chip shows both a number and a unit as two separate Text composables ("5.2" and "km") that would otherwise be read as "5.2, km" with an awkward pause; clearAndSetSemantics replaces that with one clean sentence:

// feature/trails/src/main/kotlin/com/example/trailmate/feature/trails/DistanceChip.kt
@Composable
fun DistanceChip(lengthKm: Double, modifier: Modifier = Modifier) {
    Row(
        modifier = modifier.clearAndSetSemantics {
            contentDescription = "Length: ${"%.1f".format(lengthKm)} kilometers"
        },
    ) {
        Text("%.1f".format(lengthKm))
        Text(stringResource(R.string.unit_km))
    }
}

Unlike mergeDescendants, which combines what is already there, clearAndSetSemantics throws it away and substitutes a fresh description — so it should be reached for only when the composed text would otherwise be wrong, not as a default way to add a label (using it on something that already has one correct contentDescription just silently deletes every other property, such as its role or state, that had been attached to it).

stateDescription, role and heading

Three semantics properties describe what a merged node is and what state it is in, separately from its label text:

  • role tells assistive technology how to announce and interact with a node — Role.Button, Role.Checkbox, Role.Switch, Role.RadioButton, Role.Image, Role.Tab. Built-in components like Button and Checkbox already set the right role; role matters when a custom composable behaves like one of those controls without using the underlying component directly (e.g. a Row built to act like a checkbox row).

  • stateDescription announces a control’s current state as a short phrase ("On", "Selected", "3 of 5 completed") that changes as the state changes, read in addition to the label rather than replacing it, and announced again automatically whenever it changes without any extra plumbing.

  • heading marks a node as a section heading, letting TalkBack users jump directly between headings the way a sighted user visually scans for one — useful on `TrailDetailScreen’s "Description", "Elevation profile" and "Reviews" section titles.

TrailMate’s SettingsScreen "only show favorites" row combines all three on a custom toggle row built from a Row and a Switch, so TalkBack announces one coherent control instead of three unrelated pieces:

// feature/settings/src/main/kotlin/com/example/trailmate/feature/settings/SettingsScreen.kt
@Composable
fun OnlyFavoritesRow(checked: Boolean, onCheckedChange: (Boolean) -> Unit) {
    Row(
        modifier = Modifier
            .fillMaxWidth()
            .toggleable(
                value = checked,
                role = Role.Switch,
                onValueChange = onCheckedChange,
            )
            .semantics(mergeDescendants = true) {
                stateDescription = if (checked) "On" else "Off"
            },
        verticalAlignment = Alignment.CenterVertically,
    ) {
        Text(
            text = stringResource(R.string.only_show_favorites),
            modifier = Modifier.weight(1f),
        )
        Switch(checked = checked, onCheckedChange = null) // click handled by the Row's toggleable
    }
}

Switch(onCheckedChange = null) is deliberate: toggleable on the Row already owns the click and the Role.Switch semantics for the whole merged row, so the inner Switch must not attach a second, independent click target and role or TalkBack would describe two overlapping controls. A section heading is a single line:

Text(
    text = stringResource(R.string.section_elevation_profile),
    style = MaterialTheme.typography.titleMedium,
    modifier = Modifier.semantics { heading() },
)

customActions

TalkBack’s swipe gestures reach only one "click" action per node by default. When a merged element genuinely supports more than one action — TrailMate’s trail row supports both "open" (the row’s own click) and "toggle favorite" — customActions exposes the second action through TalkBack’s local context menu (opened with a three-finger swipe up/down, or the corresponding Switch Access menu) instead of forcing the row to stay unmerged just so both targets remain independently reachable:

// feature/trails/src/main/kotlin/com/example/trailmate/feature/trails/TrailListItem.kt
Row(
    modifier = Modifier
        .fillMaxWidth()
        .clickable(onClick = { onOpen(trail.id) })
        .semantics(mergeDescendants = true) {
            customActions = listOf(
                CustomAccessibilityAction(
                    label = if (trail.isFavorite) "Remove from favorites" else "Add to favorites",
                    action = { onToggleFavorite(trail.id); true },
                ),
            )
        },
) { /* thumbnail, name, difficulty */ }

This is the Compose-side answer to the same problem AccessibilityDelegateCompat’s `addAction/performAccessibilityAction solves in the View system (see below): give TalkBack a way to reach a secondary action without a second, separately focusable widget cluttering the swipe order.

Traversal Order

TalkBack and Switch Access normally traverse the semantics tree in the same order composables are laid out (for a Row, left-to-right; for a Column, top-to-bottom), which is usually already correct. When a layout’s visual order legitimately differs from its logical reading order — for instance an overlaid FAB that is placed last in a Box for z-order but should be announced before the content beneath it — Modifier.semantics { traversalIndex = …​ } (lower values are visited first, defaulting to 0f, and negative values move a node earlier) fixes the announcement order without changing the visual layout:

Box {
    TrailContent(modifier = Modifier.semantics { traversalIndex = 1f })
    FloatingActionButton(
        onClick = onRecordHike,
        modifier = Modifier.semantics { traversalIndex = 0f },
    ) { Icon(Icons.Filled.PlayArrow, contentDescription = stringResource(R.string.cd_record_hike)) }
}

Reordering traversal should stay rare and deliberate: reordering the reading order of an otherwise normal, top-to-bottom list of controls to "fix" an announcement usually means the visual layout should change instead, since a sighted user reading the same screen top-to-bottom would then perceive the opposite structure to what a screen-reader user hears.

Touch Target Size: 48dp

Android’s accessibility guidance sets a minimum interactive touch target of 48x48dp — roughly a 9mm fingertip — below which users with limited fine motor control, or simply anyone on a moving bus, will mis-tap adjacent controls. This applies regardless of toolkit: a View-system ImageButton should never be sized smaller than 48dp x 48dp even if its icon is visually 24dp (pad the extra 24dp as invisible touch area rather than shrinking the tap target), and the IconButton/clickable composables shown above already default to a 48dp minimum size, but a raw Modifier.clickable on a small custom shape does not and should get an explicit Modifier.sizeIn(minWidth = 48.dp, minHeight = 48.dp) (or Modifier.minimumInteractiveComponentSize() from androidx.compose.material3 where that shim is available) so the touch target stays at least 48dp even when the drawn content is smaller. Two visually adjacent 48dp targets should also keep at least 8dp of visual breathing room between them so a slightly imprecise tap does not land on the neighbor.

Color Contrast

Text and meaningful icons need enough contrast against their background to be legible for users with low vision or color-vision deficiencies — the same WCAG contrast ratios covered on Web Accessibility apply just as much to a native Android screen as to a web page: 4.5:1 for normal text, 3:1 for large text (18sp+/14sp+bold) and for meaningful graphical elements. Material 3’s generated color schemes (ColorScheme.onSurface over surface, onPrimary over primary, and so on, covered on Material 3 and Theming in Compose) are designed to satisfy these ratios by construction as long as a foreground/background pair is used together as the scheme intends — the contrast problem usually appears only when a custom color is introduced outside that system, for example TrailMate’s difficulty badges, which use fixed brand colors (green/amber/red) rather than theme roles and therefore need their text color chosen and checked by hand rather than inherited for free. Never convey the difficulty level by color alone: TrailMate’s badges also carry the text label ("Easy"/"Moderate"/ "Hard"), so a user who cannot distinguish the colors still gets the information.

sp Units and Non-Linear Font Scaling

Text sizes must be specified in sp (scale-independent pixels), never dp or a raw pixel value — sp is the unit that scales with the user’s chosen system font size (Settings > Display > Font size), while dp does not. Any text sized in dp stays a fixed physical size no matter how large a user sets their font preference, defeating one of the most-used accessibility settings on the platform. Compose’s own Text composable already expects TextUnit`s in `sp (fontSize = 16.sp) and MaterialTheme.typography styles are defined in sp throughout, so following the Material 3 type scale rather than hardcoding sizes gets this right automatically in most TrailMate screens.

Since Android 14, the font-size setting is non-linear: scaling no longer multiplies every size by the same factor (which used to make already-large text grow disproportionately huge at the top of the scale); larger text sizes are scaled less aggressively than smaller ones, in sp-to-pixel curves the platform computes internally rather than a single linear fontScale multiplier. The practical implication is the same on both toolkits: never assume a fixed pixel size for text and never clip or truncate a text container at a size that only works at 100% scale — a Text/TextView should be allowed to wrap or a layout to scroll, not truncate with an ellipsis, when the user has scaled fonts up. Test scaled layouts directly in the largest font-size setting (and, separately, the largest display size setting) rather than trusting a single default-size screenshot.

Live Regions and Focus

Live Regions

A live region is a part of the screen whose content changes without any user action, that should still be announced — TrailMate’s sync-status banner ("Syncing 3 trails…​", then "Sync complete") is a live region: no one taps anything to make that text change, but a TalkBack user still needs to hear it. In the View system, ViewCompat.setAccessibilityLiveRegion(view, ViewCompat.ACCESSIBILITY_LIVE_REGION_POLITE) marks a TextView so TalkBack announces new text as soon as it settles, without stealing focus from whatever the user was already doing (ACCESSIBILITY_LIVE_REGION_ASSERTIVE interrupts immediately, and should be reserved for genuinely urgent changes). In Compose the equivalent is Modifier.semantics { liveRegion = LiveRegionMode.Polite }:

// feature/trails/src/main/kotlin/com/example/trailmate/feature/trails/TrailListScreen.kt
Text(
    text = syncStatusText,
    modifier = Modifier.semantics { liveRegion = LiveRegionMode.Polite },
)

Live regions should stay reserved for information the user did not directly cause and genuinely needs to know about; overusing Assertive regions (or firing a Polite one on every minor state change) creates the same "crying wolf" problem TalkBack users report with poorly built websites — constant interruptions eventually get tuned out or the service gets disabled for that app.

Focus

Keyboard and Switch Access users, and TalkBack after certain actions, rely on a well-defined focus order and on focus actually moving somewhere sensible after an action completes. Two patterns matter most in TrailMate: after submitting the "add trail note" dialog and dismissing it, focus should return to the control that opened it (View.requestFocus(), or in Compose a FocusRequester captured before the dialog opens and invoked in LaunchedEffect after it closes) rather than being left on a now-destroyed node, which otherwise strands TalkBack at an arbitrary or default position; and a newly appeared error message under a form field should be associated with that field (View: android:labelFor/android:accessibilityTraversalAfter; Compose: ordering plus liveRegion) so it is discoverable without hunting.

AccessibilityDelegateCompat for Custom Views

The View system’s semantics do not populate themselves for a fully custom View — nothing tells TalkBack a hand-drawn control’s role, label or available actions unless the view is told to report them, typically through an AccessibilityDelegateCompat attached with ViewCompat.setAccessibilityDelegate. TrailMate’s ElevationProfileView (a drag-to-inspect elevation chart, drawn entirely on Canvas) is exactly this case, and Custom Views, Touch, Drawing and View Animation walks through building that delegate step by step — overriding onInitializeAccessibilityNodeInfo to report a SeekBar-like className and a live summary contentDescription so TalkBack announces the chart as an adjustable control rather than staying silent about it. That page’s delegate is the View-system’s closest analogue to Compose’s customActions/role/stateDescription combination covered above: both are answering the same question — "since nothing draws this automatically, what should assistive technology be told?" — for whichever toolkit built the control. See Android Developers — Make apps more accessible.

Testing Accessibility

Accessibility Scanner

Accessibility Scanner (a Google app installed from Google Play onto a device or emulator, not a build-time tool) overlays the running app and flags common issues on screen — missing or duplicate labels, insufficient touch target size, and low contrast text — directly against the rendered UI, catching real issues that unit tests never see because it is looking at pixels and the live accessibility tree together. It works identically against a Views screen and a Compose screen, since both ultimately populate the same platform-level accessibility tree by the time it reaches the scanner.

Espresso AccessibilityChecks

For the View system, androidx.test.espresso.accessibility.AccessibilityChecks wires the same category of checks into an existing Espresso instrumented test suite (covered in full on Instrumented and UI Testing), so every screen an Espresso test already visits gets an automatic accessibility pass with no additional test code:

// feature/trails/src/androidTest/kotlin/com/example/trailmate/feature/trails/TrailListFragmentTest.kt
@Before
fun enableAccessibilityChecks() {
    AccessibilityChecks.enable().setRunChecksFromRootView(true)
}

Once enabled, any Espresso .perform()/.check() interaction against a view in that test run also validates it against the registered accessibility checks (touch target size, contrast, speakable text, and others), and fails the test with a description of the violation if one is found — turning an accessibility regression into a normal CI test failure rather than something only discovered manually.

Compose Semantics and Accessibility Checks

Compose’s own testing APIs (createComposeRule, covered on Instrumented and UI Testing) can both inspect the semantics tree directly and run automated accessibility assertions against it. SemanticsNodeInteraction.printToLog() dumps the merged semantics tree for a node during test development, which is the fastest way to see exactly what TalkBack would see:

// feature/trails/src/androidTest/kotlin/com/example/trailmate/feature/trails/TrailListScreenTest.kt
@get:Rule
val composeRule = createAndroidComposeRule<MainActivity>()

@Test
fun favoriteToggle_hasAccessibleLabel() {
    composeRule.onRoot().printToLog("SemanticsTree")
    composeRule.onNodeWithContentDescription("Add to favorites").assertExists()
}

Compose also ships accessibility-focused test assertions equivalent in spirit to Espresso’s AccessibilityChecks — for example asserting on SemanticsProperties.Role, StateDescription and touch target size through the semantics matchers already used above (onNodeWithContentDescription, assertHasClickAction, and size assertions on the node’s bounds) — so a TrailListScreenTest can verify both the toggle’s label and that its clickable bounds are at least 48dp without leaving the Compose testing APIs. Because the semantics tree is the same source of truth TalkBack and Switch Access consume, these tests exercise the real integration point rather than a separate accessibility-only mock.