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

@@ -0,0 +1,58 @@
package press.mantra.compose.ui.theme
import android.database.ContentObserver
import android.provider.Settings
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
/**
* Android has no "reduce motion" switch. What it has is **Remove animations**, under
* Accessibility, and what that does is set the three animation duration scales to zero --
* so the honest reading is `ANIMATOR_DURATION_SCALE == 0`.
*
* The platform already applies that scale to `ValueAnimator`, but **not to Compose**:
* Compose animations run on its own clock and ignore it entirely. So an app that draws its
* own transitions has to read the setting itself, which is what this is for.
*
* `TRANSITION_ANIMATION_SCALE` and `WINDOW_ANIMATION_SCALE` are the other two the switch
* sets. Reading one of the three is enough: the accessibility toggle writes all three
* together, and a developer-options user who has set only one has made a deliberate choice
* about a different thing.
*/
@Composable
actual fun platformReducedMotion(): Boolean {
val context = LocalContext.current
val resolver = remember(context) { context.contentResolver } ?: return false
var reduced by remember(resolver) { mutableStateOf(animationsRemoved(resolver)) }
// The setting is changed from the Accessibility screen, which means leaving the app and
// coming back -- but a split screen, a tablet with two apps, or a quick settings tile
// all change it without the app going away. An observer costs one registration and
// removes the whole class of "it only took effect after a restart".
DisposableEffect(resolver) {
val observer = object : ContentObserver(null) {
override fun onChange(selfChange: Boolean) {
reduced = animationsRemoved(resolver)
}
}
resolver.registerContentObserver(
Settings.Global.getUriFor(Settings.Global.ANIMATOR_DURATION_SCALE),
false,
observer,
)
onDispose { resolver.unregisterContentObserver(observer) }
}
return reduced
}
private fun animationsRemoved(resolver: android.content.ContentResolver): Boolean =
runCatching {
Settings.Global.getFloat(resolver, Settings.Global.ANIMATOR_DURATION_SCALE, 1f) == 0f
}.getOrDefault(false)

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),

View File

@@ -0,0 +1,38 @@
package press.mantra.compose.ui.theme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import platform.UIKit.UIAccessibilityIsReduceMotionEnabled
import platform.UIKit.UIAccessibilityReduceMotionStatusDidChangeNotification
import platform.Foundation.NSNotificationCenter
import platform.Foundation.NSOperationQueue
/**
* iOS names the setting exactly: Settings > Accessibility > Motion > Reduce Motion.
*
* The one platform of the three where the answer is a single documented call. The
* notification is the same shape as the darker-system-colours one
* [platformThemeContrast] observes on this platform, and for the same reason: it can be
* turned on from Control Center without the app being backgrounded.
*/
@Composable
actual fun platformReducedMotion(): Boolean {
var reduced by remember { mutableStateOf(UIAccessibilityIsReduceMotionEnabled()) }
DisposableEffect(Unit) {
val observer = NSNotificationCenter.defaultCenter.addObserverForName(
name = UIAccessibilityReduceMotionStatusDidChangeNotification,
`object` = null,
queue = NSOperationQueue.mainQueue,
) { _ ->
reduced = UIAccessibilityIsReduceMotionEnabled()
}
onDispose { NSNotificationCenter.defaultCenter.removeObserver(observer) }
}
return reduced
}

View File

@@ -0,0 +1,21 @@
package press.mantra.compose.ui.theme
import androidx.compose.runtime.Composable
/**
* Always `false`, and this is the honest answer rather than a stub.
*
* The three desktop platforms each have a reduced-motion setting -- Windows' "Show
* animations in Windows", macos' "Reduce motion", and the freedesktop
* `gtk-enable-animations` / `org.gnome.desktop.interface enable-animations` -- and **none
* of them reaches AWT or the jvm at all**. Reading any one means a native call per
* platform, which is the same wall `platformThemeContrast` hits on linux and macos.
*
* Recorded here rather than papered over, because the alternative shape -- guessing, or
* quietly disabling motion on desktop -- would be worse than a documented gap. When the
* app grows a settings screen, this becomes a preference with the platform as its default,
* which is where a desktop app should have ended up regardless: it cannot always see what
* the desktop was told.
*/
@Composable
actual fun platformReducedMotion(): Boolean = false

View File

@@ -0,0 +1,118 @@
package press.mantra.compose.ui.theme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.getUnclippedBoundsInRoot
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.runDesktopComposeUiTest
import androidx.compose.ui.unit.dp
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import kotlinx.serialization.Serializable
import kotlin.test.Test
import kotlin.test.assertTrue
/** Two destinations, so a transition has something to run between. */
@Serializable
object First
@Serializable
object Second
/**
* That the reduced-motion setting reaches the navigation transitions, measured mid-flight.
*
* The claim cannot be read off the source and cannot be asserted on the values:
* `EnterTransition` has no public shape to inspect, so "this one slides and that one does
* not" is only answerable by looking at where the arriving screen *is* part-way through.
*
* The clock is held still and advanced by hand. At a third of the way in, a sliding screen
* is still some distance from its resting place and a fading one is already at it.
*/
@OptIn(ExperimentalTestApi::class)
class NavigationMotionJvmTest {
private val window = 800
@Test
fun `the arriving screen slides when motion is not reduced`() {
assertTrue(
arrivingOffsetMidTransition(reducedMotion = false) > 1.dp.value,
"the second screen was already in place, so nothing slid",
)
}
@Test
fun `it arrives in place when the platform asks for reduced motion`() {
// Not "no transition": the screen still fades, which M3 and WCAG 2.3.3 both allow.
// What goes is the movement.
val offset = arrivingOffsetMidTransition(reducedMotion = true)
assertTrue(
offset <= 1.dp.value,
"the second screen was $offset dp from its resting place, so it still slid",
)
}
/**
* How far the arriving screen's leading edge is from zero, a third of the way through.
*
* A third rather than a frame or two: the scheme's spatial spec is a spring, and a
* spring's first frames are slow enough that a fade and a slide are hard to tell apart
* there.
*/
private fun arrivingOffsetMidTransition(reducedMotion: Boolean): Float {
var offset = 0f
runDesktopComposeUiTest(width = window, height = 600) {
lateinit var controller: NavHostController
mainClock.autoAdvance = false
setContent {
controller = rememberNavController()
// Through the theme's parameter rather than by providing the local
// around it. Providing it outside is what this test did first, and
// `TorchTheme` silently overwrote it -- the reduced case read 54dp of
// slide. That is also why `reducedMotion` is a `TorchTheme` parameter
// rather than something the theme only reads from the platform: the
// desktop actual is a hardcoded `false`, and a value nothing can override
// is a value nothing can test either.
TorchTheme(reducedMotion = reducedMotion) {
NavHost(
navController = controller,
startDestination = First,
enterTransition = NavigationMotion.enter(),
exitTransition = NavigationMotion.exit(),
popEnterTransition = NavigationMotion.popEnter(),
popExitTransition = NavigationMotion.popExit(),
) {
composable<First> { Box(Modifier.fillMaxSize().testTag("first")) }
composable<Second> { Box(Modifier.fillMaxSize().testTag("second")) }
}
}
}
mainClock.advanceTimeBy(16)
// On the main thread, and not through `runOnIdle`: navigation-compose asserts
// the thread, and nothing is idle while the clock is held.
runOnUiThread { controller.navigate(Second) }
mainClock.advanceTimeBy(16)
// A third of the scheme's default spatial duration. Long enough that a spring
// has visibly moved, short enough that it has not settled.
mainClock.advanceTimeBy(120)
offset = onNodeWithTag("second").getUnclippedBoundsInRoot().left.value
// Let the transition finish before the composition is torn down. A back stack
// entry caught mid-transition is still below CREATED, and navigation-compose
// throws trying to move it to DESTROYED.
mainClock.autoAdvance = true
waitForIdle()
}
return offset
}
}