[PM-45372] feat: Add Navigation3 base classes and tools to the UI module (#7507)

This commit is contained in:
David Perez
2026-11-09 15:07:43 +00:00
committed by GitHub
parent 95acbe84c4
commit db7d3c9498
12 changed files with 1194 additions and 185 deletions
@@ -1,5 +1,7 @@
package com.bitwarden.ui.platform.components.navigation.model
import androidx.navigation3.runtime.NavKey
/**
* Represents a user-interactable item to navigate a user via the bottom app bar or navigation rail.
*/
@@ -15,14 +17,27 @@ interface NavigationItem {
val labelRes: Int
/**
* Route of the tab's graph.
* The route of the tab's root destination, which also identifies the tab's back stack.
*/
val graphRoute: Any
val rootRoute: NavKey
// TODO: Remove this default once all modules have been migrated to Navigation3 (PM-45372).
get() = error("rootRoute is not supported by legacy Navigation Compose tabs.")
/**
* Route of the tab's start destination.
* Legacy route of the tab's Navigation Compose graph.
*
* TODO: Remove once all modules have been migrated to Navigation3 (PM-45372).
*/
val graphRoute: Any
get() = rootRoute
/**
* Legacy route of the tab's Navigation Compose start destination.
*
* TODO: Remove once all modules have been migrated to Navigation3 (PM-45372).
*/
val startDestinationRoute: Any
get() = rootRoute
/**
* The test tag of the tab.
@@ -0,0 +1,33 @@
package com.bitwarden.ui.platform.navigation
import androidx.navigation3.runtime.NavKey
/**
* Controls navigation within a bottom navigation set of back stacks.
*/
interface BottomNavigationController<BottomNavGraph : NavKey> {
/**
* The controller for the bottom navigation graph.
*/
val root: NavigationController<BottomNavGraph>
/**
* A list of all back stacks in the bottom navigation, including the root bottom nav.
*/
val backStacks: List<List<NavKey>>
/**
* Navigates to the selected tab route or pops the backstack up to the root of the already
* selected tab route.
*/
fun navigateToTabOrPopToRoot(route: BottomNavGraph)
/**
* Navigates back within the back stack of the selected tab. When the selected tab is already
* at its root, the start tab is selected instead, so the user always exits through the start
* tab.
*
* @return `true` if the navigation was handled.
*/
fun navigateBack(): Boolean
}
@@ -0,0 +1,94 @@
package com.bitwarden.ui.platform.navigation
import androidx.navigation3.runtime.NavKey
import kotlinx.coroutines.flow.Flow
/**
* Controls navigation within a single backstack of [NavKey] routes.
*
* The [backStack] is observable state that should be displayed by a `NavDisplay`, while all
* changes to it should be made through this controller.
*/
interface NavigationController<T : NavKey> {
/**
* The current backstack, ordered from the root entry to the top entry. This list is backed by
* snapshot state, so reading it within a composition will observe every change.
*/
val backStack: List<T>
/**
* Creates an observable snapshot flow of the backstack, indicating any changes that is made to
* it.
*/
val backStackSnapshot: Flow<List<T>>
/**
* Navigates to the given [route] by adding it to the top of the backstack.
*/
fun navigate(route: T)
/**
* Navigates back by removing the top entry from the backstack.
*
* The last remaining entry is never removed, since a backstack being displayed must never be
* empty.
*
* @return `true` if an entry was removed from the backstack.
*/
fun navigateBack(): Boolean
/**
* Adds the given [route] to the top of the backstack unless an entry of the same type is
* already on top of the backstack. This ensures there is at most one copy of a given
* destination on the top of the backstack.
*/
fun addSingleTop(route: T)
/**
* Pops entries off of the backstack until the most recent entry matching the [predicate] is
* on top. If [inclusive] is `true`, the matching entry is removed as well (unless it is the
* last remaining entry).
*
* @return `true` if an entry matching the [predicate] was found in the backstack.
*/
fun popUpTo(inclusive: Boolean = false, predicate: (T) -> Boolean): Boolean
/**
* Pops all entries off the backstack until only the root entry remains. This does nothing if
* only the root entry exists.
*/
fun popToRoot()
/**
* Atomically pops entries off of the backstack until the most recent entry matching the
* [predicate] is on top (or removed as well, if [inclusive] is `true`) and then adds the given
* [route].
*
* If there is no entry matching the [predicate] in the backstack, the [route] is simply added.
*/
fun navigatePoppingUpTo(route: T, inclusive: Boolean = false, predicate: (T) -> Boolean)
/**
* Navigates to the given top-level [route], such as a tab of a navigation bar, where the
* backstack only ever contains top-level routes.
*
* The first route of the backstack (the start route) is always retained beneath the given
* [route], so navigating back from any other top-level route returns to the start route.
*/
fun navigateToTopLevelRoute(route: T)
/**
* Atomically replaces the entire contents of the backstack with the given [routes].
*/
fun replaceAll(routes: List<T>)
/**
* Atomically replaces the entire contents of the backstack with the given [routes].
*/
fun replaceAll(vararg routes: T)
/**
* Atomically replaces the top of the backstack with the given [route].
*/
fun replaceLast(route: T)
}
@@ -0,0 +1,96 @@
package com.bitwarden.ui.platform.navigation
import androidx.compose.runtime.snapshotFlow
import androidx.compose.runtime.snapshots.Snapshot
import androidx.navigation3.runtime.NavBackStack
import androidx.navigation3.runtime.NavKey
import kotlinx.coroutines.flow.Flow
/**
* The default implementation of the [NavigationController], which manages the given [backStack].
*/
internal class NavigationControllerImpl<T : NavKey>(
override val backStack: NavBackStack<T>,
) : NavigationController<T> {
override val backStackSnapshot: Flow<List<T>> get() = snapshotFlow { backStack.toList() }
override fun navigate(route: T) {
backStack.add(element = route)
}
override fun navigateBack(): Boolean = if (backStack.size <= 1) {
false
} else {
backStack.removeAt(index = backStack.lastIndex)
true
}
override fun addSingleTop(route: T) {
if (backStack.lastOrNull()?.let { it::class } == route::class) return
backStack.add(element = route)
}
override fun popUpTo(
inclusive: Boolean,
predicate: (T) -> Boolean,
): Boolean {
val index = backStack.indexOfLast(predicate = predicate)
return if (index == -1) {
false
} else {
val newSize = (if (inclusive) index else index + 1).coerceAtLeast(minimumValue = 1)
if (newSize < backStack.size) backStack.subList(newSize, backStack.size).clear()
true
}
}
override fun popToRoot() {
backStack.subList(fromIndex = 1, toIndex = backStack.size).clear()
}
override fun navigatePoppingUpTo(
route: T,
inclusive: Boolean,
predicate: (T) -> Boolean,
) {
val index = backStack.indexOfLast(predicate = predicate)
Snapshot.withMutableSnapshot {
if (index != -1) {
backStack
.subList(
fromIndex = if (inclusive) index else index + 1,
toIndex = backStack.size,
)
.clear()
}
backStack.add(element = route)
}
}
override fun navigateToTopLevelRoute(route: T) {
val startRoute = backStack.first()
if (route == startRoute) {
this.replaceAll(routes = listOf(startRoute))
} else {
this.replaceAll(routes = listOf(startRoute, route))
}
}
override fun replaceAll(routes: List<T>) {
require(value = routes.isNotEmpty()) {
"A back stack cannot be replaced with an empty list."
}
Snapshot.withMutableSnapshot {
backStack.clear()
backStack.addAll(elements = routes)
}
}
override fun replaceAll(vararg routes: T) {
this.replaceAll(routes = routes.toList())
}
override fun replaceLast(route: T) {
backStack[backStack.lastIndex] = route
}
}
@@ -0,0 +1,38 @@
package com.bitwarden.ui.platform.navigation.components
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.navigation3.runtime.NavEntry
import androidx.navigation3.runtime.NavKey
import androidx.navigation3.ui.NavDisplay
import com.bitwarden.ui.platform.navigation.NavigationController
import com.bitwarden.ui.platform.navigation.util.rememberBitwardenNavEntryDecorators
import com.bitwarden.ui.platform.theme.ContentTransformProvider
import com.bitwarden.ui.platform.theme.NavDisplayTransitions
import com.bitwarden.ui.platform.theme.PredictiveContentTransformProvider
/**
* A [NavDisplay] that displays the back stack of the given [navigationController], which is
* popped whenever the user navigates back.
*/
@Composable
fun <T : NavKey> BitwardenNavDisplay(
navigationController: NavigationController<T>,
entryProvider: (key: T) -> NavEntry<T>,
modifier: Modifier = Modifier,
transitionSpec: ContentTransformProvider<T> = NavDisplayTransitions.root(),
popTransitionSpec: ContentTransformProvider<T> = transitionSpec,
predictivePopTransitionSpec: PredictiveContentTransformProvider<T> =
NavDisplayTransitions.predictive(provider = popTransitionSpec),
) {
NavDisplay(
backStack = navigationController.backStack,
modifier = modifier,
onBack = navigationController::navigateBack,
entryDecorators = rememberBitwardenNavEntryDecorators(),
transitionSpec = transitionSpec,
popTransitionSpec = popTransitionSpec,
predictivePopTransitionSpec = predictivePopTransitionSpec,
entryProvider = entryProvider,
)
}
@@ -0,0 +1,42 @@
package com.bitwarden.ui.platform.navigation.components
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.navigation3.runtime.NavEntry
import androidx.navigation3.runtime.NavKey
import androidx.navigation3.ui.NavDisplay
import com.bitwarden.ui.platform.navigation.BottomNavigationController
import com.bitwarden.ui.platform.theme.ContentTransformProvider
import com.bitwarden.ui.platform.theme.NavDisplayTransitions
import com.bitwarden.ui.platform.theme.PredictiveContentTransformProvider
/**
* A [NavDisplay] that displays the back stacks of the tabs managed by the given
* [bottomNavigationController], following the "multiple back stacks" pattern.
*
* The [tabEntries] of every tab currently in the back stack of the
* [BottomNavigationController.root] are displayed together, so the back stack of the start tab is
* always displayed beneath the back stack of the selected tab. The
* [BottomNavigationController.navigateBack] is invoked whenever the user navigates back.
*/
@Composable
fun <T : NavKey> BitwardenTabNavDisplay(
bottomNavigationController: BottomNavigationController<T>,
tabEntries: Map<T, List<NavEntry<NavKey>>>,
modifier: Modifier = Modifier,
transitionSpec: ContentTransformProvider<NavKey> = NavDisplayTransitions.fade(),
popTransitionSpec: ContentTransformProvider<NavKey> = transitionSpec,
predictivePopTransitionSpec: PredictiveContentTransformProvider<NavKey> =
NavDisplayTransitions.predictive(provider = popTransitionSpec),
) {
NavDisplay(
entries = bottomNavigationController.root.backStack.flatMap { tab ->
tabEntries.getValue(key = tab)
},
modifier = modifier,
onBack = bottomNavigationController::navigateBack,
transitionSpec = transitionSpec,
popTransitionSpec = popTransitionSpec,
predictivePopTransitionSpec = predictivePopTransitionSpec,
)
}
@@ -0,0 +1,85 @@
package com.bitwarden.ui.platform.navigation.util
import androidx.compose.runtime.Composable
import androidx.navigation3.runtime.EntryProviderScope
import androidx.navigation3.runtime.NavKey
import com.bitwarden.ui.platform.theme.EntryTransitions
import com.bitwarden.ui.platform.theme.RootTransition
/**
* Adds the [Composable] [content] entry to the [EntryProviderScope] using a
* [EntryTransitions.push] transition.
*/
inline fun <reified T : NavKey> EntryProviderScope<in T>.pushEntry(
noinline clazzContentKey: (key: T) -> Any = { defaultContentKey(key = it) },
noinline content: @Composable (T) -> Unit,
) {
entry<T>(
clazzContentKey = clazzContentKey,
metadata = EntryTransitions.push,
content = content,
)
}
/**
* Adds the [Composable] [content] entry to the [EntryProviderScope] using a
* [EntryTransitions.slideUp] transition.
*/
inline fun <reified T : NavKey> EntryProviderScope<in T>.slideUpEntry(
noinline clazzContentKey: (key: T) -> Any = { defaultContentKey(key = it) },
noinline content: @Composable (T) -> Unit,
) {
entry<T>(
clazzContentKey = clazzContentKey,
metadata = EntryTransitions.slideUp,
content = content,
)
}
/**
* Adds the [Composable] [content] entry to the [EntryProviderScope] using a
* [EntryTransitions.stay] transition.
*/
inline fun <reified T : NavKey> EntryProviderScope<in T>.stayEntry(
noinline clazzContentKey: (key: T) -> Any = { defaultContentKey(key = it) },
noinline content: @Composable (T) -> Unit,
) {
entry<T>(
clazzContentKey = clazzContentKey,
metadata = EntryTransitions.stay,
content = content,
)
}
/**
* Adds the [Composable] [content] entry to the [EntryProviderScope] using a
* [EntryTransitions.rootPush] transition. This should be used for the root of a flow.
*/
inline fun <reified T : NavKey> EntryProviderScope<in T>.rootPushEntry(
noinline clazzContentKey: (key: T) -> Any = { defaultContentKey(key = it) },
noinline content: @Composable (T) -> Unit,
) {
entry<T>(
clazzContentKey = clazzContentKey,
metadata = EntryTransitions.rootPush,
content = content,
)
}
/**
* Adds the [Composable] [content] entry to the [EntryProviderScope] using a
* [EntryTransitions.rootTransition] with the specified [RootTransition] type.
*/
inline fun <reified T : NavKey> EntryProviderScope<in T>.rootTransitionEntry(
rootTransition: RootTransition,
noinline clazzContentKey: (key: T) -> Any = { defaultContentKey(key = it) },
noinline content: @Composable (T) -> Unit,
) {
entry<T>(
clazzContentKey = clazzContentKey,
metadata = EntryTransitions.rootTransition(rootTransition = rootTransition),
content = content,
)
}
private fun defaultContentKey(key: Any): String = "$key:${key::class}"
@@ -0,0 +1,24 @@
package com.bitwarden.ui.platform.navigation.util
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
import androidx.navigation3.runtime.NavEntry
import androidx.navigation3.runtime.NavEntryDecorator
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
/**
* Remembers the standard [NavEntryDecorator]s used by every navigation display in the app.
*
* These ensure that each [NavEntry] has its own saveable state as well as its own `ViewModelStore`,
* so that ViewModels (and their `SavedStateHandle`s) are scoped to the entry and cleared when the
* entry is popped off of the back stack.
*/
@Composable
fun <T : Any> rememberBitwardenNavEntryDecorators(): List<NavEntryDecorator<T>> {
val saveableStateHolderDecorator = rememberSaveableStateHolderNavEntryDecorator<T>()
val viewModelStoreDecorator = rememberViewModelStoreNavEntryDecorator<T>()
return remember(key1 = saveableStateHolderDecorator, key2 = viewModelStoreDecorator) {
listOf(saveableStateHolderDecorator, viewModelStoreDecorator)
}
}
@@ -0,0 +1,115 @@
package com.bitwarden.ui.platform.navigation.util
import androidx.annotation.VisibleForTesting
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSerializable
import androidx.compose.runtime.snapshotFlow
import androidx.navigation3.runtime.NavBackStack
import androidx.navigation3.runtime.NavEntry
import androidx.navigation3.runtime.NavKey
import androidx.navigation3.runtime.rememberDecoratedNavEntries
import androidx.navigation3.runtime.serialization.NavBackStackSerializer
import androidx.navigation3.runtime.serialization.NavKeySerializer
import com.bitwarden.ui.platform.navigation.NavigationController
import com.bitwarden.ui.platform.navigation.NavigationControllerImpl
import timber.log.Timber
/**
* Pops entries off of the back stack until the most recent entry of type [Route] is on top. If
* [inclusive] is `true`, the entry of type [Route] is removed as well (unless it is the last
* remaining entry).
*/
inline fun <reified Route : NavKey> NavigationController<in Route>.popUpTo(
inclusive: Boolean = false,
): Boolean = this.popUpTo(inclusive = inclusive) { it is Route }
/**
* Remembers the decorated [NavEntry]s for the back stack of this [NavigationController], which
* can then be displayed by a `NavDisplay`.
*
* The state of each entry (including its saveable state and ViewModels) is owned by the
* composition this is called from, rather than by the `NavDisplay` itself. This allows the state
* to be retained while the `NavDisplay` is not being displayed, such as when it is hosted by an
* unselected tab, and it is cleared once the entry is popped off of the backstack.
*/
@Composable
fun <Route : NavKey> NavigationController<Route>.rememberNavEntries(
entryProvider: (key: Route) -> NavEntry<Route>,
): List<NavEntry<Route>> = rememberDecoratedNavEntries(
backStack = this.backStack,
entryDecorators = rememberBitwardenNavEntryDecorators(),
entryProvider = entryProvider,
)
/**
* Remembers the decorated [NavEntry]s for the back stack of this [NavigationController] (see
* [rememberNavEntries]) as [NavEntry]s of any [NavKey].
*
* This allows the entries of multiple back stacks, each belonging to a different navigation graph,
* to be displayed together by a single `NavDisplay`, such as when following the "multiple back
* stacks" pattern for the tabs of a navigation bar. The entries of each back stack are still
* decorated independently, so the state of each back stack is retained while it is not displayed.
*/
@Composable
fun <Route : NavKey> NavigationController<Route>.rememberNavKeyEntries(
entryProvider: (key: Route) -> NavEntry<Route>,
): List<NavEntry<NavKey>> {
val keys = this.backStack.toList()
val entries = this.rememberNavEntries(entryProvider = entryProvider)
return keys.zip(other = entries) { key, entry ->
NavEntry(
key = key,
contentKey = entry.contentKey,
metadata = entry.metadata,
content = { entry.Content() },
)
}
}
/**
* Creates a [NavigationController] whose backstack initially contains the given [routes].
*
* The backstack of the returned controller is not saved. Within a composition, use
* [rememberNavigationController] instead.
*/
@VisibleForTesting
fun <T : NavKey> navigationControllerOf(
vararg routes: T,
): NavigationController<T> = NavigationControllerImpl(
backStack = NavBackStack(elements = routes),
)
/**
* Remember a [NavigationController] across compositions and process death.
*/
@Composable
inline fun <reified T : NavKey> rememberNavigationController(
vararg routes: T,
): NavigationController<T> {
val backStack = rememberSerializable(
serializer = NavBackStackSerializer(elementSerializer = NavKeySerializer()),
) {
NavBackStack(elements = routes)
}
LaunchedEffect(key1 = backStack) {
snapshotFlow { backStack.toList() }.collect {
val currentStack = it.joinToString(separator = " -> ") { entry ->
entry::class.simpleName ?: "Unknown"
}
Timber.d(message = "${T::class.simpleName}: $currentStack")
}
}
return rememberNavigationController(backStack = backStack)
}
/**
* Remember a [NavigationController] across compositions and process death.
*/
@Composable
fun <T : NavKey> rememberNavigationController(
backStack: NavBackStack<T>,
): NavigationController<T> = remember(key1 = backStack) {
NavigationControllerImpl(backStack = backStack)
}
@@ -0,0 +1,247 @@
package com.bitwarden.ui.platform.theme
import androidx.compose.animation.AnimatedContentTransitionScope
import androidx.compose.animation.ContentTransform
import androidx.compose.animation.togetherWith
import androidx.navigation3.runtime.NavEntry
import androidx.navigation3.runtime.NavMetadataKey
import androidx.navigation3.runtime.contains
import androidx.navigation3.runtime.get
import androidx.navigation3.runtime.metadata
import androidx.navigation3.scene.Scene
import androidx.navigation3.ui.NavDisplay
typealias ContentTransformProvider<T> =
AnimatedContentTransitionScope<Scene<T>>.() -> ContentTransform
typealias PredictiveContentTransformProvider<T> =
AnimatedContentTransitionScope<Scene<T>>.(
swipeEdge: Int,
) -> ContentTransform
/**
* Reusable [NavEntry.metadata] that defines how an entry animates as it is pushed onto and popped
* off of a backstack.
*/
object EntryTransitions {
/**
* Slides the entry up from the bottom of the screen when it is pushed and back down when it is
* popped. The entry beneath it stays in place.
*/
val slideUp: Map<String, Any> = entryTransitions(
enter = TransitionProviders.Enter.slideUp,
popExit = TransitionProviders.Exit.slideDown,
)
/**
* Keeps the entry in place when it is pushed or popped.
*/
val stay: Map<String, Any> = entryTransitions(
enter = TransitionProviders.Enter.stay,
popExit = TransitionProviders.Exit.stay,
)
/**
* Pushes the entry in from the end of the screen when it is pushed and back towards the end
* when it is popped.
*
* This is suitable for screens deeper within a hierarchy that uses push transitions; the root
* screen of such a hierarchy should use [rootPush].
*/
val push: Map<String, Any> = entryTransitions(
enter = TransitionProviders.Enter.pushToStart,
popExit = TransitionProviders.Exit.pushToEnd,
)
/**
* Marks the entry as the root of a flow that uses [push] transitions, such as the root screen
* of a tab. The root is pushed towards the start of the screen when another entry is pushed on
* top of it and is pushed back in from the start when that entry is popped.
*
* Because the entry starts a new flow, navigating to or from the root itself uses the default
* transitions of the hosting [NavDisplay].
*/
val rootPush: Map<String, Any> = metadata {
put(FlowRootKey, true)
put(CoveredExitTransitionKey, TransitionProviders.Exit.pushToStart)
put(RevealedEnterTransitionKey, TransitionProviders.Enter.pushToEnd)
} + entryTransitions(
enter = TransitionProviders.Enter.stay,
popExit = TransitionProviders.Exit.fadeOut,
)
/**
* Specifies the [RootTransition] used when the back stack beginning with this entry replaces
* (or is replaced by) another back stack within a [NavDisplay] using
* [NavDisplayTransitions.root].
*
* This only has an effect when applied to the first entry of a back stack.
*/
fun rootTransition(rootTransition: RootTransition): Map<String, Any> = metadata {
put(RootTransitionKey, rootTransition)
}
}
/**
* Describes how a back stack animates when it replaces, or is replaced by, another back stack
* within a [NavDisplay] using [NavDisplayTransitions.root].
*/
enum class RootTransition {
/**
* Fades the back stack in and out. This is the default behavior.
*/
FADE,
/**
* The back stack appears without any animation and disappears without any animation.
*/
NONE,
/**
* Pushes the back stack in from the end of the screen. When replacing another back stack using
* this transition, the replaced back stack stays in place.
*/
PUSH,
/**
* Slides the back stack up from the bottom of the screen while the replaced back stack stays
* in place, and slides it back down when it is replaced.
*/
SLIDE_UP,
}
/**
* Reusable default transitions for a [NavDisplay]. These are used whenever the transitioning
* entries do not provide their own [EntryTransitions].
*/
object NavDisplayTransitions {
/**
* Fades between scenes.
*/
fun <T : Any> fade(): ContentTransformProvider<T> = {
TransitionProviders.Enter.fadeIn(this) togetherWith TransitionProviders.Exit.fadeOut(this)
}
/**
* Transitions for a "root" [NavDisplay] whose back stack is entirely replaced as the user moves
* between the high level flows of the app. Each back stack declares how it animates using
* [EntryTransitions.rootTransition] on its first entry.
*
* Entries without their own [EntryTransitions] do not animate when navigating within a flow.
*/
fun <T : Any> root(): ContentTransformProvider<T> = {
if (isSameFlowNavigation) {
TransitionProviders.Enter.none(this) togetherWith TransitionProviders.Exit.none(this)
} else {
val initialTransition = initialState.rootTransition
val targetTransition = targetState.rootTransition
val enter = when {
targetTransition == RootTransition.SLIDE_UP -> TransitionProviders.Enter.slideUp
targetTransition == RootTransition.PUSH -> TransitionProviders.Enter.pushToStart
initialTransition == RootTransition.NONE -> TransitionProviders.Enter.none
else -> TransitionProviders.Enter.fadeIn
}
val exit = when {
initialTransition == RootTransition.NONE -> TransitionProviders.Exit.none
initialTransition == RootTransition.SLIDE_UP -> TransitionProviders.Exit.slideDown
targetTransition == RootTransition.SLIDE_UP -> TransitionProviders.Exit.stay
initialTransition == RootTransition.PUSH &&
targetTransition == RootTransition.PUSH -> TransitionProviders.Exit.stay
else -> TransitionProviders.Exit.fadeOut
}
enter(this) togetherWith exit(this)
}
}
/**
* Converts the given [ContentTransformProvider] into a [PredictiveContentTransformProvider]
* that uses the same [ContentTransform] regardless of the swipe edge.
*/
fun <T : Any> predictive(
provider: ContentTransformProvider<T>,
): PredictiveContentTransformProvider<T> = { _ -> provider(this) }
}
/**
* Indicates whether the navigation is occurring within the same flow, which is a push or pop on the
* same back stack beneath the same root entry (see [EntryTransitions.rootPush]). This is `false`
* when the back stack has been replaced or when navigating to or from the root of a flow.
*/
val AnimatedContentTransitionScope<out Scene<*>>.isSameFlowNavigation: Boolean
get() = initialState.flowRootContentKey == targetState.flowRootContentKey
/**
* Metadata key indicating that an entry is the root of a flow.
*/
private object FlowRootKey : NavMetadataKey<Boolean>
/**
* Metadata key for the exit transition used by an entry when another entry is pushed on top of it.
*/
private object CoveredExitTransitionKey : NavMetadataKey<ExitTransitionProvider>
/**
* Metadata key for the enter transition used by an entry when the entry on top of it is popped.
*/
private object RevealedEnterTransitionKey : NavMetadataKey<EnterTransitionProvider>
/**
* Metadata key for the [RootTransition] of the back stack beginning with an entry.
*/
private object RootTransitionKey : NavMetadataKey<RootTransition>
/**
* Creates the metadata for the transitions of an entry that is pushed with the given [enter]
* transition and popped with the given [popExit] transition.
*/
private fun entryTransitions(
enter: EnterTransitionProvider,
popExit: ExitTransitionProvider,
): Map<String, Any> {
val popTransitionSpec: AnimatedContentTransitionScope<Scene<*>>.() -> ContentTransform? = {
if (isSameFlowNavigation) {
targetState.revealedEnterTransition(this) togetherWith popExit(this)
} else {
null
}
}
return NavDisplay.transitionSpec {
if (isSameFlowNavigation) {
enter(this) togetherWith initialState.coveredExitTransition(this)
} else {
null
}
} + NavDisplay.popTransitionSpec(popTransitionSpec) +
NavDisplay.predictivePopTransitionSpec { _ -> popTransitionSpec(this) }
}
/**
* The exit transition used by the top entry of this scene when another entry is pushed on top.
*/
private val Scene<*>.coveredExitTransition: ExitTransitionProvider
get() = metadata[CoveredExitTransitionKey] ?: TransitionProviders.Exit.stay
/**
* The enter transition used by the top entry of this scene when the entry on top is popped.
*/
private val Scene<*>.revealedEnterTransition: EnterTransitionProvider
get() = metadata[RevealedEnterTransitionKey] ?: TransitionProviders.Enter.stay
/**
* The [RootTransition] declared by the first entry of the back stack represented by this scene.
*/
private val Scene<*>.rootTransition: RootTransition
get() = (previousEntries.firstOrNull() ?: entries.first())
.metadata[RootTransitionKey]
?: RootTransition.FADE
/**
* The content key of the entry that roots the flow currently displayed by this scene. This is the
* last entry marked with [FlowRootKey] or, if there is none, the first entry of the back stack.
*/
private val Scene<*>.flowRootContentKey: Any
get() {
val backStack = previousEntries + entries
return (backStack.lastOrNull { FlowRootKey in it.metadata } ?: backStack.first()).contentKey
}
@@ -7,19 +7,12 @@ import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.navigation.NavBackStackEntry
import androidx.navigation.compose.NavHost
typealias EnterTransitionProvider =
(@JvmSuppressWildcards AnimatedContentTransitionScope<NavBackStackEntry>.() -> EnterTransition?)
@JvmSuppressWildcards AnimatedContentTransitionScope<*>.() -> EnterTransition
typealias ExitTransitionProvider =
(@JvmSuppressWildcards AnimatedContentTransitionScope<NavBackStackEntry>.() -> ExitTransition?)
typealias NonNullEnterTransitionProvider =
(@JvmSuppressWildcards AnimatedContentTransitionScope<NavBackStackEntry>.() -> EnterTransition)
typealias NonNullExitTransitionProvider =
(@JvmSuppressWildcards AnimatedContentTransitionScope<NavBackStackEntry>.() -> ExitTransition)
@JvmSuppressWildcards AnimatedContentTransitionScope<*>.() -> ExitTransition
/**
* The default transition time (in milliseconds) for all fade transitions in the
@@ -46,25 +39,15 @@ const val DEFAULT_PUSH_TRANSITION_TIME_MS: Int = 350
* This should be at least as large as any other transition that might also be happening during a
* navigation.
*/
val DEFAULT_STAY_TRANSITION_TIME_MS: Int =
maxOf(
DEFAULT_FADE_TRANSITION_TIME_MS,
DEFAULT_SLIDE_TRANSITION_TIME_MS,
DEFAULT_PUSH_TRANSITION_TIME_MS,
)
val DEFAULT_STAY_TRANSITION_TIME_MS: Int = maxOf(
DEFAULT_FADE_TRANSITION_TIME_MS,
DEFAULT_SLIDE_TRANSITION_TIME_MS,
DEFAULT_PUSH_TRANSITION_TIME_MS,
)
/**
* Checks if the parent of the destination before and after the navigation is the same. This is
* useful to ignore certain enter/exit transitions when navigating between distinct, nested flows.
*/
val AnimatedContentTransitionScope<NavBackStackEntry>.isSameGraphNavigation: Boolean
get() = initialState.destination.parent == targetState.destination.parent
/**
* Contains standard "transition providers" that may be used to specify the [EnterTransition] and
* [ExitTransition] used when building a typical composable destination. These may return `null`
* values in order to allow transitions between nested navigation graphs to be specified by
* components higher up in the graph.
* Contains the standard, reusable "transition providers" that are used to build the
* [EntryTransitions] for navigation entries and the default transitions of a navigation display.
*/
object TransitionProviders {
/**
@@ -73,160 +56,22 @@ object TransitionProviders {
object Enter {
/**
* Fades the new screen in.
*
* Note that this represents a `null` transition when navigating between different nested
* navigation graphs.
*/
val fadeIn: EnterTransitionProvider = {
RootTransitionProviders
.Enter
.fadeIn(this)
.takeIf { isSameGraphNavigation }
}
/**
* Slides the new screen in from the start (left) of the screen towards the end (right).
*/
val pushToEnd: EnterTransitionProvider = {
RootTransitionProviders
.Enter
.pushToEnd(this)
.takeIf { isSameGraphNavigation }
}
/**
* Slides the new screen in from the end (right) of the screen towards the start (left).
*/
val pushToStart: EnterTransitionProvider = {
RootTransitionProviders
.Enter
.pushToStart(this)
.takeIf { isSameGraphNavigation }
}
/**
* Slides the new screen in from the bottom of the screen.
*
* Note that this represents a `null` transition when navigating between different nested
* navigation graphs.
*/
val slideUp: EnterTransitionProvider = {
RootTransitionProviders
.Enter
.slideUp(this)
.takeIf { isSameGraphNavigation }
}
/**
* A "no-op" transition: this changes nothing about the screen but "lasts" as long as
* other standard transitions in order to leave the screen in place such that it does not
* immediately appear while the other screen transitions away.
*
* Note that this represents a `null` transition when navigating between different nested
* navigation graphs.
*/
val stay: EnterTransitionProvider = {
RootTransitionProviders
.Enter
.stay(this)
.takeIf { isSameGraphNavigation }
}
}
/**
* The standard set of "exit" transition providers.
*/
object Exit {
/**
* Fades the current screen out.
*
* Note that this represents a `null` transition when navigating between different nested
* navigation graphs.
*/
val fadeOut: ExitTransitionProvider = {
RootTransitionProviders
.Exit
.fadeOut(this)
.takeIf { isSameGraphNavigation }
}
/**
* Slides the current screen out to the start (left) of the screen towards the end (right).
*/
val pushToStart: ExitTransitionProvider = {
RootTransitionProviders
.Exit
.pushToStart(this)
.takeIf { isSameGraphNavigation }
}
/**
* Slides the current screen out to the end (right) of the screen towards the start (left).
*/
val pushToEnd: ExitTransitionProvider = {
RootTransitionProviders
.Exit
.pushToEnd(this)
.takeIf { isSameGraphNavigation }
}
/**
* Slides the current screen down to the bottom of the screen.
*
* Note that this represents a `null` transition when navigating between different nested
* navigation graphs.
*/
val slideDown: ExitTransitionProvider = {
RootTransitionProviders
.Exit
.slideDown(this)
.takeIf { isSameGraphNavigation }
}
/**
* A "no-op" transition: this changes nothing about the screen but "lasts" as long as
* other standard transitions in order to leave the screen in place such that it does not
* immediately disappear while the other screen transitions into place.
*
* Note that this represents a `null` transition when navigating between different nested
* navigation graphs.
*/
val stay: ExitTransitionProvider = {
RootTransitionProviders
.Exit
.stay(this)
.takeIf { isSameGraphNavigation }
}
}
}
/**
* Contains standard "transition providers" that may be used to specify the [EnterTransition] and
* [ExitTransition] used when building a root [NavHost], which requires a non-null value.
*/
object RootTransitionProviders {
/**
* The standard set of "enter" transition providers.
*/
object Enter {
/**
* Fades the new screen in.
*/
val fadeIn: NonNullEnterTransitionProvider = {
fadeIn(tween(DEFAULT_FADE_TRANSITION_TIME_MS))
fadeIn(animationSpec = tween(durationMillis = DEFAULT_FADE_TRANSITION_TIME_MS))
}
/**
* There is no transition for the entering screen.
*/
val none: NonNullEnterTransitionProvider = {
val none: EnterTransitionProvider = {
EnterTransition.None
}
/**
* Slides the new screen in from the start (left) of the screen towards the end (right).
*/
val pushToEnd: NonNullEnterTransitionProvider = {
val pushToEnd: EnterTransitionProvider = {
val totalTransitionDurationMs = DEFAULT_PUSH_TRANSITION_TIME_MS
slideIntoContainer(
towards = AnimatedContentTransitionScope.SlideDirection.End,
@@ -243,7 +88,7 @@ object RootTransitionProviders {
/**
* Slides the new screen in from the end (right) of the screen towards the start (left).
*/
val pushToStart: NonNullEnterTransitionProvider = {
val pushToStart: EnterTransitionProvider = {
val totalTransitionDurationMs = DEFAULT_PUSH_TRANSITION_TIME_MS
slideIntoContainer(
towards = AnimatedContentTransitionScope.SlideDirection.Start,
@@ -260,10 +105,10 @@ object RootTransitionProviders {
/**
* Slides the new screen in from the bottom of the screen.
*/
val slideUp: NonNullEnterTransitionProvider = {
val slideUp: EnterTransitionProvider = {
slideIntoContainer(
towards = AnimatedContentTransitionScope.SlideDirection.Up,
animationSpec = tween(DEFAULT_SLIDE_TRANSITION_TIME_MS),
animationSpec = tween(durationMillis = DEFAULT_SLIDE_TRANSITION_TIME_MS),
)
}
@@ -272,9 +117,9 @@ object RootTransitionProviders {
* other standard transitions in order to leave the screen in place such that it does not
* immediately appear while the other screen transitions away.
*/
val stay: NonNullEnterTransitionProvider = {
val stay: EnterTransitionProvider = {
fadeIn(
animationSpec = tween(DEFAULT_STAY_TRANSITION_TIME_MS),
animationSpec = tween(durationMillis = DEFAULT_STAY_TRANSITION_TIME_MS),
initialAlpha = 1f,
)
}
@@ -287,18 +132,18 @@ object RootTransitionProviders {
/**
* Fades the current screen out.
*/
val fadeOut: NonNullExitTransitionProvider = {
fadeOut(tween(DEFAULT_FADE_TRANSITION_TIME_MS))
val fadeOut: ExitTransitionProvider = {
fadeOut(animationSpec = tween(durationMillis = DEFAULT_FADE_TRANSITION_TIME_MS))
}
/**
* There is no transition for the exiting screen.
*
* Unlike the [RootTransitionProviders.Exit.stay] transition, this will immediately remove
* Unlike the [TransitionProviders.Exit.stay] transition, this will immediately remove
* the outgoing screen even if there is an ongoing enter transition happening for the new
* screen.
*/
val none: NonNullExitTransitionProvider = {
val none: ExitTransitionProvider = {
ExitTransition.None
}
@@ -306,7 +151,7 @@ object RootTransitionProviders {
* Slides the current screen out to the start (left) of the screen towards the end (right).
*/
@Suppress("MagicNumber")
val pushToStart: NonNullExitTransitionProvider = {
val pushToStart: ExitTransitionProvider = {
val totalTransitionDurationMs = DEFAULT_PUSH_TRANSITION_TIME_MS
val delayMs = totalTransitionDurationMs / 7
val slideWithoutDelayMs = totalTransitionDurationMs - delayMs
@@ -329,7 +174,7 @@ object RootTransitionProviders {
* Slides the current screen out to the end (right) of the screen towards the start (left).
*/
@Suppress("MagicNumber")
val pushToEnd: NonNullExitTransitionProvider = {
val pushToEnd: ExitTransitionProvider = {
val totalTransitionDurationMs = DEFAULT_PUSH_TRANSITION_TIME_MS
val delayMs = totalTransitionDurationMs / 7
val slideWithoutDelayMs = totalTransitionDurationMs - delayMs
@@ -351,10 +196,10 @@ object RootTransitionProviders {
/**
* Slides the current screen down to the bottom of the screen.
*/
val slideDown: NonNullExitTransitionProvider = {
val slideDown: ExitTransitionProvider = {
slideOutOfContainer(
towards = AnimatedContentTransitionScope.SlideDirection.Down,
animationSpec = tween(DEFAULT_SLIDE_TRANSITION_TIME_MS),
animationSpec = tween(durationMillis = DEFAULT_SLIDE_TRANSITION_TIME_MS),
)
}
@@ -363,11 +208,59 @@ object RootTransitionProviders {
* other standard transitions in order to leave the screen in place such that it does not
* immediately disappear while the other screen transitions into place.
*/
val stay: NonNullExitTransitionProvider = {
val stay: ExitTransitionProvider = {
fadeOut(
animationSpec = tween(DEFAULT_STAY_TRANSITION_TIME_MS),
animationSpec = tween(durationMillis = DEFAULT_STAY_TRANSITION_TIME_MS),
targetAlpha = 0.99f,
)
}
}
}
/**
* Legacy non-null "enter" transition provider used by Navigation Compose `NavHost`s.
*
* TODO: Remove once all modules have been migrated to Navigation3 (PM-45372).
*/
typealias NonNullEnterTransitionProvider =
(@JvmSuppressWildcards AnimatedContentTransitionScope<NavBackStackEntry>.() -> EnterTransition)
/**
* Legacy non-null "exit" transition provider used by Navigation Compose `NavHost`s.
*
* TODO: Remove once all modules have been migrated to Navigation3 (PM-45372).
*/
typealias NonNullExitTransitionProvider =
(@JvmSuppressWildcards AnimatedContentTransitionScope<NavBackStackEntry>.() -> ExitTransition)
/**
* Legacy transition providers used by root Navigation Compose `NavHost`s, which delegate to the
* standard [TransitionProviders].
*
* TODO: Remove once all modules have been migrated to Navigation3 (PM-45372).
*/
object RootTransitionProviders {
/**
* The standard set of "enter" transition providers.
*/
object Enter {
val fadeIn: EnterTransitionProvider = TransitionProviders.Enter.fadeIn
val none: EnterTransitionProvider = TransitionProviders.Enter.none
val pushToEnd: EnterTransitionProvider = TransitionProviders.Enter.pushToEnd
val pushToStart: EnterTransitionProvider = TransitionProviders.Enter.pushToStart
val slideUp: EnterTransitionProvider = TransitionProviders.Enter.slideUp
val stay: EnterTransitionProvider = TransitionProviders.Enter.stay
}
/**
* The standard set of "exit" transition providers.
*/
object Exit {
val fadeOut: ExitTransitionProvider = TransitionProviders.Exit.fadeOut
val none: ExitTransitionProvider = TransitionProviders.Exit.none
val pushToEnd: ExitTransitionProvider = TransitionProviders.Exit.pushToEnd
val pushToStart: ExitTransitionProvider = TransitionProviders.Exit.pushToStart
val slideDown: ExitTransitionProvider = TransitionProviders.Exit.slideDown
val stay: ExitTransitionProvider = TransitionProviders.Exit.stay
}
}
@@ -0,0 +1,327 @@
package com.bitwarden.ui.platform.navigation
import androidx.compose.runtime.snapshots.Snapshot
import androidx.navigation3.runtime.NavKey
import app.cash.turbine.test
import com.bitwarden.ui.platform.navigation.util.navigationControllerOf
import com.bitwarden.ui.platform.navigation.util.popUpTo
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertThrows
class NavigationControllerTest {
@Test
fun `navigate should add the route to the top of the back stack`() {
val navigationController = navigationControllerOf<TestGraph>(TestGraph.RouteA)
navigationController.navigate(route = TestGraph.RouteB)
assertEquals(listOf(TestGraph.RouteA, TestGraph.RouteB), navigationController.backStack)
}
@Test
fun `navigateBack should remove the top entry when there are multiple entries`() {
val navigationController = navigationControllerOf(TestGraph.RouteA, TestGraph.RouteB)
val result = navigationController.navigateBack()
assertTrue(result)
assertEquals(listOf(TestGraph.RouteA), navigationController.backStack)
}
@Test
fun `navigateBack should not remove the last remaining entry`() {
val navigationController = navigationControllerOf(TestGraph.RouteA)
val result = navigationController.navigateBack()
assertFalse(result)
assertEquals(listOf(TestGraph.RouteA), navigationController.backStack)
}
@Test
fun `addSingleTop should add the route when the top entry is a different type`() {
val navigationController = navigationControllerOf<TestGraph>(TestGraph.RouteA)
navigationController.addSingleTop(route = TestGraph.RouteB)
assertEquals(listOf(TestGraph.RouteA, TestGraph.RouteB), navigationController.backStack)
}
@Test
fun `addSingleTop should not add the route when the top entry is the same type`() {
val navigationController = navigationControllerOf(
TestGraph.RouteA,
TestGraph.RouteC(id = "1"),
)
navigationController.addSingleTop(TestGraph.RouteC(id = "2"))
assertEquals(
listOf(TestGraph.RouteA, TestGraph.RouteC(id = "1")),
navigationController.backStack,
)
}
@Test
fun `popUpTo should pop every entry above the most recent matching entry`() {
val navigationController = navigationControllerOf(
TestGraph.RouteA,
TestGraph.RouteB,
TestGraph.RouteC(id = "1"),
TestGraph.RouteB,
TestGraph.RouteC(id = "2"),
)
val result = navigationController.popUpTo<TestGraph.RouteB>()
assertTrue(result)
assertEquals(
listOf(
TestGraph.RouteA,
TestGraph.RouteB,
TestGraph.RouteC(id = "1"),
TestGraph.RouteB,
),
navigationController.backStack,
)
}
@Test
fun `popUpTo with inclusive should also pop the matching entry`() {
val navigationController = navigationControllerOf(
TestGraph.RouteA,
TestGraph.RouteB,
TestGraph.RouteC(id = "1"),
)
val result = navigationController.popUpTo<TestGraph.RouteB>(inclusive = true)
assertTrue(result)
assertEquals(listOf(TestGraph.RouteA), navigationController.backStack)
}
@Test
fun `popUpTo with inclusive should not pop the last remaining entry`() {
val navigationController = navigationControllerOf(TestGraph.RouteA, TestGraph.RouteB)
val result = navigationController.popUpTo<TestGraph.RouteA>(inclusive = true)
assertTrue(result)
assertEquals(listOf(TestGraph.RouteA), navigationController.backStack)
}
@Test
fun `popUpTo should do nothing when there is no matching entry`() {
val navigationController = navigationControllerOf(TestGraph.RouteA, TestGraph.RouteB)
val result = navigationController.popUpTo<TestGraph.RouteC>()
assertFalse(result)
assertEquals(listOf(TestGraph.RouteA, TestGraph.RouteB), navigationController.backStack)
}
@Test
fun `popUpTo with a predicate should pop every entry above the most recent match`() {
val navigationController = navigationControllerOf(
TestGraph.RouteA,
TestGraph.RouteC(id = "1"),
TestGraph.RouteC(id = "2"),
TestGraph.RouteB,
)
val result = navigationController.popUpTo { it == TestGraph.RouteC(id = "1") }
assertTrue(result)
assertEquals(
listOf(TestGraph.RouteA, TestGraph.RouteC(id = "1")),
navigationController.backStack,
)
}
@Test
fun `popToRoot should pop every entry except the root entry`() {
val navigationController = navigationControllerOf(
TestGraph.RouteA,
TestGraph.RouteB,
TestGraph.RouteC(id = "1"),
)
navigationController.popToRoot()
assertEquals(listOf(TestGraph.RouteA), navigationController.backStack)
}
@Test
fun `popToRoot should do nothing when only the root entry exists`() {
val navigationController = navigationControllerOf(TestGraph.RouteA)
navigationController.popToRoot()
assertEquals(listOf(TestGraph.RouteA), navigationController.backStack)
}
@Test
fun `navigatePoppingUpTo should pop above the matching entry before adding the route`() {
val navigationController = navigationControllerOf(
TestGraph.RouteA,
TestGraph.RouteB,
TestGraph.RouteC(id = "1"),
)
navigationController.navigatePoppingUpTo(route = TestGraph.RouteC(id = "2")) {
it is TestGraph.RouteB
}
assertEquals(
listOf(TestGraph.RouteA, TestGraph.RouteB, TestGraph.RouteC(id = "2")),
navigationController.backStack,
)
}
@Test
fun `navigatePoppingUpTo with inclusive should pop the matching entry before adding`() {
val navigationController = navigationControllerOf(
TestGraph.RouteA,
TestGraph.RouteB,
TestGraph.RouteC(id = "1"),
)
navigationController.navigatePoppingUpTo(
route = TestGraph.RouteC(id = "2"),
inclusive = true,
) { it is TestGraph.RouteB }
assertEquals(
listOf(TestGraph.RouteA, TestGraph.RouteC(id = "2")),
navigationController.backStack,
)
}
@Test
fun `navigatePoppingUpTo with inclusive should allow replacing the only entry`() {
val navigationController = navigationControllerOf<TestGraph>(TestGraph.RouteB)
navigationController.navigatePoppingUpTo(route = TestGraph.RouteA, inclusive = true) {
it is TestGraph.RouteB
}
assertEquals(listOf(TestGraph.RouteA), navigationController.backStack)
}
@Test
fun `navigatePoppingUpTo should add the route when there is no matching entry`() {
val navigationController = navigationControllerOf<TestGraph>(TestGraph.RouteA)
navigationController.navigatePoppingUpTo(route = TestGraph.RouteC(id = "1")) {
it is TestGraph.RouteB
}
assertEquals(
listOf(TestGraph.RouteA, TestGraph.RouteC(id = "1")),
navigationController.backStack,
)
}
@Test
fun `replaceAll should replace the entire back stack`() {
val navigationController = navigationControllerOf(TestGraph.RouteA, TestGraph.RouteB)
navigationController.replaceAll(TestGraph.RouteC(id = "1"), TestGraph.RouteB)
assertEquals(
listOf(TestGraph.RouteC(id = "1"), TestGraph.RouteB),
navigationController.backStack,
)
}
@Test
fun `replaceAll with a list should replace the entire back stack`() {
val navigationController = navigationControllerOf(TestGraph.RouteA, TestGraph.RouteB)
navigationController.replaceAll(routes = listOf(TestGraph.RouteC(id = "1")))
assertEquals(listOf(TestGraph.RouteC(id = "1")), navigationController.backStack)
}
@Test
fun `replaceAll should throw when the given routes are empty`() {
val navigationController = navigationControllerOf(TestGraph.RouteA)
assertThrows<IllegalArgumentException> {
navigationController.replaceAll(routes = emptyList())
}
assertEquals(listOf(TestGraph.RouteA), navigationController.backStack)
}
@Test
fun `navigateToTopLevelRoute should retain the start route beneath the given route`() {
val navigationController = navigationControllerOf(TestGraph.RouteA, TestGraph.RouteB)
navigationController.navigateToTopLevelRoute(route = TestGraph.RouteC(id = "1"))
assertEquals(
listOf(TestGraph.RouteA, TestGraph.RouteC(id = "1")),
navigationController.backStack,
)
}
@Test
fun `navigateToTopLevelRoute should only contain the start route when navigating to it`() {
val navigationController = navigationControllerOf(TestGraph.RouteA, TestGraph.RouteB)
navigationController.navigateToTopLevelRoute(route = TestGraph.RouteA)
assertEquals(listOf(TestGraph.RouteA), navigationController.backStack)
}
@Test
fun `replaceLast should replace the top entry of the back stack`() {
val navigationController = navigationControllerOf(TestGraph.RouteA, TestGraph.RouteB)
navigationController.replaceLast(route = TestGraph.RouteC(id = "1"))
assertEquals(
listOf(TestGraph.RouteA, TestGraph.RouteC(id = "1")),
navigationController.backStack,
)
}
@Test
fun `replaceLast should replace the only entry of the back stack`() {
val navigationController = navigationControllerOf<TestGraph>(TestGraph.RouteA)
navigationController.replaceLast(route = TestGraph.RouteB)
assertEquals(listOf(TestGraph.RouteB), navigationController.backStack)
}
@Test
fun `backStackSnapshot should emit the current back stack and any changes to it`() =
runTest {
val navigationController = navigationControllerOf<TestGraph>(TestGraph.RouteA)
navigationController.backStackSnapshot.test {
assertEquals(listOf(TestGraph.RouteA), awaitItem())
navigationController.navigate(route = TestGraph.RouteB)
Snapshot.sendApplyNotifications()
assertEquals(listOf(TestGraph.RouteA, TestGraph.RouteB), awaitItem())
navigationController.replaceAll(TestGraph.RouteC(id = "1"))
assertEquals(listOf(TestGraph.RouteC(id = "1")), awaitItem())
}
}
}
private sealed class TestGraph : NavKey {
data object RouteA : TestGraph()
data object RouteB : TestGraph()
data class RouteC(val id: String) : TestGraph()
}