Platform: Android
Android is a compositor with opinions, a gesture system that claims the screen edges before your code runs, and a colour palette that may be generated from the user's wallpaper after your app is signed. Every rule here exists because some part of the runtime moves underneath you: design for the surfaces the platform owns and it renders your work faithfully; assume fixed geometry and it will draw your header under the clock.
1. Insets, not constants
From API 35 an app targeting the current SDK is drawn edge-to-edge whether it asked or not. The
failure is silent and visual: the top app bar under the clock, the last row under the gesture
pill. Opt in explicitly with enableEdgeToEdge() in onCreate() before setContent, so
behaviour matches on API 29 and API 36, then consume insets at the composable that renders
against the edge, never at the root — a root-level systemBarsPadding() stops content
scrolling under a translucent bar, which was the point. A list takes
contentPadding = WindowInsets.systemBars.asPaddingValues(), not a padding modifier.
The rest are navigationBarsPadding(), displayCutoutPadding(), safeDrawingPadding() as
the union, and imePadding() for the keyboard, which animates in step rather than snapping. A
hardcoded top or bottom constant is a bug: status bar height runs from 24dp to 48dp with a large
cutout, and the navigation bar is 48dp with three buttons against 24dp with gestures.
2. Targets and the gesture edges
48×48dp minimum with 8dp between adjacent targets. The number is anthropometric: a finger pad
contacts roughly 10mm of glass and 48dp is about 9mm at any density, so below it the resolved
touch point becomes a coin flip. minimumInteractiveComponentSize() expands the touch area
without changing drawn bounds, which is why an IconButton measures 48dp around a 24dp icon;
enlarging the glyph trades an ergonomics defect for a hierarchy defect. Gesture navigation claims
20–24dp inward from both vertical edges before your composable sees the event, so an
edge-anchored carousel is inoperable. Claim it back with setSystemGestureExclusionRects,
under the 200dp-per-edge cap — exceeding it silently drops the earliest rects.
3. Material 3 tokens
Shape is a scale — 0, 4, 8, 12, 16, 28dp and full — and radius encodes size class, because
perceived roundness is radius relative to the shorter side. Elevation is tonal, not shadowed:
reach for surfaceContainerLow through surfaceContainerHighest rather than
Modifier.shadow(6.dp), since a black shadow on a near-black surface carries no information
in dark theme and a hand-drawn one will not tint under dynamic colour.
Hardcoded hex on a surface or accent role is forbidden. From API 31 the system derives the tonal
palette from the wallpaper, so a literal background stays fixed while onSurface shifts and
the contrast ratio signed off in design review is gone. Brand-critical colour stays as a named
token outside the scheme, checked against both neutral sets.
Text sizes are in sp; dp text ignores the user's font-size preference and is the most
common typographic defect in Android UI. Take styles from MaterialTheme.typography rather
than constructing TextStyle inline, which discards the token's line height and its tracking.
4. Motion tokens
Short 50/100/150/200ms, medium 250–400ms, long 450–600ms, extra-long 700–1000ms, chosen by
distance travelled and area changed. Emphasized decelerate cubic-bezier(0.05, 0.7, 0.1, 1.0)
enters, emphasized accelerate cubic-bezier(0.3, 0.0, 0.8, 0.15) exits, standard
cubic-bezier(0.2, 0.0, 0.0, 1.0) moves within the screen. Exits run shorter than enters: an
entering element must be read, a departing one is finished business and holding it delays the
next thing.
Compose ships no LocalReducedMotion; derive one from
Settings.Global.ANIMATOR_DURATION_SCALE == 0f, and reduce by removing translation and scale
while keeping a 100–150ms cross-fade — zeroing durations turns state changes into teleports.
5. Compose state
One immutable UiState per screen as a single StateFlow, because two flows cannot emit
atomically and some frame will render a spinner over stale content. Create it with
stateIn(viewModelScope, WhileSubscribed(5_000), Loading): without the grace window a rotation
re-issues the network call, and with Eagerly the flow runs while the screen is invisible.
Model variants as a sealed interface, and put derived values in get() properties so
copy() cannot produce a subtotal contradicting its line items.
One-shot effects — navigation, a snackbar — are not state; route them through a buffered
Channel collected under repeatOnLifecycle(STARTED), since a replay = 0
SharedFlow drops emissions while backgrounded. The decision rule is one question: does it
need to survive rotation? Split each screen into a stateless Screen(uiState, onAction) and a
Route owning the ViewModel, so tests can reach every state.
6. Compose rendering
Composition, layout, draw. Reading a State binds the reading phase to that state, so
Modifier.offset(x = scroll.dp) recomposes on every scroll frame while
Modifier.offset { IntOffset(…) } re-lays out only; the same holds for graphicsLayer { }
and drawBehind { }. Wrap a rarely-changing derivation in derivedStateOf. With strong
skipping on by default, the remaining culprit is what a lambda closes over.
Lazy items need a stable key and a contentType: without the key, removing item 3 marks
4..n as changed and destroys their state, and without contentType slots cannot be reused.
indexOf() in a key lambda makes layout O(n²), and a SubcomposeLayout — including
BoxWithConstraints — inside a lazy item stops prefetching working.
7. Navigation, adaptivity, semantics
Back is a platform guarantee. Set android:enableOnBackInvokedCallback="true" and consume
predictive back progress, not only its commit, or the gesture cannot be reversed mid-swipe. Up
is not Back: Up moves toward the app root, Back through history, and wiring the toolbar arrow to
onBackPressed produces a dead end on every deep link. Prefer Snackbar to Toast for
anything actionable — a Toast has no action slot and no place in the TalkBack focus order.
Branch layout on WindowSizeClass — compact under 600dp, medium 600–840dp, expanded above —
computed from the window, never a device name.
TalkBack reads the semantics tree, not the composition tree. Meaningful images take a functional
contentDescription and decorative ones an explicit null;
semantics(mergeDescendants = true) collapses a row into one focus stop; section titles take
heading(). traversalIndex is a silent no-op without an ancestor marked
isTraversalGroup = true. Card numbers and balances take sensitiveData = true at node
level — FLAG_SECURE blanks the screenshot and leaves accessibility text readable.
8. Feel and build shape
Haptics belong on state commits: fire on the event that caused the change, land on the same
frame as the visual, and reserve them for commit, snap, success and error. Read touch and fling
constants from ViewConfiguration, and do not port iOS rubber-banding — Android 12+
stretches at the boundary through EdgeEffect, and unfamiliarity reads as breakage. A sheet
tracks the finger 1:1 and commits on projected velocity, not displacement.
On the build, shared Gradle logic goes in convention plugins inside a build-logic composite
build whose settings file re-declares the version catalog, because a composite does not inherit
it. Pin versions exactly, enable the configuration and build caches and
nonTransitiveRClass, keep I/O out of configuration time, prefer KSP to kapt, and ship an
AAB with resConfigs and abiFilters constrained.
Rules
MUST NOT — Do not clear a system bar, cutout, or keyboard with a dp literal; derive every top and bottom clearance from a WindowInsets source.
Why: Status bar height ranges from 24dp on an older handset to 48dp with a large cutout, and the navigation bar is 48dp with three-button navigation against 24dp with gestures, so no single constant is correct on more than one device in one orientation. The keyboard is worse: it animates to its final height, so a fixed offset is wrong for the whole duration of the transition even when the end value happens to match.
Incorrect:
Modifier.padding(top = 24.dp, bottom = 48.dp)
Correct:
Modifier.safeDrawingPadding().imePadding()
MUST NOT — Do not write hex colour literals for surface, accent, or text roles; read them from MaterialTheme.colorScheme with dynamic schemes on API 31 and above.
Why: From API 31 the system generates the tonal palette from the user’s wallpaper, so half the scheme is decided after the app is signed. A literal background stays fixed while onSurface moves to suit that palette, which means the contrast ratio verified in design review is not the ratio the user sees, and no static check on the source can detect it.
Source: Material 3 dynamic colour
Exceptions:
- Brand-critical colour whose hue carries the meaning — a logo lockup or a category chip — kept as a named token outside the scheme and contrast-checked against both neutral sets.
Incorrect:
Surface(color = Color(0xFF1B1B1F)) { Text("Total", color = Color.White) }
Correct:
Surface(color = MaterialTheme.colorScheme.surfaceContainerHigh) { Text("Total") }
MUST — Call enableEdgeToEdge() in onCreate() before setContent, and consume insets at the composable that renders against the edge rather than at the root.
Why: From API 35 the system draws the window edge-to-edge for apps targeting the current SDK regardless of whether they opted in, so inheriting the target-SDK default means behaviour changes silently when the target is bumped. Consuming insets at the root then reintroduces the original problem in reverse: content stops scrolling under the translucent bars, which is the entire reason for drawing edge-to-edge.
Source: Android 15 behaviour changes, edge-to-edge enforcement
Incorrect:
Scaffold(modifier = Modifier.systemBarsPadding()) { LazyColumn { … } }
Correct:
LazyColumn(contentPadding = WindowInsets.systemBars.asPaddingValues()) { … }
MUST — Give every interactive element a hit area of at least 48×48dp with 8dp of separation, expanding the touch area rather than the drawn glyph.
Why: An adult finger pad contacts roughly 10mm of glass and 48dp is about 9mm at any density, so below that the contact patch overlaps adjacent targets and which one receives the touch becomes a coin flip. Solving it by enlarging the glyph instead trades an ergonomics defect for a hierarchy defect, since icon size is a signal of importance.
Source: Material Design 3 accessibility guidance
Incorrect:
IconButton(onClick = ::close, modifier = Modifier.size(32.dp)) { Icon(…) }
Correct:
IconButton(onClick = ::close, modifier = Modifier.minimumInteractiveComponentSize()) { Icon(…) }
MUST — Register a system gesture exclusion rect for any horizontally-draggable component within roughly 24dp of a vertical screen edge, staying under the 200dp-per-edge cap.
Why: The back-gesture strips consume 20-24dp inward from each vertical edge and intercept horizontal drags before the composable sees the event, so an edge-anchored carousel or swipe row is simply inoperable and looks like a bug in your code. The 200dp cap matters because exceeding it does not throw — the platform silently drops the earliest rects, so the first exclusion zone on a screen stops working with no diagnostic.
MUST — Express every text size in sp and take styles from MaterialTheme.typography rather than constructing TextStyle inline.
Why: dp text is immune to the user’s font-size preference, so an accessibility setting the user relies on has no effect on your app and the failure is invisible to anyone testing at default scale. Inline TextStyle construction additionally drops the token’s line height and tracking, and the tracking is not uniform across the scale — it is negative at display sizes and positive at label sizes for optical reasons.
Incorrect:
Text("Total", style = TextStyle(fontSize = 14.dp.value.sp, letterSpacing = 0.5.sp))
Correct:
Text("Total", style = MaterialTheme.typography.bodyMedium)
MUST — Animate entering elements with emphasized decelerate and exiting elements with emphasized accelerate, giving the exit a shorter duration than its matching enter.
Why: An entering element carries content the user has to read, so it arrives quickly and settles slowly to give the eye time to land. A departing element is finished business, and every millisecond it eases out is a millisecond before the next thing can be attended to. A symmetric pair with one curve in both directions makes dismissal feel sticky, and users report stickiness as slowness.
Source: Material 3 motion easing and duration tokens
Incorrect:
enter = fadeIn(tween(300)), exit = fadeOut(tween(300))
Correct:
enter = fadeIn(tween(300, easing = EmphasizedDecelerateEasing)),
exit = fadeOut(tween(150, easing = EmphasizedAccelerateEasing))
MUST — Expose exactly one immutable UiState per screen as a StateFlow created with stateIn(scope, WhileSubscribed(5_000), initial).
Why: Two flows cannot emit atomically, so any screen driven by several flows will eventually render a frame showing an impossible combination such as a spinner over stale content. The five-second grace window is separately load-bearing: without it the upstream is cancelled and restarted on every configuration change, so a rotation re-issues a network call, and with Eagerly the flow keeps running while the screen is invisible.
Incorrect:
val items = repo.items.stateIn(…)
val isLoading = MutableStateFlow(true)
val error = MutableStateFlow<String?>(null)
Correct:
val uiState: StateFlow<CheckoutUiState> = repo.cart.map(::toUiState)
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), Loading)
MUST — Give every lazy list item a stable key and a contentType, and never compute a key with indexOf().
Why: Without a key, removing item 3 marks items 4..n as changed, so their remembered state and scroll positions are destroyed. Without contentType, a header’s composition slot cannot be reused for another header, so a heterogeneous list allocates fresh subtrees while scrolling. indexOf() is a linear scan run per visible item, which makes layout O(n²) — a 500-row list performs about 250,000 comparisons per frame.
Incorrect:
items(orders, key = { orders.indexOf(it) }) { OrderRow(it) }
Correct:
items(orders, key = { it.id }, contentType = { it.kind }) { OrderRow(it) }
SHOULD — Read scroll-, drag-, and animation-driven values inside offset { }, graphicsLayer { }, or drawBehind { } lambdas rather than at composition time.
Why: Compose binds a State read to the phase in which it happens, so a read during composition invalidates the whole subtree every frame the value changes, while the same read deferred into a layout or draw lambda re-runs only that phase. On a scroll-driven value that is the difference between recomposing a list on every frame and merely re-placing it.
Incorrect:
Modifier.offset(x = scrollOffset.dp)
Correct:
Modifier.offset { IntOffset(scrollOffset.roundToInt(), 0) }
Before reporting completion
Run these checks against your own output. Answer each question explicitly rather than assuming the answer, because the point of the exercise is to notice what you did not notice while building.
Confirm the screen is laid out against runtime insets with reachable targets. (blocking)
- Does the Activity call enableEdgeToEdge() before setContent, and is every top and bottom clearance derived from a WindowInsets source rather than a dp literal?
- Do text fields clear the keyboard through imePadding() or an ime inset read rather than a fixed offset?
- Does any horizontally-draggable component sit within 24dp of a vertical edge without a gesture exclusion rect, and does the screen stay under 200dp of exclusion per edge?
- Is every interactive element at least 48×48dp in hit area with 8dp separation, achieved by expanding the touch area rather than the glyph?
Confirm motion tokens, screen state, and Compose phase discipline. (blocking)
- Is every exit duration shorter than its matching enter, with enters on emphasized decelerate and exits on emphasized accelerate?
- Is there a reduced-motion source derived from ANIMATOR_DURATION_SCALE that removes translation and scale while keeping a 100-150ms cross-fade?
- Does the screen expose exactly one StateFlow with WhileSubscribed(5_000), with derived values as get() properties rather than constructor parameters?
- Are one-shot effects delivered over a buffered Channel collected under repeatOnLifecycle(STARTED) rather than held as state?
- Does every lazy item supply a stable key and a contentType, with no indexOf() in a key lambda and no SubcomposeLayout or BoxWithConstraints inside an item?
Confirm TalkBack, Back, and adaptive behaviour before reporting the screen done.
- Is enableOnBackInvokedCallback set, and does the handler consume predictive back progress rather than only its commit?
- Is the toolbar Up action distinct from system Back on every deep-linkable screen?
- Does every image carry a functional contentDescription or an explicit null, do composite rows merge descendants, and do section titles declare heading()?
- Does every traversalIndex have an ancestor marked isTraversalGroup = true?
- Is every layout branch keyed on WindowSizeClass rather than a device name, and does the screen still work when the window is resized to compact mid-session?
Scan for hardcoded values and banned patterns. (blocking)
bash scripts/audit_design.sh . --platform android
Further reference
These are not loaded by default. Read one only when its question is the question you currently have.
references/insets-targets-and-material-tokens.md— How do I lay a screen out against the system bars, size touch targets, and pick shape, elevation, colour, type and motion values that survive dynamic colour, font scaling, and a foldable changing size mid-session?references/compose-state-and-rendering.md— How should I structure a Compose screen’s state, effects, and previews, and what actually causes recomposition, scroll jank, and quadratic layout cost in a lazy list?references/platform-conventions-and-build.md— What are the Android-specific conventions I must not port from another platform — Back, TalkBack semantics, haptic constants, fling physics, bottom sheets, adaptive icons, ripples — and how should the Gradle build be structured?