Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Scene<NavKey>>.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()
Original file line number Diff line number Diff line change
@@ -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 <T : Any> rememberBlockBackDuringTransitionNavEntryDecorator(): NavEntryDecorator<T> = remember {
NavEntryDecorator { entry ->
val transition = LocalNavAnimatedContentScope.current.transition
NavigationBackHandler(
state = rememberNavigationEventState(NavigationEventInfo.None),
isBackEnabled = transition.currentState != transition.targetState,
onBackCompleted = {},
)
entry.Content()
}
}
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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<String, Any> =
if (BottomBarTab.entries.any { it.destination == key }) mapOf(BOTTOM_BAR_TAB_METADATA_KEY to true) else emptyMap()

internal val Scene<NavKey>.isBottomBarTab: Boolean
get() = metadata[BOTTOM_BAR_TAB_METADATA_KEY] == true

@Composable
internal fun BottomBarTab.toNavigationBarItem() = AppNavigationBarItem(
label = stringResource(label),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ internal fun navEntryForKey(key: NavKey, navigator: Navigator): NavEntry<NavKey>
when (key) {
is Destination -> when(key) {
Destination.Home -> {
NavEntry(key) {
NavEntry(key, metadata = bottomBarTabMetadata(key)) {
SampleHomeScreen(
viewModel = koinViewModel(
parameters = {
Expand All @@ -25,7 +25,7 @@ internal fun navEntryForKey(key: NavKey, navigator: Navigator): NavEntry<NavKey>
}
}
Destination.Onboarding -> {
NavEntry(key) {
NavEntry(key, metadata = bottomBarTabMetadata(key)) {
SampleOnboardingScreen(
viewModel = koinViewModel(
parameters = {
Expand All @@ -36,7 +36,7 @@ internal fun navEntryForKey(key: NavKey, navigator: Navigator): NavEntry<NavKey>
}
}
Destination.SampleInterop -> {
NavEntry(key) {
NavEntry(key, metadata = bottomBarTabMetadata(key)) {
SampleInteropScreen(
viewModel = koinViewModel(
parameters = {
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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)
}
Expand All @@ -125,19 +121,3 @@ fun NavigationRoot() {
}
}
}

private fun transitionSpec(): AnimatedContentTransitionScope<Scene<NavKey>>.() -> ContentTransform = {
slideInHorizontally { it } + fadeIn() togetherWith
slideOutHorizontally { -it } + fadeOut()
}

private fun popTransitionSpec(): AnimatedContentTransitionScope<Scene<NavKey>>.() -> ContentTransform = {
slideInHorizontally { -it } + fadeIn() togetherWith
slideOutHorizontally { it } + fadeOut()
}

private fun predictivePopTransitionSpec(): AnimatedContentTransitionScope<Scene<NavKey>>.(Int) -> ContentTransform = {
slideInHorizontally { -it } + fadeIn() togetherWith
slideOutHorizontally { it } + fadeOut()
}

Original file line number Diff line number Diff line change
@@ -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<Scene<NavKey>>.() -> ContentTransform,
val popTransitionSpec: AnimatedContentTransitionScope<Scene<NavKey>>.() -> ContentTransform,
val predictivePopTransitionSpec: AnimatedContentTransitionScope<Scene<NavKey>>.(swipeEdge: Int) -> ContentTransform,
)

/**
* Screen transitions that feel native on each platform.
*/
@Composable
internal expect fun rememberNavigationTransitions(): NavigationTransitions
Original file line number Diff line number Diff line change
@@ -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<Scene<NavKey>>.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<Scene<NavKey>>.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 <T> transitionTween(durationMillis: Int, easing: Easing): TweenSpec<T> = tween(durationMillis, easing = easing)
Loading