Import Navigation Animation from Accompanist and Implement navigation animations for SPA.

Test: manually test on device.
Bug: 261931334
Change-Id: I6bbceb1566ca46ecb960a959c0bc306fdcdbc09a
This commit is contained in:
Kelly
2022-12-09 11:38:01 +08:00
parent 38b4352d4f
commit 2c3670665b
6 changed files with 545 additions and 5 deletions

View File

@@ -27,6 +27,7 @@ android_library {
"androidx.slice_slice-builders",
"androidx.slice_slice-core",
"androidx.slice_slice-view",
"androidx.compose.animation_animation",
"androidx.compose.material3_material3",
"androidx.compose.material_material-icons-extended",
"androidx.compose.runtime_runtime",

View File

@@ -14,6 +14,8 @@
* limitations under the License.
*/
@file:OptIn(ExperimentalAnimationApi::class)
package com.android.settingslib.spa.framework
import android.content.Intent
@@ -21,25 +23,31 @@ import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.annotation.VisibleForTesting
import androidx.compose.animation.AnimatedContentScope
import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.unit.IntOffset
import androidx.core.view.WindowCompat
import androidx.navigation.NavGraph.Companion.findStartDestination
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import com.android.settingslib.spa.R
import com.android.settingslib.spa.framework.common.LogCategory
import com.android.settingslib.spa.framework.common.SettingsPage
import com.android.settingslib.spa.framework.common.SettingsPageProvider
import com.android.settingslib.spa.framework.common.SettingsPageProviderRepository
import com.android.settingslib.spa.framework.common.SpaEnvironmentFactory
import com.android.settingslib.spa.framework.compose.AnimatedNavHost
import com.android.settingslib.spa.framework.compose.LocalNavController
import com.android.settingslib.spa.framework.compose.NavControllerWrapperImpl
import com.android.settingslib.spa.framework.compose.composable
import com.android.settingslib.spa.framework.compose.localNavController
import com.android.settingslib.spa.framework.compose.rememberAnimatedNavController
import com.android.settingslib.spa.framework.theme.SettingsTheme
import com.android.settingslib.spa.framework.util.PageEvent
import com.android.settingslib.spa.framework.util.getDestination
@@ -86,7 +94,7 @@ open class BrowseActivity : ComponentActivity() {
@VisibleForTesting
@Composable
fun BrowseContent(sppRepository: SettingsPageProviderRepository, initialIntent: Intent? = null) {
val navController = rememberNavController()
val navController = rememberAnimatedNavController()
CompositionLocalProvider(navController.localNavController()) {
val controller = LocalNavController.current as NavControllerWrapperImpl
controller.NavContent(sppRepository.getAllProviders())
@@ -97,15 +105,41 @@ fun BrowseContent(sppRepository: SettingsPageProviderRepository, initialIntent:
@Composable
private fun NavControllerWrapperImpl.NavContent(allProvider: Collection<SettingsPageProvider>) {
val nullPage = SettingsPage.createNull()
NavHost(
AnimatedNavHost(
navController = navController,
startDestination = nullPage.sppName,
) {
val slideEffect = tween<IntOffset>(durationMillis = 300)
val fadeEffect = tween<Float>(durationMillis = 300)
composable(nullPage.sppName) {}
for (spp in allProvider) {
composable(
route = spp.name + spp.parameter.navRoute(),
arguments = spp.parameter,
enterTransition = {
slideIntoContainer(
AnimatedContentScope.SlideDirection.Left,
animationSpec = slideEffect
) + fadeIn(animationSpec = fadeEffect)
},
exitTransition = {
slideOutOfContainer(
AnimatedContentScope.SlideDirection.Left,
animationSpec = slideEffect
) + fadeOut(animationSpec = fadeEffect)
},
popEnterTransition = {
slideIntoContainer(
AnimatedContentScope.SlideDirection.Right,
animationSpec = slideEffect
) + fadeIn(animationSpec = fadeEffect)
},
popExitTransition = {
slideOutOfContainer(
AnimatedContentScope.SlideDirection.Right,
animationSpec = slideEffect
) + fadeOut(animationSpec = fadeEffect)
},
) { navBackStackEntry ->
spp.PageEvent(navBackStackEntry.arguments)
spp.Page(navBackStackEntry.arguments)

View File

@@ -0,0 +1,79 @@
/*
* Copyright (C) 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 com.android.settingslib.spa.framework.compose
import androidx.compose.animation.AnimatedVisibilityScope
import androidx.compose.animation.ExperimentalAnimationApi
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
/**
* 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].
*/
@ExperimentalAnimationApi
@Navigator.Name("animatedComposable")
public class AnimatedComposeNavigator : Navigator<AnimatedComposeNavigator.Destination>() {
internal val transitionsInProgress get() = state.transitionsInProgress
internal val backStack get() = state.backStack
internal val isPop = mutableStateOf(false)
override fun navigate(
entries: List<NavBackStackEntry>,
navOptions: NavOptions?,
navigatorExtras: Extras?
) {
entries.forEach { entry ->
state.pushWithTransition(entry)
}
isPop.value = false
}
override fun createDestination(): Destination {
return Destination(this, content = { })
}
override fun popBackStack(popUpTo: NavBackStackEntry, savedState: Boolean) {
state.popWithTransition(popUpTo, savedState)
isPop.value = true
}
internal fun markTransitionComplete(entry: NavBackStackEntry) {
state.markTransitionComplete(entry)
}
/**
* NavDestination specific to [AnimatedComposeNavigator]
*/
@ExperimentalAnimationApi
@NavDestination.ClassType(Composable::class)
public class Destination(
navigator: AnimatedComposeNavigator,
internal val content: @Composable AnimatedVisibilityScope.(NavBackStackEntry) -> Unit
) : NavDestination(navigator)
internal companion object {
internal const val NAME = "animatedComposable"
}
}

View File

@@ -0,0 +1,267 @@
/*
* Copyright (C) 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 com.android.settingslib.spa.framework.compose
import androidx.activity.compose.LocalOnBackPressedDispatcherOwner
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedContentScope
import androidx.compose.animation.ContentTransform
import androidx.compose.animation.EnterTransition
import androidx.compose.animation.ExitTransition
import androidx.compose.animation.ExperimentalAnimationApi
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.with
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
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.compose.DialogHost
import androidx.navigation.compose.DialogNavigator
import androidx.navigation.compose.LocalOwnersProvider
import androidx.navigation.createGraph
import androidx.navigation.get
import kotlinx.coroutines.flow.map
/**
* 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 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
@ExperimentalAnimationApi
public fun AnimatedNavHost(
navController: NavHostController,
startDestination: String,
modifier: Modifier = Modifier,
contentAlignment: Alignment = Alignment.Center,
route: String? = null,
enterTransition: (AnimatedContentScope<NavBackStackEntry>.() -> EnterTransition) =
{ fadeIn(animationSpec = tween(700)) },
exitTransition: (AnimatedContentScope<NavBackStackEntry>.() -> ExitTransition) =
{ fadeOut(animationSpec = tween(700)) },
popEnterTransition: (AnimatedContentScope<NavBackStackEntry>.() -> EnterTransition) =
enterTransition,
popExitTransition: (AnimatedContentScope<NavBackStackEntry>.() -> ExitTransition) =
exitTransition,
builder: NavGraphBuilder.() -> Unit
) {
AnimatedNavHost(
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].
*
* @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 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
*/
@ExperimentalAnimationApi
@Composable
public fun AnimatedNavHost(
navController: NavHostController,
graph: NavGraph,
modifier: Modifier = Modifier,
contentAlignment: Alignment = Alignment.Center,
enterTransition: (AnimatedContentScope<NavBackStackEntry>.() -> EnterTransition) =
{ fadeIn(animationSpec = tween(700)) },
exitTransition: (AnimatedContentScope<NavBackStackEntry>.() -> ExitTransition) =
{ fadeOut(animationSpec = tween(700)) },
popEnterTransition: (AnimatedContentScope<NavBackStackEntry>.() -> EnterTransition) =
enterTransition,
popExitTransition: (AnimatedContentScope<NavBackStackEntry>.() -> ExitTransition) =
exitTransition,
) {
val lifecycleOwner = LocalLifecycleOwner.current
val viewModelStoreOwner = checkNotNull(LocalViewModelStoreOwner.current) {
"NavHost requires a ViewModelStoreOwner to be provided via LocalViewModelStoreOwner"
}
val onBackPressedDispatcherOwner = LocalOnBackPressedDispatcherOwner.current
val onBackPressedDispatcher = onBackPressedDispatcherOwner?.onBackPressedDispatcher
// on successful recompose we setup the navController with proper inputs
// after the first time, this will only happen again if one of the inputs changes
navController.setLifecycleOwner(lifecycleOwner)
navController.setViewModelStore(viewModelStoreOwner.viewModelStore)
if (onBackPressedDispatcher != null) {
navController.setOnBackPressedDispatcher(onBackPressedDispatcher)
}
navController.graph = graph
val saveableStateHolder = rememberSaveableStateHolder()
// Find the ComposeNavigator, returning early if it isn't found
// (such as is the case when using TestNavHostController)
val composeNavigator = navController.navigatorProvider.get<Navigator<out NavDestination>>(
AnimatedComposeNavigator.NAME
) as? AnimatedComposeNavigator ?: return
val visibleEntries by remember(navController.visibleEntries) {
navController.visibleEntries.map {
it.filter { entry ->
entry.destination.navigatorName == AnimatedComposeNavigator.NAME
}
}
}.collectAsState(emptyList())
val backStackEntry = visibleEntries.lastOrNull()
if (backStackEntry != null) {
val finalEnter: AnimatedContentScope<NavBackStackEntry>.() -> EnterTransition = {
val targetDestination = targetState.destination as AnimatedComposeNavigator.Destination
if (composeNavigator.isPop.value) {
targetDestination.hierarchy.firstNotNullOfOrNull { destination ->
popEnterTransitions[destination.route]?.invoke(this)
} ?: popEnterTransition.invoke(this)
} else {
targetDestination.hierarchy.firstNotNullOfOrNull { destination ->
enterTransitions[destination.route]?.invoke(this)
} ?: enterTransition.invoke(this)
}
}
val finalExit: AnimatedContentScope<NavBackStackEntry>.() -> ExitTransition = {
val initialDestination =
initialState.destination as AnimatedComposeNavigator.Destination
if (composeNavigator.isPop.value) {
initialDestination.hierarchy.firstNotNullOfOrNull { destination ->
popExitTransitions[destination.route]?.invoke(this)
} ?: popExitTransition.invoke(this)
} else {
initialDestination.hierarchy.firstNotNullOfOrNull { destination ->
exitTransitions[destination.route]?.invoke(this)
} ?: exitTransition.invoke(this)
}
}
val transition = updateTransition(backStackEntry, label = "entry")
transition.AnimatedContent(
modifier,
transitionSpec = {
val zIndex = if (composeNavigator.isPop.value) {
visibleEntries.indexOf(initialState).toFloat()
} else {
visibleEntries.indexOf(targetState).toFloat()
}
// 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) {
ContentTransform(finalEnter(this), finalExit(this), zIndex)
} else {
EnterTransition.None with ExitTransition.None
}
},
contentAlignment,
contentKey = { it.id }
) {
// In some specific cases, such as clearing your back stack by changing your
// start destination, AnimatedContent can contain an entry that is no longer
// part of visible entries since it was cleared from the back stack and is not
// animating. In these cases the currentEntry will be null, and in those cases,
// AnimatedContent will just skip attempting to transition the old entry.
// See https://issuetracker.google.com/238686802
val currentEntry = visibleEntries.lastOrNull { entry ->
it == entry
}
// while in the scope of the composable, we provide the navBackStackEntry as the
// ViewModelStoreOwner and LifecycleOwner
currentEntry?.LocalOwnersProvider(saveableStateHolder) {
(currentEntry.destination as AnimatedComposeNavigator.Destination)
.content(this, currentEntry)
}
}
if (transition.currentState == transition.targetState) {
visibleEntries.forEach { entry ->
composeNavigator.markTransitionComplete(entry)
}
}
}
val dialogNavigator = navController.navigatorProvider.get<Navigator<out NavDestination>>(
"dialog"
) as? DialogNavigator ?: return
// Show any dialog destinations
DialogHost(dialogNavigator)
}
@ExperimentalAnimationApi
internal val enterTransitions =
mutableMapOf<String?,
(AnimatedContentScope<NavBackStackEntry>.() -> EnterTransition?)?>()
@ExperimentalAnimationApi
internal val exitTransitions =
mutableMapOf<String?, (AnimatedContentScope<NavBackStackEntry>.() -> ExitTransition?)?>()
@ExperimentalAnimationApi
internal val popEnterTransitions =
mutableMapOf<String?, (AnimatedContentScope<NavBackStackEntry>.() -> EnterTransition?)?>()
@ExperimentalAnimationApi
internal val popExitTransitions =
mutableMapOf<String?, (AnimatedContentScope<NavBackStackEntry>.() -> ExitTransition?)?>()

View File

@@ -0,0 +1,118 @@
/*
* Copyright (C) 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 com.android.settingslib.spa.framework.compose
import androidx.compose.animation.AnimatedContentScope
import androidx.compose.animation.AnimatedVisibilityScope
import androidx.compose.animation.EnterTransition
import androidx.compose.animation.ExitTransition
import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.runtime.Composable
import androidx.navigation.NamedNavArgument
import androidx.navigation.NavBackStackEntry
import androidx.navigation.NavDeepLink
import androidx.navigation.NavGraph
import androidx.navigation.NavGraphBuilder
import androidx.navigation.compose.navigation
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 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
*/
@ExperimentalAnimationApi
public fun NavGraphBuilder.composable(
route: String,
arguments: List<NamedNavArgument> = emptyList(),
deepLinks: List<NavDeepLink> = emptyList(),
enterTransition: (AnimatedContentScope<NavBackStackEntry>.() -> EnterTransition?)? = null,
exitTransition: (AnimatedContentScope<NavBackStackEntry>.() -> ExitTransition?)? = null,
popEnterTransition: (
AnimatedContentScope<NavBackStackEntry>.() -> EnterTransition?
)? = enterTransition,
popExitTransition: (
AnimatedContentScope<NavBackStackEntry>.() -> ExitTransition?
)? = exitTransition,
content: @Composable AnimatedVisibilityScope.(NavBackStackEntry) -> Unit
) {
addDestination(
AnimatedComposeNavigator.Destination(
provider[AnimatedComposeNavigator::class],
content
).apply {
this.route = route
arguments.forEach { (argumentName, argument) ->
addArgument(argumentName, argument)
}
deepLinks.forEach { deepLink ->
addDeepLink(deepLink)
}
enterTransition?.let { enterTransitions[route] = enterTransition }
exitTransition?.let { exitTransitions[route] = exitTransition }
popEnterTransition?.let { popEnterTransitions[route] = popEnterTransition }
popExitTransition?.let { popExitTransitions[route] = popExitTransition }
}
)
}
/**
* 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
*/
@ExperimentalAnimationApi
public fun NavGraphBuilder.navigation(
startDestination: String,
route: String,
arguments: List<NamedNavArgument> = emptyList(),
deepLinks: List<NavDeepLink> = emptyList(),
enterTransition: (AnimatedContentScope<NavBackStackEntry>.() -> EnterTransition?)? = null,
exitTransition: (AnimatedContentScope<NavBackStackEntry>.() -> ExitTransition?)? = null,
popEnterTransition: (
AnimatedContentScope<NavBackStackEntry>.() -> EnterTransition?
)? = enterTransition,
popExitTransition: (
AnimatedContentScope<NavBackStackEntry>.() -> ExitTransition?
)? = exitTransition,
builder: NavGraphBuilder.() -> Unit
) {
navigation(startDestination, route, arguments, deepLinks, builder).apply {
enterTransition?.let { enterTransitions[route] = enterTransition }
exitTransition?.let { exitTransitions[route] = exitTransition }
popEnterTransition?.let { popEnterTransitions[route] = popEnterTransition }
popExitTransition?.let { popExitTransitions[route] = popExitTransition }
}
}

View File

@@ -0,0 +1,41 @@
/*
* Copyright (C) 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 com.android.settingslib.spa.framework.compose
import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.navigation.NavDestination
import androidx.navigation.NavHostController
import androidx.navigation.Navigator
import androidx.navigation.compose.rememberNavController
/**
* Creates a NavHostController that handles the adding of the [ComposeNavigator], [DialogNavigator]
* and [AnimatedComposeNavigator]. Additional [androidx.navigation.Navigator] instances should be
* added in a [androidx.compose.runtime.SideEffect] block.
*
* @see AnimatedNavHost
*/
@ExperimentalAnimationApi
@Composable
fun rememberAnimatedNavController(
vararg navigators: Navigator<out NavDestination>
): NavHostController {
val animatedNavigator = remember { AnimatedComposeNavigator() }
return rememberNavController(animatedNavigator, *navigators)
}