ADR-0071: Coordinated content motion
- Status: Accepted
- Date: 2026-10-17
- Amended: 2026-10-20 (Warm ledger —
Motionis the single source for tab/detail/expand/search/control/toast; ADR-0079)
Context
Content changed at three different scopes with no shared rule. A single ledger
row appeared, moved or left on its own (#199). Onboarding steps swapped with no
directional cue (#200). A month change replaced the period label and the
month-scoped result blocks on Home, Activity, Budgets and Reports independently,
and Home swapped its monetary Text values directly while
Modifier.animateContentSize only followed the new bounds — nothing tied the
label to the result it described (#201). Lists that relied on
Modifier.animateItem replayed a whole month as fresh item insertions, which the
two destinations suppressed with a per-screen helper.
Decision
Content motion communicates the scope of the change and stops when settled.
ADR-0042 owns navigation motion; this ADR owns content motion and keeps the
same bounded, animator-scale-aware posture.
- A single item — appearance, disappearance and placement animate with the
keyed lazy item (
Modifier.animateItem): Activity day headers and rows, Budgets category cards, and Review-queue cards. A group header leaves with its last child (sticky headers do not use item animation, which would duplicate the header while its last card disappears). - A batch within a period — the same keyed item motion handles it (an edit reorders cards, a swipe removes a row).
- A change of visible period — the period label and the month-scoped result
blocks crossfade together (Activity's ledger, Budgets' list, Home's hero,
Reports' snapshot) over a short shared duration. A month request is not itself
a new displayed result: the new period only appears once the ViewModel emits
it with its rows and formatted totals, so a new label is never paired with the
previous month's money while data loads. Each crossfade layer renders the
exact snapshot (its own formatted
Longminor units); money is never interpolated, and a changed slice set clears any chart selection so no stale tooltip or drill-down survives. - Onboarding steps — Next fades and slides the step toward logical start and Back reverses it (ADR-0042/ADR-0060).
The shared transition lives once in ui/components/MonthSelector.kt
(MonthScopedCrossfade, MONTH_TRANSITION_MILLIS); a destination composes it
around its month-scoped blocks instead of suppressing or hand-timing motion.
Because the period crossfade composes a fresh list, a whole-month swap no longer
replays per-row appearance, so the per-screen suppression helper is gone.
Consequences
A period change reads as one coordinated step everywhere, while an in-month
edit keeps the finer per-item motion. Empty states crossfade like any other
period result. Compose animations honour the system animator scale, so scale 0
settles immediately and the crossfade never fabricates a partial value. The cost
is one extra composition of the month-scoped blocks during the transition.
Warm ledger amendment (2026-10-20)
ui/theme/Motion.kt is the single source for every Warm transition: tab
180 ms, detail 240 ms with a 32 dp logical-start offset, expand 200 ms, search
180 ms with an 8 dp offset, control 180 ms, toast 180 ms with an 8 dp rise and a
1,700 ms dwell, plus the one easing curve CubicBezierEasing(0.2, 0, 0, 1).
A screen passes these constants to tween; it does not declare its own
duration. Onboarding step content reuses the expand duration (200 ms) so its
progress bar and content settle together. Money is never interpolated; only
whole formatted snapshots crossfade (ADR-0079).
Verification
TransactionsContentTest drives a month change and asserts the settled label
matches its settled rows, and the filtered-empty recovery renders and resets.
ActivityListMotionTest / BudgetListMotionTest keep single-item appearance,
disappearance and reorder; BudgetListMotionTest asserts a month swap replaces
rows without keeping the old ones. ReportsSnapshotCardTest asserts a data
change drops the slice drill-down. WarmLedgerContractTest pins every Motion
constant (tab/detail/expand/search/control/toast, both offsets and the dwell);
OnboardingBackContractTest covers the 200 ms step transition. Device review
covers rapid previous/next, empty/populated months, scale 0 and large text.