diff --git a/core/ui/src/commonMain/kotlin/nl/q42/template/core/ui/theme/AppColorScheme.kt b/core/ui/src/commonMain/kotlin/nl/q42/template/core/ui/theme/AppColorScheme.kt index 78a93621..181de0a1 100644 --- a/core/ui/src/commonMain/kotlin/nl/q42/template/core/ui/theme/AppColorScheme.kt +++ b/core/ui/src/commonMain/kotlin/nl/q42/template/core/ui/theme/AppColorScheme.kt @@ -13,4 +13,6 @@ interface AppColorScheme { val error: Color val errorContent: Color val highlightColor: Color + /** Dims the screen underneath during an iOS push/pop transition. */ + val navigationTransitionDim: Color } diff --git a/core/ui/src/commonMain/kotlin/nl/q42/template/core/ui/theme/AppColorSchemeDark.kt b/core/ui/src/commonMain/kotlin/nl/q42/template/core/ui/theme/AppColorSchemeDark.kt index 0293f9ab..888ae54b 100644 --- a/core/ui/src/commonMain/kotlin/nl/q42/template/core/ui/theme/AppColorSchemeDark.kt +++ b/core/ui/src/commonMain/kotlin/nl/q42/template/core/ui/theme/AppColorSchemeDark.kt @@ -13,6 +13,7 @@ object AppColorSchemeDark : AppColorScheme { override val error = ErrorDark override val errorContent = OnErrorDark override val highlightColor = OnSurfaceDark.copy(alpha = 0.15f) + override val navigationTransitionDim = ScrimDark.copy(alpha = 0.1f) } /** diff --git a/core/ui/src/commonMain/kotlin/nl/q42/template/core/ui/theme/AppColorSchemeLight.kt b/core/ui/src/commonMain/kotlin/nl/q42/template/core/ui/theme/AppColorSchemeLight.kt index fd502b5a..26dfb56b 100644 --- a/core/ui/src/commonMain/kotlin/nl/q42/template/core/ui/theme/AppColorSchemeLight.kt +++ b/core/ui/src/commonMain/kotlin/nl/q42/template/core/ui/theme/AppColorSchemeLight.kt @@ -14,6 +14,7 @@ object AppColorSchemeLight : AppColorScheme { override val error = ErrorLight override val errorContent = OnErrorLight override val highlightColor = OnSurfaceLight.copy(alpha = 0.2f) + override val navigationTransitionDim = ScrimLight.copy(alpha = 0.1f) } /** diff --git a/shared/src/androidMain/kotlin/nl/q42/template/navigation/NavigationTransitions.android.kt b/shared/src/androidMain/kotlin/nl/q42/template/navigation/NavigationTransitions.android.kt new file mode 100644 index 00000000..66301a1e --- /dev/null +++ b/shared/src/androidMain/kotlin/nl/q42/template/navigation/NavigationTransitions.android.kt @@ -0,0 +1,61 @@ +package nl.q42.template.navigation + +import androidx.compose.animation.AnimatedContentTransitionScope +import androidx.compose.animation.ContentTransform +import androidx.compose.animation.core.LinearOutSlowInEasing +import androidx.compose.animation.core.snap +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.animation.togetherWith +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.navigation3.runtime.NavKey +import androidx.navigation3.scene.Scene + +private const val TAB_FADE_DURATION_MILLIS = 300 + +@Composable +internal actual fun rememberNavigationTransitions(): NavigationTransitions = remember { + NavigationTransitions( + transitionSpec = { + if (isTabSwitch()) { + fadeInOverPreviousTab() + } else { + slideInHorizontally { it } + fadeIn() togetherWith + slideOutHorizontally { -it } + fadeOut() + } + }, + popTransitionSpec = { + if (isTabSwitch()) fadeOutOverNextTab() else slideBack() + }, + predictivePopTransitionSpec = { + if (isTabSwitch()) fadeOutOverNextTab() else slideBack() + }, + ) +} + +/** + * Tabs are not spatially related, so Material fades between navigation bar destinations instead of sliding. + */ +private fun AnimatedContentTransitionScope>.isTabSwitch(): Boolean = + initialState.isBottomBarTab && targetState.isBottomBarTab + +/* + * Crossfades tabs by fading only the top screen, while the screen underneath stays fully visible. Screens are opaque, + * so this never shows the empty background, unlike Material's fade through, which fades out the old screen first. + * NavDisplay draws the new screen on top for a push, but underneath for a pop. + */ +private fun fadeInOverPreviousTab(): ContentTransform = + fadeIn(tween(TAB_FADE_DURATION_MILLIS, easing = LinearOutSlowInEasing)) togetherWith + fadeOut(snap(delayMillis = TAB_FADE_DURATION_MILLIS)) + +private fun fadeOutOverNextTab(): ContentTransform = + fadeIn(snap()) togetherWith + fadeOut(tween(TAB_FADE_DURATION_MILLIS, easing = LinearOutSlowInEasing)) + +private fun slideBack(): ContentTransform = + slideInHorizontally { -it } + fadeIn() togetherWith + slideOutHorizontally { it } + fadeOut() diff --git a/shared/src/commonMain/kotlin/nl/q42/template/navigation/BlockBackDuringTransitionNavEntryDecorator.kt b/shared/src/commonMain/kotlin/nl/q42/template/navigation/BlockBackDuringTransitionNavEntryDecorator.kt new file mode 100644 index 00000000..4b84f926 --- /dev/null +++ b/shared/src/commonMain/kotlin/nl/q42/template/navigation/BlockBackDuringTransitionNavEntryDecorator.kt @@ -0,0 +1,32 @@ +package nl.q42.template.navigation + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.navigation3.runtime.NavEntryDecorator +import androidx.navigation3.ui.LocalNavAnimatedContentScope +import androidx.navigationevent.NavigationEventInfo +import androidx.navigationevent.compose.NavigationBackHandler +import androidx.navigationevent.compose.rememberNavigationEventState + +/** + * Ignores back while a screen is still animating in or out. + * + * A back swipe that starts during a push makes NavDisplay seek a new transition while the push + * is still running. Compose then shows the screens in the wrong places, ignores the finger and + * jumps to the end. This is still the case in navigation3 1.2.0. + * + * The handler is registered after the one of NavDisplay, so it gets back events first. A back + * swipe that is already in progress keeps going to NavDisplay, so this does not interrupt it. + */ +@Composable +internal fun rememberBlockBackDuringTransitionNavEntryDecorator(): NavEntryDecorator = remember { + NavEntryDecorator { entry -> + val transition = LocalNavAnimatedContentScope.current.transition + NavigationBackHandler( + state = rememberNavigationEventState(NavigationEventInfo.None), + isBackEnabled = transition.currentState != transition.targetState, + onBackCompleted = {}, + ) + entry.Content() + } +} diff --git a/shared/src/commonMain/kotlin/nl/q42/template/navigation/BottomBarTab.kt b/shared/src/commonMain/kotlin/nl/q42/template/navigation/BottomBarTab.kt index 1fc3398c..61b363bf 100644 --- a/shared/src/commonMain/kotlin/nl/q42/template/navigation/BottomBarTab.kt +++ b/shared/src/commonMain/kotlin/nl/q42/template/navigation/BottomBarTab.kt @@ -1,6 +1,8 @@ package nl.q42.template.navigation import androidx.compose.runtime.Composable +import androidx.navigation3.runtime.NavKey +import androidx.navigation3.scene.Scene import nl.q42.template.core.navigation.Destination import nl.q42.template.core.navigation.viewmodel.BackstackBehavior import nl.q42.template.core.navigation.viewmodel.Navigator @@ -30,6 +32,17 @@ internal enum class BottomBarTab( Interop(Destination.SampleInterop, Res.string.navigation_tab_interop, Res.drawable.sample_extension_24, "sample_tab_interop"), } +private const val BOTTOM_BAR_TAB_METADATA_KEY = "bottomBarTab" + +/** + * Marks the nav entry of a tab, so transitions can tell a tab switch apart from a push or pop. + */ +internal fun bottomBarTabMetadata(key: NavKey): Map = + if (BottomBarTab.entries.any { it.destination == key }) mapOf(BOTTOM_BAR_TAB_METADATA_KEY to true) else emptyMap() + +internal val Scene.isBottomBarTab: Boolean + get() = metadata[BOTTOM_BAR_TAB_METADATA_KEY] == true + @Composable internal fun BottomBarTab.toNavigationBarItem() = AppNavigationBarItem( label = stringResource(label), diff --git a/shared/src/commonMain/kotlin/nl/q42/template/navigation/NavEntryForKey.kt b/shared/src/commonMain/kotlin/nl/q42/template/navigation/NavEntryForKey.kt index 44b6833a..cbbcece0 100644 --- a/shared/src/commonMain/kotlin/nl/q42/template/navigation/NavEntryForKey.kt +++ b/shared/src/commonMain/kotlin/nl/q42/template/navigation/NavEntryForKey.kt @@ -14,7 +14,7 @@ internal fun navEntryForKey(key: NavKey, navigator: Navigator): NavEntry when (key) { is Destination -> when(key) { Destination.Home -> { - NavEntry(key) { + NavEntry(key, metadata = bottomBarTabMetadata(key)) { SampleHomeScreen( viewModel = koinViewModel( parameters = { @@ -25,7 +25,7 @@ internal fun navEntryForKey(key: NavKey, navigator: Navigator): NavEntry } } Destination.Onboarding -> { - NavEntry(key) { + NavEntry(key, metadata = bottomBarTabMetadata(key)) { SampleOnboardingScreen( viewModel = koinViewModel( parameters = { @@ -36,7 +36,7 @@ internal fun navEntryForKey(key: NavKey, navigator: Navigator): NavEntry } } Destination.SampleInterop -> { - NavEntry(key) { + NavEntry(key, metadata = bottomBarTabMetadata(key)) { SampleInteropScreen( viewModel = koinViewModel( parameters = { diff --git a/shared/src/commonMain/kotlin/nl/q42/template/navigation/NavigationRoot.kt b/shared/src/commonMain/kotlin/nl/q42/template/navigation/NavigationRoot.kt index dbacc60a..3a1ee746 100644 --- a/shared/src/commonMain/kotlin/nl/q42/template/navigation/NavigationRoot.kt +++ b/shared/src/commonMain/kotlin/nl/q42/template/navigation/NavigationRoot.kt @@ -1,12 +1,5 @@ package nl.q42.template.navigation -import androidx.compose.animation.AnimatedContentTransitionScope -import androidx.compose.animation.ContentTransform -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.slideInHorizontally -import androidx.compose.animation.slideOutHorizontally -import androidx.compose.animation.togetherWith import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.PaddingValues @@ -25,7 +18,6 @@ import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDe import androidx.navigation3.runtime.NavKey import androidx.navigation3.runtime.rememberNavBackStack import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator -import androidx.navigation3.scene.Scene import androidx.navigation3.ui.NavDisplay import androidx.savedstate.serialization.SavedStateConfiguration import kotlinx.serialization.InternalSerializationApi @@ -86,10 +78,13 @@ fun NavigationRoot() { * * rememberViewModelStoreNavEntryDecorator: required to make sure that viewModels are properly * scoped to the corresponding views and cleared when the view is removed from the backstack. + * + * rememberBlockBackDuringTransitionNavEntryDecorator: ignores back while a screen animates in or out. */ val selectedTab = BottomBarTab.entries.find { it.destination == backStack.lastOrNull() } var bottomBarHeight by remember { mutableStateOf(0.dp) } val visibleBottomBarHeight = if (selectedTab != null) bottomBarHeight else 0.dp + val transitions = rememberNavigationTransitions() Box(Modifier.fillMaxSize().background(AppTheme.colors.surface)) { // Screens draw behind the bottom bar, so their content scrolls behind the Liquid Glass. @@ -100,12 +95,13 @@ fun NavigationRoot() { modifier = Modifier.consumeWindowInsets(PaddingValues(bottom = visibleBottomBarHeight)), entryDecorators = listOf( rememberSaveableStateHolderNavEntryDecorator(), - rememberViewModelStoreNavEntryDecorator() + rememberViewModelStoreNavEntryDecorator(), + rememberBlockBackDuringTransitionNavEntryDecorator(), ), backStack = backStack, - transitionSpec = transitionSpec(), - popTransitionSpec = popTransitionSpec(), - predictivePopTransitionSpec = predictivePopTransitionSpec(), + transitionSpec = transitions.transitionSpec, + popTransitionSpec = transitions.popTransitionSpec, + predictivePopTransitionSpec = transitions.predictivePopTransitionSpec, entryProvider = { key -> navEntryForKey(key = key, navigator = navigator) } @@ -125,19 +121,3 @@ fun NavigationRoot() { } } } - -private fun transitionSpec(): AnimatedContentTransitionScope>.() -> ContentTransform = { - slideInHorizontally { it } + fadeIn() togetherWith - slideOutHorizontally { -it } + fadeOut() -} - -private fun popTransitionSpec(): AnimatedContentTransitionScope>.() -> ContentTransform = { - slideInHorizontally { -it } + fadeIn() togetherWith - slideOutHorizontally { it } + fadeOut() -} - -private fun predictivePopTransitionSpec(): AnimatedContentTransitionScope>.(Int) -> ContentTransform = { - slideInHorizontally { -it } + fadeIn() togetherWith - slideOutHorizontally { it } + fadeOut() -} - diff --git a/shared/src/commonMain/kotlin/nl/q42/template/navigation/NavigationTransitions.kt b/shared/src/commonMain/kotlin/nl/q42/template/navigation/NavigationTransitions.kt new file mode 100644 index 00000000..253054a0 --- /dev/null +++ b/shared/src/commonMain/kotlin/nl/q42/template/navigation/NavigationTransitions.kt @@ -0,0 +1,19 @@ +package nl.q42.template.navigation + +import androidx.compose.animation.AnimatedContentTransitionScope +import androidx.compose.animation.ContentTransform +import androidx.compose.runtime.Composable +import androidx.navigation3.runtime.NavKey +import androidx.navigation3.scene.Scene + +internal class NavigationTransitions( + val transitionSpec: AnimatedContentTransitionScope>.() -> ContentTransform, + val popTransitionSpec: AnimatedContentTransitionScope>.() -> ContentTransform, + val predictivePopTransitionSpec: AnimatedContentTransitionScope>.(swipeEdge: Int) -> ContentTransform, +) + +/** + * Screen transitions that feel native on each platform. + */ +@Composable +internal expect fun rememberNavigationTransitions(): NavigationTransitions diff --git a/shared/src/iosMain/kotlin/nl/q42/template/navigation/NavigationTransitions.ios.kt b/shared/src/iosMain/kotlin/nl/q42/template/navigation/NavigationTransitions.ios.kt new file mode 100644 index 00000000..d5f547f7 --- /dev/null +++ b/shared/src/iosMain/kotlin/nl/q42/template/navigation/NavigationTransitions.ios.kt @@ -0,0 +1,101 @@ +package nl.q42.template.navigation + +import androidx.compose.animation.AnimatedContentTransitionScope +import androidx.compose.animation.AnimatedContentTransitionScope.SlideDirection +import androidx.compose.animation.ContentTransform +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExperimentalAnimationApi +import androidx.compose.animation.ExitTransition +import androidx.compose.animation.core.CubicBezierEasing +import androidx.compose.animation.core.Easing +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.TweenSpec +import androidx.compose.animation.core.tween +import androidx.compose.animation.unveilIn +import androidx.compose.animation.veilOut +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.graphics.Color +import androidx.navigation3.runtime.NavKey +import androidx.navigation3.scene.Scene +import androidx.navigationevent.NavigationEvent +import nl.q42.template.core.ui.theme.AppTheme + +/* + * Mimics the UINavigationController push/pop: the new screen slides in over the full width, while + * the screen underneath moves along a bit (parallax) and dims. The timing and easing are taken from + * the navigation3 iOS defaults, which approximate UIKit's no-bounce spring; the parallax and dim + * amount are closer to UIKit than those defaults. + */ +private const val TRANSITION_DURATION_MILLIS = 500 + +/** + * While the finger is down, the back swipe sets the progress directly. After lifting the finger, navigation3 finishes + * (or cancels) in the remaining part of this duration. UIKit finishes much faster, keeping the speed of the finger: + * about 150 ms after letting go at a third of the screen. A 500 ms duration felt sluggish there. + */ +private const val PREDICTIVE_BACK_DURATION_MILLIS = 250 +private const val PARALLAX_FRACTION = 0.3f +private val TransitionEasing = CubicBezierEasing(0.2833f, 0.99f, 0.31833f, 0.99f) + +@Composable +internal actual fun rememberNavigationTransitions(): NavigationTransitions { + val dimColor = AppTheme.colors.navigationTransitionDim + return remember(dimColor) { + NavigationTransitions( + transitionSpec = { + if (isTabSwitch()) noTransition() else slide(SlideDirection.Left, isPop = false, dimColor, TRANSITION_DURATION_MILLIS, TransitionEasing) + }, + popTransitionSpec = { + if (isTabSwitch()) noTransition() else slide(SlideDirection.Right, isPop = true, dimColor, TRANSITION_DURATION_MILLIS, TransitionEasing) + }, + // The back swipe drives the transition progress, so it must follow the finger linearly + predictivePopTransitionSpec = { swipeEdge -> + if (isTabSwitch()) { + noTransition() + } else { + val towards = if (swipeEdge == NavigationEvent.EDGE_RIGHT) SlideDirection.Left else SlideDirection.Right + slide(towards, isPop = true, dimColor, PREDICTIVE_BACK_DURATION_MILLIS, LinearEasing) + } + }, + ) + } +} + +/** + * Like UITabBarController, switching tabs swaps the screen without animating. + */ +private fun AnimatedContentTransitionScope>.isTabSwitch(): Boolean = + initialState.isBottomBarTab && targetState.isBottomBarTab + +private fun noTransition() = ContentTransform(EnterTransition.None, ExitTransition.None) + +/** + * Slides the top screen over the full width, and the screen underneath by [PARALLAX_FRACTION] while dimming it. + */ +@OptIn(ExperimentalAnimationApi::class) // veilOut and unveilIn +private fun AnimatedContentTransitionScope>.slide( + towards: SlideDirection, + isPop: Boolean, + dimColor: Color, + durationMillis: Int, + easing: Easing, +): ContentTransform { + val enter = if (isPop) { + slideIntoContainer(towards, transitionTween(durationMillis, easing), initialOffset = ::parallaxOffset) + + unveilIn(transitionTween(durationMillis, easing), initialColor = dimColor) + } else { + slideIntoContainer(towards, transitionTween(durationMillis, easing)) + } + val exit = if (isPop) { + slideOutOfContainer(towards, transitionTween(durationMillis, easing)) + } else { + slideOutOfContainer(towards, transitionTween(durationMillis, easing), targetOffset = ::parallaxOffset) + + veilOut(transitionTween(durationMillis, easing), targetColor = dimColor) + } + return ContentTransform(enter, exit) +} + +private fun parallaxOffset(fullWidth: Int): Int = (fullWidth * PARALLAX_FRACTION).toInt() + +private fun transitionTween(durationMillis: Int, easing: Easing): TweenSpec = tween(durationMillis, easing = easing)