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)