Skip to content

ADR-0071: Coordinated content motion

  • Status: Accepted
  • Date: 2026-10-17
  • Amended: 2026-10-20 (Warm ledger — Motion is 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 Long minor 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.