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:
@@ -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)
|
||||
@@ -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>()
|
||||
|
||||
@@ -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()
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user