From 61793e2779b1fa90577d2cd26f09e514fb3510ef Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Tue, 8 Sep 2026 08:11:54 +0200 Subject: [PATCH] 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 --- .../mantra/compose/ui/theme/Motion.android.kt | 58 +++++++ .../ui/composable/navigation/MantraNavHost.kt | 13 +- .../press/mantra/compose/ui/theme/Motion.kt | 159 ++++++++++++++++++ .../press/mantra/compose/ui/theme/Theme.kt | 8 + .../mantra/compose/ui/theme/Motion.ios.kt | 38 +++++ .../mantra/compose/ui/theme/Motion.jvm.kt | 21 +++ .../ui/theme/NavigationMotionJvmTest.kt | 118 +++++++++++++ 7 files changed, 414 insertions(+), 1 deletion(-) create mode 100644 composeApp/src/androidMain/kotlin/press/mantra/compose/ui/theme/Motion.android.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/theme/Motion.kt create mode 100644 composeApp/src/iosMain/kotlin/press/mantra/compose/ui/theme/Motion.ios.kt create mode 100644 composeApp/src/jvmMain/kotlin/press/mantra/compose/ui/theme/Motion.jvm.kt create mode 100644 composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/theme/NavigationMotionJvmTest.kt diff --git a/composeApp/src/androidMain/kotlin/press/mantra/compose/ui/theme/Motion.android.kt b/composeApp/src/androidMain/kotlin/press/mantra/compose/ui/theme/Motion.android.kt new file mode 100644 index 00000000..d93cdf17 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/press/mantra/compose/ui/theme/Motion.android.kt @@ -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) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt index f86a23e6..ac8293a4 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt @@ -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 { backStackEntry -> val route = backStackEntry.toRoute() diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/theme/Motion.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/theme/Motion.kt new file mode 100644 index 00000000..6022a524 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/theme/Motion.kt @@ -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.() -> 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.() -> 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.() -> 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.() -> 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 = + motionScheme.defaultSpatialSpec() + +/** The scheme's default effects spec, typed for a fade. */ +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +private fun MaterialTheme.motionSchemeEffects(): FiniteAnimationSpec = + motionScheme.defaultEffectsSpec() diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/theme/Theme.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/theme/Theme.kt index 3e160e8e..d5c2c66f 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/theme/Theme.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/theme/Theme.kt @@ -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), diff --git a/composeApp/src/iosMain/kotlin/press/mantra/compose/ui/theme/Motion.ios.kt b/composeApp/src/iosMain/kotlin/press/mantra/compose/ui/theme/Motion.ios.kt new file mode 100644 index 00000000..b53fb3dc --- /dev/null +++ b/composeApp/src/iosMain/kotlin/press/mantra/compose/ui/theme/Motion.ios.kt @@ -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 +} diff --git a/composeApp/src/jvmMain/kotlin/press/mantra/compose/ui/theme/Motion.jvm.kt b/composeApp/src/jvmMain/kotlin/press/mantra/compose/ui/theme/Motion.jvm.kt new file mode 100644 index 00000000..4606bc93 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/press/mantra/compose/ui/theme/Motion.jvm.kt @@ -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 diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/theme/NavigationMotionJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/theme/NavigationMotionJvmTest.kt new file mode 100644 index 00000000..961150be --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/theme/NavigationMotionJvmTest.kt @@ -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 { Box(Modifier.fillMaxSize().testTag("first")) } + composable { 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 + } +}