From 122089eb1e1e045d75e8acfa958251ce3433fc71 Mon Sep 17 00:00:00 2001 From: Vladimir Dostoyevskiy <99822064+VDostoyevskiy@users.noreply.github.com> Date: Sat, 25 Nov 2023 14:11:00 +0800 Subject: [PATCH] Predictive Back for NavHost (#335) * Grab navigation-compose * Initial SeekableTransitions * Refine * Fixes * Fix anim again * Easeout Predictive Back --------- Co-authored-by: GG Bond --- app/build.gradle.kts | 2 + .../compose/ComposeNavGraphNavigator.kt | 63 +++ .../navigation/compose/ComposeNavigator.kt | 128 ++++++ .../androidx/navigation/compose/DialogHost.kt | 137 ++++++ .../navigation/compose/DialogNavigator.kt | 96 ++++ .../compose/NavBackStackEntryProvider.kt | 88 ++++ .../navigation/compose/NavGraphBuilder.kt | 243 +++++++++++ .../androidx/navigation/compose/NavHost.kt | 412 ++++++++++++++++++ .../navigation/compose/NavHostController.kt | 86 ++++ gradle/libs.versions.toml | 1 + 10 files changed, 1256 insertions(+) create mode 100644 app/src/main/kotlin/androidx/navigation/compose/ComposeNavGraphNavigator.kt create mode 100644 app/src/main/kotlin/androidx/navigation/compose/ComposeNavigator.kt create mode 100644 app/src/main/kotlin/androidx/navigation/compose/DialogHost.kt create mode 100644 app/src/main/kotlin/androidx/navigation/compose/DialogNavigator.kt create mode 100644 app/src/main/kotlin/androidx/navigation/compose/NavBackStackEntryProvider.kt create mode 100644 app/src/main/kotlin/androidx/navigation/compose/NavGraphBuilder.kt create mode 100644 app/src/main/kotlin/androidx/navigation/compose/NavHost.kt create mode 100644 app/src/main/kotlin/androidx/navigation/compose/NavHostController.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 7b956eeb3..a7a0d3cb5 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -217,6 +217,7 @@ dependencies { implementation(libs.androidx.fragment) // https://developer.android.com/jetpack/androidx/releases/lifecycle implementation(libs.androidx.lifecycle.process) + implementation(libs.androidx.lifecycle.compose) // https://developer.android.com/jetpack/androidx/releases/navigation implementation(libs.bundles.androidx.navigation) @@ -281,6 +282,7 @@ configurations.all { // Workaround IME won't show again once hidden. // Google issue-tracker link? force("androidx.compose.ui:ui-text-android:1.6.0-alpha08") + exclude("androidx.navigation", "navigation-compose") } } diff --git a/app/src/main/kotlin/androidx/navigation/compose/ComposeNavGraphNavigator.kt b/app/src/main/kotlin/androidx/navigation/compose/ComposeNavGraphNavigator.kt new file mode 100644 index 000000000..e5feb9556 --- /dev/null +++ b/app/src/main/kotlin/androidx/navigation/compose/ComposeNavGraphNavigator.kt @@ -0,0 +1,63 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package androidx.navigation.compose + +import androidx.compose.animation.AnimatedContentTransitionScope +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.navigation.NavBackStackEntry +import androidx.navigation.NavGraph +import androidx.navigation.NavGraphNavigator +import androidx.navigation.Navigator +import androidx.navigation.NavigatorProvider + +/** + * Custom subclass of [NavGraphNavigator] that adds support for defining + * transitions at the navigation graph level. + */ +@Navigator.Name("navigation") +internal class ComposeNavGraphNavigator( + navigatorProvider: NavigatorProvider, +) : NavGraphNavigator(navigatorProvider) { + override fun createDestination(): NavGraph { + return ComposeNavGraph(this) + } + + internal class ComposeNavGraph( + navGraphNavigator: Navigator, + ) : NavGraph(navGraphNavigator) { + internal var enterTransition: ( + @JvmSuppressWildcards + AnimatedContentTransitionScope.() -> EnterTransition? + )? = null + + internal var exitTransition: ( + @JvmSuppressWildcards + AnimatedContentTransitionScope.() -> ExitTransition? + )? = null + + internal var popEnterTransition: ( + @JvmSuppressWildcards + AnimatedContentTransitionScope.() -> EnterTransition? + )? = null + + internal var popExitTransition: ( + @JvmSuppressWildcards + AnimatedContentTransitionScope.() -> ExitTransition? + )? = null + } +} diff --git a/app/src/main/kotlin/androidx/navigation/compose/ComposeNavigator.kt b/app/src/main/kotlin/androidx/navigation/compose/ComposeNavigator.kt new file mode 100644 index 000000000..a753204b1 --- /dev/null +++ b/app/src/main/kotlin/androidx/navigation/compose/ComposeNavigator.kt @@ -0,0 +1,128 @@ +/* + * Copyright 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package androidx.navigation.compose + +import androidx.compose.animation.AnimatedContentScope +import androidx.compose.animation.AnimatedContentTransitionScope +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.navigation.NavBackStackEntry +import androidx.navigation.NavDestination +import androidx.navigation.NavOptions +import androidx.navigation.Navigator +import androidx.navigation.compose.ComposeNavigator.Destination + +/** + * Navigator that navigates through [Composable]s. Every destination using this Navigator must + * set a valid [Composable] by setting it directly on an instantiated [Destination] or calling + * [composable]. + */ +@Navigator.Name("composable") +public class ComposeNavigator : Navigator() { + + /** + * Get the map of transitions currently in progress from the [state]. + */ + internal val transitionsInProgress get() = state.transitionsInProgress + + /** + * Get the back stack from the [state]. + */ + public val backStack get() = state.backStack + + internal val isPop = mutableStateOf(false) + + override fun navigate( + entries: List, + navOptions: NavOptions?, + navigatorExtras: Extras?, + ) { + entries.forEach { entry -> + state.pushWithTransition(entry) + } + isPop.value = false + } + + override fun createDestination(): Destination { + return Destination(this) { } + } + + override fun popBackStack(popUpTo: NavBackStackEntry, savedState: Boolean) { + state.popWithTransition(popUpTo, savedState) + isPop.value = true + } + + /** + * Callback to mark a navigation in transition as complete. + * + * This should be called in conjunction with [navigate] and [popBackStack] as those + * calls merely start a transition to the target destination, and requires manually marking + * the transition as complete by calling this method. + * + * Failing to call this method could result in entries being prevented from reaching their + * final [Lifecycle.State]. + */ + public fun onTransitionComplete(entry: NavBackStackEntry) { + state.markTransitionComplete(entry) + } + + /** + * NavDestination specific to [ComposeNavigator] + */ + @NavDestination.ClassType(Composable::class) + public class Destination( + navigator: ComposeNavigator, + internal val content: + @Composable AnimatedContentScope.(@JvmSuppressWildcards NavBackStackEntry) -> Unit, + ) : NavDestination(navigator) { + + @Deprecated( + message = "Deprecated in favor of Destination that supports AnimatedContent", + level = DeprecationLevel.HIDDEN, + ) + constructor( + navigator: ComposeNavigator, + content: @Composable (NavBackStackEntry) -> @JvmSuppressWildcards Unit, + ) : this(navigator, content = { entry -> content(entry) }) + + internal var enterTransition: ( + @JvmSuppressWildcards + AnimatedContentTransitionScope.() -> EnterTransition? + )? = null + + internal var exitTransition: ( + @JvmSuppressWildcards + AnimatedContentTransitionScope.() -> ExitTransition? + )? = null + + internal var popEnterTransition: ( + @JvmSuppressWildcards + AnimatedContentTransitionScope.() -> EnterTransition? + )? = null + + internal var popExitTransition: ( + @JvmSuppressWildcards + AnimatedContentTransitionScope.() -> ExitTransition? + )? = null + } + + internal companion object { + internal const val NAME = "composable" + } +} diff --git a/app/src/main/kotlin/androidx/navigation/compose/DialogHost.kt b/app/src/main/kotlin/androidx/navigation/compose/DialogHost.kt new file mode 100644 index 000000000..2c552e9d9 --- /dev/null +++ b/app/src/main/kotlin/androidx/navigation/compose/DialogHost.kt @@ -0,0 +1,137 @@ +/* + * Copyright 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package androidx.navigation.compose + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveableStateHolder +import androidx.compose.runtime.snapshots.SnapshotStateList +import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.window.Dialog +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.navigation.NavBackStackEntry +import androidx.navigation.compose.DialogNavigator.Destination + +/** + * Show each [Destination] on the [DialogNavigator]'s back stack as a [Dialog]. + * + * Note that [NavHost] will call this for you; you do not need to call it manually. + */ +@Composable +public fun DialogHost(dialogNavigator: DialogNavigator) { + val saveableStateHolder = rememberSaveableStateHolder() + val dialogBackStack by dialogNavigator.backStack.collectAsState() + val visibleBackStack = rememberVisibleList(dialogBackStack) + visibleBackStack.PopulateVisibleList(dialogBackStack) + + val transitionInProgress by dialogNavigator.transitionInProgress.collectAsState() + val dialogsToDispose = remember { mutableStateListOf() } + + visibleBackStack.forEach { backStackEntry -> + val destination = backStackEntry.destination as Destination + Dialog( + onDismissRequest = { dialogNavigator.dismiss(backStackEntry) }, + properties = destination.dialogProperties, + ) { + DisposableEffect(backStackEntry) { + dialogsToDispose.add(backStackEntry) + onDispose { + dialogNavigator.onTransitionComplete(backStackEntry) + dialogsToDispose.remove(backStackEntry) + } + } + + // while in the scope of the composable, we provide the navBackStackEntry as the + // ViewModelStoreOwner and LifecycleOwner + backStackEntry.LocalOwnersProvider(saveableStateHolder) { + destination.content(backStackEntry) + } + } + } + // Dialogs may have been popped before it was composed. To prevent leakage, we need to + // mark popped entries as complete here. Check that we don't accidentally complete popped + // entries that were composed, unless they were disposed of already. + LaunchedEffect(transitionInProgress, dialogsToDispose) { + transitionInProgress.forEach { entry -> + if (!dialogNavigator.backStack.value.contains(entry) && + !dialogsToDispose.contains(entry) + ) { + dialogNavigator.onTransitionComplete(entry) + } + } + } +} + +@Composable +internal fun MutableList.PopulateVisibleList( + backStack: Collection, +) { + val isInspecting = LocalInspectionMode.current + backStack.forEach { entry -> + DisposableEffect(entry.lifecycle) { + val observer = LifecycleEventObserver { _, event -> + // show dialog in preview + if (isInspecting && !contains(entry)) { + add(entry) + } + // ON_START -> add to visibleBackStack, ON_STOP -> remove from visibleBackStack + if (event == Lifecycle.Event.ON_START) { + // We want to treat the visible lists as Sets but we want to keep + // the functionality of mutableStateListOf() so that we recompose in response + // to adds and removes. + if (!contains(entry)) { + add(entry) + } + } + if (event == Lifecycle.Event.ON_STOP) { + remove(entry) + } + } + entry.lifecycle.addObserver(observer) + onDispose { + entry.lifecycle.removeObserver(observer) + } + } + } +} + +@Composable +internal fun rememberVisibleList( + backStack: Collection, +): SnapshotStateList { + // show dialog in preview + val isInspecting = LocalInspectionMode.current + return remember(backStack) { + mutableStateListOf().also { + it.addAll( + backStack.filter { entry -> + if (isInspecting) { + true + } else { + entry.lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED) + } + }, + ) + } + } +} diff --git a/app/src/main/kotlin/androidx/navigation/compose/DialogNavigator.kt b/app/src/main/kotlin/androidx/navigation/compose/DialogNavigator.kt new file mode 100644 index 000000000..127bfb9a0 --- /dev/null +++ b/app/src/main/kotlin/androidx/navigation/compose/DialogNavigator.kt @@ -0,0 +1,96 @@ +/* + * Copyright 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package androidx.navigation.compose + +import androidx.compose.runtime.Composable +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import androidx.navigation.FloatingWindow +import androidx.navigation.NavBackStackEntry +import androidx.navigation.NavDestination +import androidx.navigation.NavOptions +import androidx.navigation.Navigator +import androidx.navigation.compose.DialogNavigator.Destination + +/** + * Navigator that navigates through [Composable]s that will be hosted within a + * [Dialog]. Every destination using this Navigator must set a valid [Composable] by setting it + * directly on an instantiated [Destination] or calling [dialog]. + */ +@Navigator.Name("dialog") +public class DialogNavigator : Navigator() { + + /** + * Get the back stack from the [state]. + */ + internal val backStack get() = state.backStack + + /** + * Get the transitioning dialogs from the [state]. + */ + internal val transitionInProgress get() = state.transitionsInProgress + + /** + * Dismiss the dialog destination associated with the given [backStackEntry]. + */ + internal fun dismiss(backStackEntry: NavBackStackEntry) { + popBackStack(backStackEntry, false) + } + + override fun navigate( + entries: List, + navOptions: NavOptions?, + navigatorExtras: Extras?, + ) { + entries.forEach { entry -> + state.push(entry) + } + } + + override fun createDestination(): Destination { + return Destination(this) { } + } + + override fun popBackStack(popUpTo: NavBackStackEntry, savedState: Boolean) { + state.popWithTransition(popUpTo, savedState) + // When popping, the incoming dialog is marked transitioning to hold it in + // STARTED. With pop complete, we can remove it from transition so it can move to RESUMED. + val popIndex = state.transitionsInProgress.value.indexOf(popUpTo) + // do not mark complete for entries up to and including popUpTo + state.transitionsInProgress.value.forEachIndexed { index, entry -> + if (index > popIndex) onTransitionComplete(entry) + } + } + + internal fun onTransitionComplete(entry: NavBackStackEntry) { + state.markTransitionComplete(entry) + } + + /** + * NavDestination specific to [DialogNavigator] + */ + @NavDestination.ClassType(Composable::class) + public class Destination( + navigator: DialogNavigator, + internal val dialogProperties: DialogProperties = DialogProperties(), + internal val content: @Composable (NavBackStackEntry) -> Unit, + ) : NavDestination(navigator), FloatingWindow + + internal companion object { + internal const val NAME = "dialog" + } +} diff --git a/app/src/main/kotlin/androidx/navigation/compose/NavBackStackEntryProvider.kt b/app/src/main/kotlin/androidx/navigation/compose/NavBackStackEntryProvider.kt new file mode 100644 index 000000000..20e2fd229 --- /dev/null +++ b/app/src/main/kotlin/androidx/navigation/compose/NavBackStackEntryProvider.kt @@ -0,0 +1,88 @@ +/* + * Copyright 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:Suppress("ktlint:standard:property-naming", "ktlint:standard:function-naming") + +package androidx.navigation.compose + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.saveable.SaveableStateHolder +import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.compose.ui.platform.LocalSavedStateRegistryOwner +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation.NavBackStackEntry +import java.lang.ref.WeakReference +import java.util.UUID + +/** + * Provides [this] [NavBackStackEntry] as [LocalViewModelStoreOwner], [LocalLifecycleOwner] and + * [LocalSavedStateRegistryOwner] to the [content] and saves the [content]'s saveable states with + * the given [saveableStateHolder]. + * + * @param saveableStateHolder The [SaveableStateHolder] that holds the saved states. The same + * holder should be used for all [NavBackStackEntry]s in the encapsulating [Composable] and the + * holder should be hoisted. + * @param content The content [Composable] + */ +@Composable +public fun NavBackStackEntry.LocalOwnersProvider( + saveableStateHolder: SaveableStateHolder, + content: @Composable () -> Unit, +) { + CompositionLocalProvider( + LocalViewModelStoreOwner provides this, + LocalLifecycleOwner provides this, + LocalSavedStateRegistryOwner provides this, + ) { + saveableStateHolder.SaveableStateProvider(content) + } +} + +@Composable +private fun SaveableStateHolder.SaveableStateProvider(content: @Composable () -> Unit) { + val viewModel = viewModel() + // Stash a reference to the SaveableStateHolder in the ViewModel so that + // it is available when the ViewModel is cleared, marking the permanent removal of this + // NavBackStackEntry from the back stack. Which, because of animations, + // only happens after this leaves composition. Which means we can't rely on + // DisposableEffect to clean up this reference (as it'll be cleaned up too early) + viewModel.saveableStateHolderRef = WeakReference(this) + SaveableStateProvider(viewModel.id, content) +} + +internal class BackStackEntryIdViewModel(handle: SavedStateHandle) : ViewModel() { + + private val IdKey = "SaveableStateHolder_BackStackEntryKey" + + // we create our own id for each back stack entry to support multiple entries of the same + // destination. this id will be restored by SavedStateHandle + val id: UUID = handle.get(IdKey) ?: UUID.randomUUID().also { handle.set(IdKey, it) } + + lateinit var saveableStateHolderRef: WeakReference + + // onCleared will be called on the entries removed from the back stack. here we notify + // SaveableStateProvider that we should remove any state is had associated with this + // destination as it is no longer needed. + override fun onCleared() { + super.onCleared() + saveableStateHolderRef.get()?.removeState(id) + saveableStateHolderRef.clear() + } +} diff --git a/app/src/main/kotlin/androidx/navigation/compose/NavGraphBuilder.kt b/app/src/main/kotlin/androidx/navigation/compose/NavGraphBuilder.kt new file mode 100644 index 000000000..300ecf6b5 --- /dev/null +++ b/app/src/main/kotlin/androidx/navigation/compose/NavGraphBuilder.kt @@ -0,0 +1,243 @@ +/* + * Copyright 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package androidx.navigation.compose + +import androidx.compose.animation.AnimatedContentScope +import androidx.compose.animation.AnimatedContentTransitionScope +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.runtime.Composable +import androidx.compose.ui.window.DialogProperties +import androidx.navigation.NamedNavArgument +import androidx.navigation.NavBackStackEntry +import androidx.navigation.NavDeepLink +import androidx.navigation.NavGraph +import androidx.navigation.NavGraphBuilder +import androidx.navigation.get + +/** + * Add the [Composable] to the [NavGraphBuilder] + * + * @param route route for the destination + * @param arguments list of arguments to associate with destination + * @param deepLinks list of deep links to associate with the destinations + * @param content composable for the destination + */ +@Deprecated( + message = "Deprecated in favor of composable builder that supports AnimatedContent", + level = DeprecationLevel.HIDDEN, +) +public fun NavGraphBuilder.composable( + route: String, + arguments: List = emptyList(), + deepLinks: List = emptyList(), + content: @Composable (NavBackStackEntry) -> Unit, +) { + addDestination( + ComposeNavigator.Destination(provider[ComposeNavigator::class]) { + entry -> + content(entry) + }.apply { + this.route = route + arguments.forEach { (argumentName, argument) -> + addArgument(argumentName, argument) + } + deepLinks.forEach { deepLink -> + addDeepLink(deepLink) + } + }, + ) +} + +/** + * Add the [Composable] to the [NavGraphBuilder] + * + * @param route route for the destination + * @param arguments list of arguments to associate with destination + * @param deepLinks list of deep links to associate with the destinations + * @param enterTransition callback to determine the destination's enter transition + * @param exitTransition callback to determine the destination's exit transition + * @param popEnterTransition callback to determine the destination's popEnter transition + * @param popExitTransition callback to determine the destination's popExit transition + * @param content composable for the destination + */ +public fun NavGraphBuilder.composable( + route: String, + arguments: List = emptyList(), + deepLinks: List = emptyList(), + enterTransition: ( + @JvmSuppressWildcards + AnimatedContentTransitionScope.() -> EnterTransition? + )? = null, + exitTransition: ( + @JvmSuppressWildcards + AnimatedContentTransitionScope.() -> ExitTransition? + )? = null, + popEnterTransition: ( + @JvmSuppressWildcards + AnimatedContentTransitionScope.() -> EnterTransition? + )? = + enterTransition, + popExitTransition: ( + @JvmSuppressWildcards + AnimatedContentTransitionScope.() -> ExitTransition? + )? = + exitTransition, + content: @Composable AnimatedContentScope.(NavBackStackEntry) -> Unit, +) { + addDestination( + ComposeNavigator.Destination( + provider[ComposeNavigator::class], + content, + ).apply { + this.route = route + arguments.forEach { (argumentName, argument) -> + addArgument(argumentName, argument) + } + deepLinks.forEach { deepLink -> + addDeepLink(deepLink) + } + this.enterTransition = enterTransition + this.exitTransition = exitTransition + this.popEnterTransition = popEnterTransition + this.popExitTransition = popExitTransition + }, + ) +} + +/** + * Construct a nested [NavGraph] + * + * @sample androidx.navigation.compose.samples.NestedNavInGraphWithArgs + * + * @param startDestination the starting destination's route for this NavGraph + * @param route the destination's unique route + * @param arguments list of arguments to associate with destination + * @param deepLinks list of deep links to associate with the destinations + * @param builder the builder used to construct the graph + */ +@Deprecated( + message = "Deprecated in favor of navigation builder that supports AnimatedContent", + level = DeprecationLevel.HIDDEN, +) +public fun NavGraphBuilder.navigation( + startDestination: String, + route: String, + arguments: List = emptyList(), + deepLinks: List = emptyList(), + builder: NavGraphBuilder.() -> Unit, +) { + addDestination( + NavGraphBuilder(provider, startDestination, route).apply(builder).build().apply { + arguments.forEach { (argumentName, argument) -> + addArgument(argumentName, argument) + } + deepLinks.forEach { deepLink -> + addDeepLink(deepLink) + } + }, + ) +} + +/** + * Construct a nested [NavGraph] + * + * @param startDestination the starting destination's route for this NavGraph + * @param route the destination's unique route + * @param arguments list of arguments to associate with destination + * @param deepLinks list of deep links to associate with the destinations + * @param enterTransition callback to define enter transitions for destination in this NavGraph + * @param exitTransition callback to define exit transitions for destination in this NavGraph + * @param popEnterTransition callback to define pop enter transitions for destination in this + * NavGraph + * @param popExitTransition callback to define pop exit transitions for destination in this NavGraph + * @param builder the builder used to construct the graph + * + * @return the newly constructed nested NavGraph + */ +public fun NavGraphBuilder.navigation( + startDestination: String, + route: String, + arguments: List = emptyList(), + deepLinks: List = emptyList(), + enterTransition: (AnimatedContentTransitionScope.() -> EnterTransition?)? = + null, + exitTransition: (AnimatedContentTransitionScope.() -> ExitTransition?)? = + null, + popEnterTransition: ( + AnimatedContentTransitionScope.() -> EnterTransition? + )? = enterTransition, + popExitTransition: ( + AnimatedContentTransitionScope.() -> ExitTransition? + )? = exitTransition, + builder: NavGraphBuilder.() -> Unit, +) { + addDestination( + NavGraphBuilder(provider, startDestination, route).apply(builder).build().apply { + arguments.forEach { (argumentName, argument) -> + addArgument(argumentName, argument) + } + deepLinks.forEach { deepLink -> + addDeepLink(deepLink) + } + if (this is ComposeNavGraphNavigator.ComposeNavGraph) { + this.enterTransition = enterTransition + this.exitTransition = exitTransition + this.popEnterTransition = popEnterTransition + this.popExitTransition = popExitTransition + } + }, + ) +} + +/** + * Add the [Composable] to the [NavGraphBuilder] that will be hosted within a + * [androidx.compose.ui.window.Dialog]. This is suitable only when this dialog represents + * a separate screen in your app that needs its own lifecycle and saved state, independent + * of any other destination in your navigation graph. For use cases such as `AlertDialog`, + * you should use those APIs directly in the [composable] destination that wants to show that + * dialog. + * + * @param route route for the destination + * @param arguments list of arguments to associate with destination + * @param deepLinks list of deep links to associate with the destinations + * @param dialogProperties properties that should be passed to [androidx.compose.ui.window.Dialog]. + * @param content composable content for the destination that will be hosted within the Dialog + */ +public fun NavGraphBuilder.dialog( + route: String, + arguments: List = emptyList(), + deepLinks: List = emptyList(), + dialogProperties: DialogProperties = DialogProperties(), + content: @Composable (NavBackStackEntry) -> Unit, +) { + addDestination( + DialogNavigator.Destination( + provider[DialogNavigator::class], + dialogProperties, + content, + ).apply { + this.route = route + arguments.forEach { (argumentName, argument) -> + addArgument(argumentName, argument) + } + deepLinks.forEach { deepLink -> + addDeepLink(deepLink) + } + }, + ) +} diff --git a/app/src/main/kotlin/androidx/navigation/compose/NavHost.kt b/app/src/main/kotlin/androidx/navigation/compose/NavHost.kt new file mode 100644 index 000000000..d1f307d12 --- /dev/null +++ b/app/src/main/kotlin/androidx/navigation/compose/NavHost.kt @@ -0,0 +1,412 @@ +/* + * Copyright 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package androidx.navigation.compose + +import android.annotation.SuppressLint +import androidx.activity.compose.PredictiveBackHandler +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedContentTransitionScope +import androidx.compose.animation.ContentTransform +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.EaseOut +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.MutableTransitionState +import androidx.compose.animation.core.tween +import androidx.compose.animation.core.updateTransition +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveableStateHolder +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner +import androidx.navigation.NavBackStackEntry +import androidx.navigation.NavDestination +import androidx.navigation.NavDestination.Companion.hierarchy +import androidx.navigation.NavGraph +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavHostController +import androidx.navigation.Navigator +import androidx.navigation.createGraph +import androidx.navigation.get +import kotlin.math.roundToLong +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.withContext + +/** + * Provides in place in the Compose hierarchy for self contained navigation to occur. + * + * Once this is called, any Composable within the given [NavGraphBuilder] can be navigated to from + * the provided [navController]. + * + * The builder passed into this method is [remember]ed. This means that for this NavHost, the + * contents of the builder cannot be changed. + * + * @sample androidx.navigation.compose.samples.NavScaffold + * + * @param navController the navController for this host + * @param startDestination the route for the start destination + * @param modifier The modifier to be applied to the layout. + * @param route the route for the graph + * @param builder the builder used to construct the graph + */ +@Deprecated( + message = "Deprecated in favor of NavHost that supports AnimatedContent", + level = DeprecationLevel.HIDDEN, +) +@Composable +public fun NavHost( + navController: NavHostController, + startDestination: String, + modifier: Modifier = Modifier, + route: String? = null, + builder: NavGraphBuilder.() -> Unit, +) { + NavHost( + navController, + remember(route, startDestination, builder) { + navController.createGraph(startDestination, route, builder) + }, + modifier, + ) +} + +/** + * Provides in place in the Compose hierarchy for self contained navigation to occur. + * + * Once this is called, any Composable within the given [NavGraphBuilder] can be navigated to from + * the provided [navController]. + * + * The builder passed into this method is [remember]ed. This means that for this NavHost, the + * contents of the builder cannot be changed. + * + * @param navController the navController for this host + * @param startDestination the route for the start destination + * @param modifier The modifier to be applied to the layout. + * @param contentAlignment The [Alignment] of the [AnimatedContent] + * @param route the route for the graph + * @param enterTransition callback to define enter transitions for destination in this host + * @param exitTransition callback to define exit transitions for destination in this host + * @param popEnterTransition callback to define popEnter transitions for destination in this host + * @param popExitTransition callback to define popExit transitions for destination in this host + * @param builder the builder used to construct the graph + */ +@Composable +public fun NavHost( + navController: NavHostController, + startDestination: String, + modifier: Modifier = Modifier, + contentAlignment: Alignment = Alignment.Center, + route: String? = null, + enterTransition: (AnimatedContentTransitionScope.() -> EnterTransition) = + { fadeIn(animationSpec = tween(700)) }, + exitTransition: (AnimatedContentTransitionScope.() -> ExitTransition) = + { fadeOut(animationSpec = tween(700)) }, + popEnterTransition: (AnimatedContentTransitionScope.() -> EnterTransition) = + enterTransition, + popExitTransition: (AnimatedContentTransitionScope.() -> ExitTransition) = + exitTransition, + builder: NavGraphBuilder.() -> Unit, +) { + NavHost( + navController, + remember(route, startDestination, builder) { + navController.createGraph(startDestination, route, builder) + }, + modifier, + contentAlignment, + enterTransition, + exitTransition, + popEnterTransition, + popExitTransition, + ) +} + +/** + * Provides in place in the Compose hierarchy for self contained navigation to occur. + * + * Once this is called, any Composable within the given [NavGraphBuilder] can be navigated to from + * the provided [navController]. + * + * The graph passed into this method is [remember]ed. This means that for this NavHost, the graph + * cannot be changed. + * + * @param navController the navController for this host + * @param graph the graph for this host + * @param modifier The modifier to be applied to the layout. + */ +@Deprecated( + message = "Deprecated in favor of NavHost that supports AnimatedContent", + level = DeprecationLevel.HIDDEN, +) +@Composable +public fun NavHost( + navController: NavHostController, + graph: NavGraph, + modifier: Modifier = Modifier, +) = NavHost(navController, graph, modifier) + +/** + * Provides in place in the Compose hierarchy for self contained navigation to occur. + * + * Once this is called, any Composable within the given [NavGraphBuilder] can be navigated to from + * the provided [navController]. + * + * @param navController the navController for this host + * @param graph the graph for this host + * @param modifier The modifier to be applied to the layout. + * @param contentAlignment The [Alignment] of the [AnimatedContent] + * @param enterTransition callback to define enter transitions for destination in this host + * @param exitTransition callback to define exit transitions for destination in this host + * @param popEnterTransition callback to define popEnter transitions for destination in this host + * @param popExitTransition callback to define popExit transitions for destination in this host + */ +@SuppressLint("StateFlowValueCalledInComposition") +@Composable +public fun NavHost( + navController: NavHostController, + graph: NavGraph, + modifier: Modifier = Modifier, + contentAlignment: Alignment = Alignment.Center, + enterTransition: (AnimatedContentTransitionScope.() -> EnterTransition) = + { fadeIn(animationSpec = tween(700)) }, + exitTransition: (AnimatedContentTransitionScope.() -> ExitTransition) = + { fadeOut(animationSpec = tween(700)) }, + popEnterTransition: (AnimatedContentTransitionScope.() -> EnterTransition) = + enterTransition, + popExitTransition: (AnimatedContentTransitionScope.() -> ExitTransition) = + exitTransition, +) { + val lifecycleOwner = LocalLifecycleOwner.current + val viewModelStoreOwner = checkNotNull(LocalViewModelStoreOwner.current) { + "NavHost requires a ViewModelStoreOwner to be provided via LocalViewModelStoreOwner" + } + + navController.setViewModelStore(viewModelStoreOwner.viewModelStore) + + // Then set the graph + navController.graph = graph + + // Find the ComposeNavigator, returning early if it isn't found + // (such as is the case when using TestNavHostController) + val composeNavigator = navController.navigatorProvider.get>( + ComposeNavigator.NAME, + ) as? ComposeNavigator ?: return + + val currentBackStack by composeNavigator.backStack.collectAsState() + + DisposableEffect(lifecycleOwner) { + // Setup the navController with proper owners + navController.setLifecycleOwner(lifecycleOwner) + onDispose { } + } + + val saveableStateHolder = rememberSaveableStateHolder() + + val allVisibleEntries by navController.visibleEntries.collectAsState() + + // Intercept back only when there's a destination to pop + val visibleEntries by remember { + derivedStateOf { + allVisibleEntries.filter { entry -> + entry.destination.navigatorName == ComposeNavigator.NAME + } + } + } + + val backStackEntry: NavBackStackEntry? = visibleEntries.lastOrNull() + + val zIndices = remember { mutableMapOf() } + + if (backStackEntry != null) { + val finalEnter: AnimatedContentTransitionScope.() -> EnterTransition = { + val targetDestination = targetState.destination as ComposeNavigator.Destination + + if (composeNavigator.isPop.value) { + targetDestination.hierarchy.firstNotNullOfOrNull { destination -> + destination.createPopEnterTransition(this) + } ?: popEnterTransition.invoke(this) + } else { + targetDestination.hierarchy.firstNotNullOfOrNull { destination -> + destination.createEnterTransition(this) + } ?: enterTransition.invoke(this) + } + } + + val finalExit: AnimatedContentTransitionScope.() -> ExitTransition = { + val initialDestination = initialState.destination as ComposeNavigator.Destination + + if (composeNavigator.isPop.value) { + initialDestination.hierarchy.firstNotNullOfOrNull { destination -> + destination.createPopExitTransition(this) + } ?: popExitTransition.invoke(this) + } else { + initialDestination.hierarchy.firstNotNullOfOrNull { destination -> + destination.createExitTransition(this) + } ?: exitTransition.invoke(this) + } + } + + DisposableEffect(true) { + onDispose { + visibleEntries.forEach { entry -> + composeNavigator.onTransitionComplete(entry) + } + } + } + + val duration = 500000000L + val transitionState = remember { MutableTransitionState(backStackEntry) } + val transition = updateTransition(transitionState = transitionState, label = "entry") + val animatedFraction = remember { Animatable(0f).also { it.updateBounds(lowerBound = 0f, upperBound = 1f) } } + + fun seekToFraction(lState: NavBackStackEntry, rState: NavBackStackEntry) { + val fraction = animatedFraction.value + val playTimeNanos = (fraction * duration).roundToLong() + transition.setPlaytimeAfterInitialAndTargetStateEstablished(lState, rState, playTimeNanos) + } + + LaunchedEffect(backStackEntry) { + try { + animatedFraction.animateTo(1f, animationSpec = tween(700, easing = LinearEasing)) { + seekToFraction(transitionState.currentState, backStackEntry) + } + } finally { + withContext(NonCancellable) { + animatedFraction.snapTo(0f) + transition.setPlaytimeAfterInitialAndTargetStateEstablished( + backStackEntry, + backStackEntry, + 0, + ) + } + } + } + + PredictiveBackHandler(currentBackStack.size > 1) { flow -> + // Prev can never be null, i.e. BackHandler should not enabled when prev is null + val prev = currentBackStack.last { it != backStackEntry } + try { + flow.collect { + val interpolated = EaseOut.transform(it.progress) + animatedFraction.snapTo(interpolated) + seekToFraction(backStackEntry, prev) + } + navController.popBackStack() + } catch (e: CancellationException) { + withContext(NonCancellable) { + animatedFraction.snapTo(0f) + transition.setPlaytimeAfterInitialAndTargetStateEstablished( + backStackEntry, + backStackEntry, + 0, + ) + } + } + } + + transition.AnimatedContent( + modifier, + transitionSpec = { + // If the initialState of the AnimatedContent is not in visibleEntries, we are in + // a case where visible has cleared the old state for some reason, so instead of + // attempting to animate away from the initialState, we skip the animation. + if (initialState in visibleEntries) { + val initialZIndex = zIndices[initialState.id] + ?: 0f.also { zIndices[initialState.id] = 0f } + val targetZIndex = when { + targetState.id == initialState.id -> initialZIndex + composeNavigator.isPop.value -> initialZIndex - 1f + else -> initialZIndex + 1f + }.also { zIndices[targetState.id] = it } + + ContentTransform(finalEnter(this), finalExit(this), targetZIndex) + } else { + EnterTransition.None togetherWith ExitTransition.None + } + }, + contentAlignment, + contentKey = { it.id }, + ) { + // while in the scope of the composable, we provide the navBackStackEntry as the + // ViewModelStoreOwner and LifecycleOwner + it.LocalOwnersProvider(saveableStateHolder) { + (it.destination as ComposeNavigator.Destination) + .content(this, it) + } + } + LaunchedEffect(transition.currentState, transition.targetState) { + if (transition.currentState == transition.targetState) { + visibleEntries.forEach { entry -> + composeNavigator.onTransitionComplete(entry) + } + zIndices + .filter { it.key != transition.targetState.id } + .forEach { zIndices.remove(it.key) } + } + } + } + + val dialogNavigator = navController.navigatorProvider.get>( + DialogNavigator.NAME, + ) as? DialogNavigator ?: return + + // Show any dialog destinations + DialogHost(dialogNavigator) +} + +private fun NavDestination.createEnterTransition( + scope: AnimatedContentTransitionScope, +): EnterTransition? = when (this) { + is ComposeNavigator.Destination -> this.enterTransition?.invoke(scope) + is ComposeNavGraphNavigator.ComposeNavGraph -> this.enterTransition?.invoke(scope) + else -> null +} + +private fun NavDestination.createExitTransition( + scope: AnimatedContentTransitionScope, +): ExitTransition? = when (this) { + is ComposeNavigator.Destination -> this.exitTransition?.invoke(scope) + is ComposeNavGraphNavigator.ComposeNavGraph -> this.exitTransition?.invoke(scope) + else -> null +} + +private fun NavDestination.createPopEnterTransition( + scope: AnimatedContentTransitionScope, +): EnterTransition? = when (this) { + is ComposeNavigator.Destination -> this.popEnterTransition?.invoke(scope) + is ComposeNavGraphNavigator.ComposeNavGraph -> this.popEnterTransition?.invoke(scope) + else -> null +} +private fun NavDestination.createPopExitTransition( + scope: AnimatedContentTransitionScope, +): ExitTransition? = when (this) { + is ComposeNavigator.Destination -> this.popExitTransition?.invoke(scope) + is ComposeNavGraphNavigator.ComposeNavGraph -> this.popExitTransition?.invoke(scope) + else -> null +} diff --git a/app/src/main/kotlin/androidx/navigation/compose/NavHostController.kt b/app/src/main/kotlin/androidx/navigation/compose/NavHostController.kt new file mode 100644 index 000000000..a9b104f23 --- /dev/null +++ b/app/src/main/kotlin/androidx/navigation/compose/NavHostController.kt @@ -0,0 +1,86 @@ +/* + * Copyright 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:Suppress("ktlint:standard:property-naming", "ktlint:standard:function-naming") + +package androidx.navigation.compose + +import android.content.Context +import android.os.Bundle +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.State +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.platform.LocalContext +import androidx.navigation.NavBackStackEntry +import androidx.navigation.NavController +import androidx.navigation.NavDestination +import androidx.navigation.NavHostController +import androidx.navigation.Navigator + +/** + * Gets the current navigation back stack entry as a [MutableState]. When the given navController + * changes the back stack due to a [NavController.navigate] or [NavController.popBackStack] this + * will trigger a recompose and return the top entry on the back stack. + * + * @return a mutable state of the current back stack entry + */ +@Composable +public fun NavController.currentBackStackEntryAsState(): State { + return currentBackStackEntryFlow.collectAsState(null) +} + +/** + * Creates a NavHostController that handles the adding of the [ComposeNavigator] and + * [DialogNavigator]. Additional [Navigator] instances can be passed through [navigators] to + * be applied to the returned NavController. Note that each [Navigator] must be separately + * remembered before being passed in here: any changes to those inputs will cause the + * NavController to be recreated. + * + * @see NavHost + */ +@Composable +public fun rememberNavController( + vararg navigators: Navigator, +): NavHostController { + val context = LocalContext.current + return rememberSaveable(inputs = navigators, saver = NavControllerSaver(context)) { + createNavController(context) + }.apply { + for (navigator in navigators) { + navigatorProvider.addNavigator(navigator) + } + } +} + +private fun createNavController(context: Context) = + NavHostController(context).apply { + navigatorProvider.addNavigator(ComposeNavGraphNavigator(navigatorProvider)) + navigatorProvider.addNavigator(ComposeNavigator()) + navigatorProvider.addNavigator(DialogNavigator()) + } + +/** + * Saver to save and restore the NavController across config change and process death. + */ +private fun NavControllerSaver( + context: Context, +): Saver = Saver( + save = { it.saveState() }, + restore = { createNavController(context).apply { restoreState(it) } }, +) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b45d9e04b..2fad53884 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -53,6 +53,7 @@ androidx-core = { module = "androidx.core:core-ktx", version.ref = "androidx-cor androidx-core-splashscreen = { module = "androidx.core:core-splashscreen", version.ref = "androidx-core-splashscreen" } androidx-fragment = { module = "androidx.fragment:fragment-ktx", version.ref = "androidx-fragment" } androidx-lifecycle-process = { module = "androidx.lifecycle:lifecycle-process", version.ref = "androidx-lifecycle" } +androidx-lifecycle-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "androidx-lifecycle" } androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "androidx-navigation" } androidx-navigation-fragment = { module = "androidx.navigation:navigation-fragment-ktx", version.ref = "androidx-navigation" }