mirror of
https://github.com/bitwarden/android.git
synced 2026-11-09 10:34:18 -06:00
[PM-45372] feat: Add Navigation3 base classes and tools to the UI module (#7507)
This commit is contained in:
+18
-3
@@ -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
|
||||
}
|
||||
}
|
||||
+38
@@ -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,
|
||||
)
|
||||
}
|
||||
+42
@@ -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,
|
||||
)
|
||||
}
|
||||
+85
@@ -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)
|
||||
}
|
||||
}
|
||||
+115
@@ -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()
|
||||
}
|
||||
Reference in New Issue
Block a user