feat: give navigation its transitions from the motion scheme, and honour reduced motion

Phase 7, the first half. All 43 routes took navigation-compose's default, which
turns out not to be the hard cut the plan expected: on android and desktop it is
`fadeIn(tween(700))` / `fadeOut(tween(700))`, written into the library's own
internals. Both halves of that are worth changing. 700ms is roughly three times
M3's duration for a full-screen change, and a literal inside a dependency is not
a decision this app made -- phase 1 wired a `MotionScheme` into the theme
precisely so that there would be one place to make it.

**The plan named an API that an app cannot reach.** It says every spec should
come from `MotionSchemeKeyTokens`; that enum is `internal` to material3, so the
tokens are not addressable by name from outside. `MaterialTheme.motionScheme` is
the public surface and offers the same six specs. Two private helpers name which
of them this app uses for what -- `defaultSpatialSpec` for the slide,
`defaultEffectsSpec` for the fade -- which is the distinction the scheme draws:
spatial motion is springy because it moves something, effects motion is not
because a fading colour that overshoots looks like a fault.

**The shape is M3's shared axis.** The arriving screen slides in from the
trailing edge while the leaving one slides out toward the leading edge, both
fading; going back mirrors it, so the direction of travel is legible rather than
a dissolve that looks the same either way. `slideIntoContainer` is
layout-direction aware, so an RTL locale gets the mirror for free.

**Reduced motion, on the three platforms, in the shape phase 1 established.**
`platformReducedMotion()` is an expect/actual beside `platformThemeContrast()`,
observed rather than read once, because somebody who turns it on because motion
makes them ill should not have to restart the app.

Android has no "reduce motion" switch -- it has **Remove animations**, which sets
the animation duration scales to zero. The platform applies that scale to
`ValueAnimator` and **not to Compose**, which runs on its own clock and ignores
it entirely, so an app that draws its own transitions has to read the setting
itself. A `ContentObserver` on `ANIMATOR_DURATION_SCALE` catches the change
without a restart.

iOS is the one platform where it is a single documented call,
`UIAccessibilityIsReduceMotionEnabled`, with the same notification shape as the
darker-system-colours one already observed there.

Desktop answers `false`, and says at the site why that is honest rather than a
stub: Windows, macos and the freedesktop desktops each have the setting and none
of the three reaches AWT. That is the same wall `platformThemeContrast` hits on
linux and macos, and the same eventual answer -- a preference with the platform
as its default.

Reduced motion does not mean *no* transition. The screen still fades; what goes
is the movement, which is what M3 and WCAG 2.3.3 are both about.

**Two tests, and the first one found a design flaw in the second.** The claim
"this transition slides and that one does not" cannot be asserted on the values --
`EnterTransition` has no public shape to inspect -- so it is measured: hold the
clock, navigate, advance a third of the way, and read where the arriving screen
is. Sliding, it is 54dp from home; reduced, it is already there.

The reduced case read 54dp at first, because the test provided
`LocalReducedMotion` *around* `TorchTheme` and the theme overwrote it. The fix is
not in the test: `reducedMotion` is now a `TorchTheme` parameter defaulted to the
platform, exactly as `contrast` is, because a value nothing can override is a
value nothing can test -- and because the desktop actual is a hardcoded `false`
that a settings screen will eventually need to override anyway.

637 jvm tests green; android and desktop compile. The audit's motion count goes
2 -> 11 and navigation transitions 0 -> 3.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kgothatso Ngako
2026-09-08 08:11:54 +02:00
parent 16775bf6c7
commit 61793e2779
7 changed files with 414 additions and 1 deletions

View File

@@ -102,6 +102,7 @@ import press.mantra.compose.ui.view.model.SynchronizationViewModel
import press.mantra.compose.ui.view.state.NavigationUIState
import press.mantra.compose.ui.view.state.NostrEventDetailUIState
import press.mantra.compose.ui.view.state.SearchUIState
import press.mantra.compose.ui.theme.NavigationMotion
import press.mantra.compose.ui.theme.breakpoint
import co.touchlab.kermit.Logger
import fr.acinq.phoenix.PhoenixGlobal
@@ -403,7 +404,17 @@ fun MantraNavHost(
) {
NavHost(
navController = navController,
startDestination = LoadingRoute()
startDestination = LoadingRoute(),
// All 43 routes at once, which is the point: navigation-compose's default is
// `fadeIn(tween(700))` in its own internals, so before this the app's
// transitions were a library's literal rather than a decision, and roughly
// three times M3's duration for a full-screen change. These come from the
// theme's MotionScheme and collapse to a fade when the platform asks for
// reduced motion.
enterTransition = NavigationMotion.enter(),
exitTransition = NavigationMotion.exit(),
popEnterTransition = NavigationMotion.popEnter(),
popExitTransition = NavigationMotion.popExit(),
) {
composable<SovereignWalletStartupRoute> { backStackEntry ->
val route = backStackEntry.toRoute<SovereignWalletStartupRoute>()

View File

@@ -0,0 +1,159 @@
package press.mantra.compose.ui.theme
import androidx.compose.animation.AnimatedContentTransitionScope
import androidx.compose.animation.EnterTransition
import androidx.compose.animation.ExitTransition
import androidx.compose.animation.core.FiniteAnimationSpec
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.unit.IntOffset
import androidx.navigation.NavBackStackEntry
/**
* Whether the platform has been asked to keep motion to a minimum.
*
* Provided by [TorchTheme] as [LocalReducedMotion]; the transitions below read it, so a
* call site does not have to remember to. Reading it directly is for a screen animating
* something the shared helpers do not cover.
*
* The same expect/actual shape as [platformThemeContrast], for the same reason: this is a
* per-platform accessibility setting, it can change while the app is running, and somebody
* who turns it on because motion makes them ill should not have to restart the app.
*/
@Composable
expect fun platformReducedMotion(): Boolean
/** Defaults to `false`, which is the app's behaviour anywhere the theme is not reached. */
val LocalReducedMotion = staticCompositionLocalOf { false }
/** `MaterialTheme.reducedMotion`, to match `MaterialTheme.breakpoint` and `MaterialTheme.spacing`. */
val MaterialTheme.reducedMotion: Boolean
@Composable
@ReadOnlyComposable
get() = LocalReducedMotion.current
/**
* The transitions between navigation destinations.
*
* **What this replaces.** All 43 routes took navigation-compose's default, which on
* android and desktop is `fadeIn(tween(700))` / `fadeOut(tween(700))`. That is not the
* hard cut the plan expected -- but 700 milliseconds is roughly three times M3's own
* duration for a full-screen transition, and a `tween` written into a library's internals
* is not a decision this app made. Both directions now come from the theme's
* [androidx.compose.material3.MotionScheme], which is where phase 1 put the one decision
* about how this app moves.
*
* **Shape.** M3's shared-axis transition for forward and backward navigation: the arriving
* screen slides in from the trailing edge while the leaving one slides out toward the
* leading edge, both fading. Going back mirrors it, so the direction of travel is legible
* rather than a dissolve that looks the same either way. `slideIntoContainer` is
* layout-direction aware, so an RTL locale gets the mirror image for free.
*
* Spatial specs for the slide and effects specs for the fade, which is the distinction the
* scheme draws: spatial motion is springy because it moves something, effects motion is not
* because a fading colour that overshoots looks like a fault.
*
* Typed for `NavBackStackEntry` rather than star-projected. These are only ever handed to
* a `NavHost`, and a star projection would make every call site cast.
*/
object NavigationMotion {
/** The distance a sliding screen travels, as a fraction of the container. */
private const val SlideFraction = 0.25f
@Composable
fun enter(): AnimatedContentTransitionScope<NavBackStackEntry>.() -> EnterTransition {
val reduced = MaterialTheme.reducedMotion
val spatial = MaterialTheme.motionSchemeSpatial()
val effects = MaterialTheme.motionSchemeEffects()
return {
if (reduced) {
fadeIn(effects)
} else {
slideIntoContainer(
towards = AnimatedContentTransitionScope.SlideDirection.Start,
animationSpec = spatial,
initialOffset = { (it * SlideFraction).toInt() },
) + fadeIn(effects)
}
}
}
@Composable
fun exit(): AnimatedContentTransitionScope<NavBackStackEntry>.() -> ExitTransition {
val reduced = MaterialTheme.reducedMotion
val spatial = MaterialTheme.motionSchemeSpatial()
val effects = MaterialTheme.motionSchemeEffects()
return {
if (reduced) {
fadeOut(effects)
} else {
slideOutOfContainer(
towards = AnimatedContentTransitionScope.SlideDirection.Start,
animationSpec = spatial,
targetOffset = { (it * SlideFraction).toInt() },
) + fadeOut(effects)
}
}
}
@Composable
fun popEnter(): AnimatedContentTransitionScope<NavBackStackEntry>.() -> EnterTransition {
val reduced = MaterialTheme.reducedMotion
val spatial = MaterialTheme.motionSchemeSpatial()
val effects = MaterialTheme.motionSchemeEffects()
return {
if (reduced) {
fadeIn(effects)
} else {
slideIntoContainer(
towards = AnimatedContentTransitionScope.SlideDirection.End,
animationSpec = spatial,
initialOffset = { (it * SlideFraction).toInt() },
) + fadeIn(effects)
}
}
}
@Composable
fun popExit(): AnimatedContentTransitionScope<NavBackStackEntry>.() -> ExitTransition {
val reduced = MaterialTheme.reducedMotion
val spatial = MaterialTheme.motionSchemeSpatial()
val effects = MaterialTheme.motionSchemeEffects()
return {
if (reduced) {
fadeOut(effects)
} else {
slideOutOfContainer(
towards = AnimatedContentTransitionScope.SlideDirection.End,
animationSpec = spatial,
targetOffset = { (it * SlideFraction).toInt() },
) + fadeOut(effects)
}
}
}
}
/**
* The scheme's default spatial spec, typed for a slide.
*
* `MotionSchemeKeyTokens` -- which the plan named -- is `internal` to material3, so an app
* cannot reach the tokens by name. `MaterialTheme.motionScheme` is the public surface and
* gives the same six specs; these two helpers exist only to name which of them this app
* uses for what, so that the choice is made once rather than at every call site.
*/
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
private fun MaterialTheme.motionSchemeSpatial(): FiniteAnimationSpec<IntOffset> =
motionScheme.defaultSpatialSpec()
/** The scheme's default effects spec, typed for a fade. */
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
private fun MaterialTheme.motionSchemeEffects(): FiniteAnimationSpec<Float> =
motionScheme.defaultEffectsSpec()

View File

@@ -489,6 +489,11 @@ fun TorchTheme(
contrast: ThemeContrast = platformThemeContrast(),
// Dynamic color is available on Android 12+
dynamicColor: Boolean = true,
// A parameter with a platform default, the same shape as `contrast` and for the same
// reason: the platform's answer is the right starting point and the wrong final word.
// A desktop cannot see what its desktop environment was told, and a settings screen
// will eventually want to override this on every platform.
reducedMotion: Boolean = platformReducedMotion(),
content: @Composable () -> Unit
) {
// Dynamic colour wins when the platform offers it, because it is the user's own
@@ -513,6 +518,9 @@ fun TorchTheme(
CompositionLocalProvider(
LocalExtendedColors provides extendedColorsFor(darkTheme),
LocalBreakpoint provides breakpoint,
// Provided once here rather than read per animation, so a screen cannot animate
// against a setting a neighbouring screen is honouring.
LocalReducedMotion provides reducedMotion,
// The payoff for tokenising spacing in phase 2: the screen margin widens from
// 16dp to 24dp at medium and above without a single call site changing.
LocalSpacing provides spacingFor(breakpoint),