From cb96627ae8763046a3e6ffa8de7213c2023b0913 Mon Sep 17 00:00:00 2001 From: Nino van Hooff Date: Thu, 22 Oct 2026 15:07:47 +0200 Subject: [PATCH 1/4] Use platform-specific navigation transitions iOS now mimics the UINavigationController push/pop: the new screen slides in over the full width while the screen underneath moves 30% and dims slightly. The back swipe follows the finger, and switching tabs no longer animates, like UITabBarController. Android keeps the existing slide-and-fade. Co-Authored-By: Claude Opus 5.5 --- .../template/core/ui/theme/AppColorScheme.kt | 2 + .../core/ui/theme/AppColorSchemeDark.kt | 1 + .../core/ui/theme/AppColorSchemeLight.kt | 1 + .../NavigationTransitions.android.kt | 27 ++++++ .../q42/template/navigation/BottomBarTab.kt | 13 +++ .../q42/template/navigation/NavEntryForKey.kt | 6 +- .../q42/template/navigation/NavigationRoot.kt | 31 +------ .../navigation/NavigationTransitions.kt | 19 ++++ .../navigation/NavigationTransitions.ios.kt | 89 +++++++++++++++++++ 9 files changed, 159 insertions(+), 30 deletions(-) create mode 100644 shared/src/androidMain/kotlin/nl/q42/template/navigation/NavigationTransitions.android.kt create mode 100644 shared/src/commonMain/kotlin/nl/q42/template/navigation/NavigationTransitions.kt create mode 100644 shared/src/iosMain/kotlin/nl/q42/template/navigation/NavigationTransitions.ios.kt 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..dc83acb3 --- /dev/null +++ b/shared/src/androidMain/kotlin/nl/q42/template/navigation/NavigationTransitions.android.kt @@ -0,0 +1,27 @@ +package nl.q42.template.navigation + +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 + +@Composable +internal actual fun rememberNavigationTransitions(): NavigationTransitions = remember { + NavigationTransitions( + transitionSpec = { + slideInHorizontally { it } + fadeIn() togetherWith + slideOutHorizontally { -it } + fadeOut() + }, + popTransitionSpec = { + slideInHorizontally { -it } + fadeIn() togetherWith + slideOutHorizontally { it } + fadeOut() + }, + predictivePopTransitionSpec = { + slideInHorizontally { -it } + fadeIn() togetherWith + slideOutHorizontally { it } + fadeOut() + }, + ) +} 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..72a966ca 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 @@ -90,6 +82,7 @@ fun NavigationRoot() { 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. @@ -103,9 +96,9 @@ fun NavigationRoot() { rememberViewModelStoreNavEntryDecorator() ), 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 +118,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..a567c4ac --- /dev/null +++ b/shared/src/iosMain/kotlin/nl/q42/template/navigation/NavigationTransitions.ios.kt @@ -0,0 +1,89 @@ +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 +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, TransitionEasing) + }, + popTransitionSpec = { + if (isTabSwitch()) noTransition() else slide(SlideDirection.Right, isPop = true, dimColor, TransitionEasing) + }, + // The back swipe drives the transition progress, so it must follow the finger linearly + predictivePopTransitionSpec = { swipeEdge -> + val towards = if (swipeEdge == NavigationEvent.EDGE_RIGHT) SlideDirection.Left else SlideDirection.Right + slide(towards, isPop = true, dimColor, 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, + easing: Easing, +): ContentTransform { + val enter = if (isPop) { + slideIntoContainer(towards, transitionTween(easing), initialOffset = ::parallaxOffset) + + unveilIn(transitionTween(easing), initialColor = dimColor) + } else { + slideIntoContainer(towards, transitionTween(easing)) + } + val exit = if (isPop) { + slideOutOfContainer(towards, transitionTween(easing)) + } else { + slideOutOfContainer(towards, transitionTween(easing), targetOffset = ::parallaxOffset) + + veilOut(transitionTween(easing), targetColor = dimColor) + } + return ContentTransform(enter, exit) +} + +private fun parallaxOffset(fullWidth: Int): Int = (fullWidth * PARALLAX_FRACTION).toInt() + +private fun transitionTween(easing: Easing): TweenSpec = tween(TRANSITION_DURATION_MILLIS, easing = easing) From fb8f5a966364fb9db5363cd5935bced6e09d0727 Mon Sep 17 00:00:00 2001 From: Nino van Hooff Date: Tue, 10 Nov 2026 14:59:40 +0100 Subject: [PATCH 2/4] Skip tab-switch back swipe animation and finish iOS back swipe faster Ported from GoingsOn.CMP#47: the predictive back transition now also returns noTransition() when going back between two tabs, like the regular pop. After releasing a back swipe, navigation3 finishes in the remaining part of the predictive duration; 250 ms matches UIKit instead of 500 ms. Co-Authored-By: Claude Sonnet 5.5 --- .../navigation/NavigationTransitions.ios.kt | 34 +++++++++++++------ 1 file changed, 23 insertions(+), 11 deletions(-) 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 index a567c4ac..d5f547f7 100644 --- a/shared/src/iosMain/kotlin/nl/q42/template/navigation/NavigationTransitions.ios.kt +++ b/shared/src/iosMain/kotlin/nl/q42/template/navigation/NavigationTransitions.ios.kt @@ -28,6 +28,13 @@ import nl.q42.template.core.ui.theme.AppTheme * 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) @@ -37,15 +44,19 @@ internal actual fun rememberNavigationTransitions(): NavigationTransitions { return remember(dimColor) { NavigationTransitions( transitionSpec = { - if (isTabSwitch()) noTransition() else slide(SlideDirection.Left, isPop = false, dimColor, TransitionEasing) + 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, TransitionEasing) + 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 -> - val towards = if (swipeEdge == NavigationEvent.EDGE_RIGHT) SlideDirection.Left else SlideDirection.Right - slide(towards, isPop = true, dimColor, LinearEasing) + 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) + } }, ) } @@ -67,23 +78,24 @@ private fun AnimatedContentTransitionScope>.slide( towards: SlideDirection, isPop: Boolean, dimColor: Color, + durationMillis: Int, easing: Easing, ): ContentTransform { val enter = if (isPop) { - slideIntoContainer(towards, transitionTween(easing), initialOffset = ::parallaxOffset) + - unveilIn(transitionTween(easing), initialColor = dimColor) + slideIntoContainer(towards, transitionTween(durationMillis, easing), initialOffset = ::parallaxOffset) + + unveilIn(transitionTween(durationMillis, easing), initialColor = dimColor) } else { - slideIntoContainer(towards, transitionTween(easing)) + slideIntoContainer(towards, transitionTween(durationMillis, easing)) } val exit = if (isPop) { - slideOutOfContainer(towards, transitionTween(easing)) + slideOutOfContainer(towards, transitionTween(durationMillis, easing)) } else { - slideOutOfContainer(towards, transitionTween(easing), targetOffset = ::parallaxOffset) + - veilOut(transitionTween(easing), targetColor = dimColor) + 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(easing: Easing): TweenSpec = tween(TRANSITION_DURATION_MILLIS, easing = easing) +private fun transitionTween(durationMillis: Int, easing: Easing): TweenSpec = tween(durationMillis, easing = easing) From 3026bfdffd712b97a53009fac9840c508e7967a3 Mon Sep 17 00:00:00 2001 From: Nino van Hooff Date: Tue, 10 Nov 2026 15:00:26 +0100 Subject: [PATCH 3/4] Ignore back while a screen is animating in or out Ported from GoingsOn.CMP (1e5a6ef1): a back swipe that starts during a push made NavDisplay seek a new transition while the push was still running, so screens ended up in the wrong place and jumped to the end. A decorator now disables back while the entry's transition is running. Co-Authored-By: Claude Sonnet 5.5 --- ...ckBackDuringTransitionNavEntryDecorator.kt | 32 +++++++++++++++++++ .../q42/template/navigation/NavigationRoot.kt | 5 ++- 2 files changed, 36 insertions(+), 1 deletion(-) create mode 100644 shared/src/commonMain/kotlin/nl/q42/template/navigation/BlockBackDuringTransitionNavEntryDecorator.kt 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/NavigationRoot.kt b/shared/src/commonMain/kotlin/nl/q42/template/navigation/NavigationRoot.kt index 72a966ca..3a1ee746 100644 --- a/shared/src/commonMain/kotlin/nl/q42/template/navigation/NavigationRoot.kt +++ b/shared/src/commonMain/kotlin/nl/q42/template/navigation/NavigationRoot.kt @@ -78,6 +78,8 @@ 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) } @@ -93,7 +95,8 @@ fun NavigationRoot() { modifier = Modifier.consumeWindowInsets(PaddingValues(bottom = visibleBottomBarHeight)), entryDecorators = listOf( rememberSaveableStateHolderNavEntryDecorator(), - rememberViewModelStoreNavEntryDecorator() + rememberViewModelStoreNavEntryDecorator(), + rememberBlockBackDuringTransitionNavEntryDecorator(), ), backStack = backStack, transitionSpec = transitions.transitionSpec, From 46c44e1ef3591121cefe13df912db30a1f37370e Mon Sep 17 00:00:00 2001 From: Nino van Hooff Date: Tue, 10 Nov 2026 15:22:49 +0100 Subject: [PATCH 4/4] Crossfade tabs on Android instead of sliding Ported from GoingsOn: fade only the top screen over the opaque screen underneath. Co-Authored-By: Claude Sonnet 5.5 --- .../NavigationTransitions.android.kt | 46 ++++++++++++++++--- 1 file changed, 40 insertions(+), 6 deletions(-) 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 index dc83acb3..66301a1e 100644 --- a/shared/src/androidMain/kotlin/nl/q42/template/navigation/NavigationTransitions.android.kt +++ b/shared/src/androidMain/kotlin/nl/q42/template/navigation/NavigationTransitions.android.kt @@ -1,5 +1,10 @@ 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 @@ -7,21 +12,50 @@ 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 = { - slideInHorizontally { it } + fadeIn() togetherWith - slideOutHorizontally { -it } + fadeOut() + if (isTabSwitch()) { + fadeInOverPreviousTab() + } else { + slideInHorizontally { it } + fadeIn() togetherWith + slideOutHorizontally { -it } + fadeOut() + } }, popTransitionSpec = { - slideInHorizontally { -it } + fadeIn() togetherWith - slideOutHorizontally { it } + fadeOut() + if (isTabSwitch()) fadeOutOverNextTab() else slideBack() }, predictivePopTransitionSpec = { - slideInHorizontally { -it } + fadeIn() togetherWith - slideOutHorizontally { it } + fadeOut() + 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()