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 |
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:
-
roletells 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 likeButtonandCheckboxalready set the right role;rolematters when a custom composable behaves like one of those controls without using the underlying component directly (e.g. aRowbuilt to act like a checkbox row). -
stateDescriptionannounces 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. -
headingmarks 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.
References
-
Android Developers — Principles for improving app accessibility
-
Android Developers — Make your Android app accessible (pathway)
-
Web Accessibility — WCAG, the POUR principles, conformance levels and contrast ratios that this page’s Android-specific guidance implements