Files
mantra-kmp/composeApp
Kgothatso Ngako 410ece2df9 feat: fade between a screen's states instead of cutting between them
Phase 7, the second half. Every screen in this app is a `when` over a UI state --
loading, error, empty, loaded -- and every one of those changes was an
unannounced cut: the spinner is there in one frame and the content is there in
the next, with nothing saying they are the same screen answering the same
question.

`ScreenStateTransition` is M3's fade-through, which is the transition for content
that replaces other content without being spatially related to it: the outgoing
state fades out, the incoming one fades in and grows the last 8% into place.
`SizeTransform(clip = false)`, so a tall loaded state does not stretch a short
spinner on its way in. Specs from the theme's `MotionScheme`, effects for the
fade and spatial for the scale.

**The content key is the state's class, not the state.** This is the half that is
easy to get wrong and impossible to see: keyed on the value, a screen re-runs the
whole fade every time its loaded data changes -- a message arriving, a list
growing by one -- so the screen flickers whenever anything happens, and every
screenshot of it looks perfect. Keyed on the class, the animation runs when the
state does and the data flows through untouched. There is a test for exactly
that, and it is the more useful of the two.

**Applied to 20 screens, and not to 15 others.** `AnimatedContent` is a layout
node, so it can only wrap a `when` that is a composable's whole body. Where the
`when` sits inside a `Column` whose branches use `Modifier.weight` -- the sign-in
and create-profile flows, the frost signing and proposal screens, the two feed
detail widgets, the four render helpers still on view models -- wrapping it would
take those branches out of `ColumnScope`. The rule is mechanical, the reason is
recorded once in `ScreenState.kt` rather than at each site, and the screens it
excludes are named here rather than silently skipped.

Reduced motion keeps the crossfade and drops the scale, which is the same
position the navigation transitions take: what WCAG 2.3.3 and M3 ask to remove is
movement, not the signal that something changed.

**Most of this diff is indentation** -- 3,699 lines of it against 157 lines of
substance, which is 21 screens gaining a wrapper and one helper being written.
`git diff -w` shows the second number.

**Verified by holding the clock still and looking at one frame**, which is the
only frame that can tell a crossfade from a cut: during a transition both states
are composed, and during a cut only ever one is.

639 jvm tests green; android and desktop compile. The audit's motion count goes
11 -> 13.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 08:16:05 +02:00
..