From 0e0e24209526f6682815d2b83a99b51f42e207e0 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Thu, 15 Oct 2026 13:18:26 +0600 Subject: [PATCH] feat(navigation): add custom navigation transition specifications for push and pop actions --- .../kotlin/dev/krtirtho/spotube/App.kt | 8 +- .../spotube/core/navigation/NavTransitions.kt | 76 +++++++++++++++++++ 2 files changed, 83 insertions(+), 1 deletion(-) create mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/navigation/NavTransitions.kt diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/App.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/App.kt index 0cf66c03..fb59f5ec 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/App.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/App.kt @@ -37,6 +37,9 @@ import dev.krtirtho.spotube.core.DebugConfig import dev.krtirtho.spotube.core.navigation.Navigator import dev.krtirtho.spotube.core.navigation.Routes import dev.krtirtho.spotube.core.navigation.TOP_LEVEL_ROUTES +import dev.krtirtho.spotube.core.navigation.navPopTransitionSpec +import dev.krtirtho.spotube.core.navigation.navPredictivePopTransitionSpec +import dev.krtirtho.spotube.core.navigation.navPushTransitionSpec import dev.krtirtho.spotube.core.navigation.rememberNavigationState import dev.krtirtho.spotube.core.navigation.toEntries import dev.krtirtho.spotube.core.ui.component.LocalSharedTransitionScope @@ -210,7 +213,10 @@ fun App( NavDisplay( modifier = Modifier.fillMaxSize(), onBack = navigator::pop, - entries = navigationState.toEntries(koinEntryProvider()) + entries = navigationState.toEntries(koinEntryProvider()), + transitionSpec = navPushTransitionSpec, + popTransitionSpec = navPopTransitionSpec, + predictivePopTransitionSpec = navPredictivePopTransitionSpec ) } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/navigation/NavTransitions.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/navigation/NavTransitions.kt new file mode 100644 index 00000000..742ac190 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/navigation/NavTransitions.kt @@ -0,0 +1,76 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package dev.krtirtho.spotube.core.navigation + +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.CubicBezierEasing +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.ui.unit.IntOffset +import androidx.navigation3.runtime.NavKey +import androidx.navigation3.scene.Scene + +private const val TRANSITION_DURATION_MILLIS = 300 +private const val SLIDE_FRACTION = 0.08f +private val EmphasizedDecelerate = CubicBezierEasing(0.05f, 0.7f, 0.1f, 1f) + +// NavEntry.contentKey defaults to key.toString() +private val topLevelContentKeys = TOP_LEVEL_ROUTES.map { it.toString() }.toSet() + +/** + * The tab a scene belongs to: the last top-level route in its stack. Switching tabs changes + * this, while pushing/popping pages inside a tab doesn't. + */ +private fun Scene.tabRoot(): Any? = + (previousEntries + entries).lastOrNull { it.contentKey in topLevelContentKeys }?.contentKey + +private fun AnimatedContentTransitionScope>.isTabSwitch(): Boolean = + initialState.tabRoot() != targetState.tabRoot() + +/** Material shared-axis (X) style: a short horizontal slide combined with a cross-fade. */ +private fun AnimatedContentTransitionScope>.sharedAxisX( + forward: Boolean +): ContentTransform { + if (isTabSwitch()) return ContentTransform(EnterTransition.None, ExitTransition.None) + + val direction = if (forward) 1 else -1 + val slideSpec = tween( + TRANSITION_DURATION_MILLIS, easing = EmphasizedDecelerate + ) + val enter = slideInHorizontally(slideSpec) { (it * SLIDE_FRACTION).toInt() * direction } + + fadeIn(tween(TRANSITION_DURATION_MILLIS / 2, delayMillis = TRANSITION_DURATION_MILLIS / 6)) + val exit = slideOutHorizontally(slideSpec) { -(it * SLIDE_FRACTION).toInt() * direction } + + fadeOut(tween(TRANSITION_DURATION_MILLIS / 3)) + return enter togetherWith exit +} + +val navPushTransitionSpec: AnimatedContentTransitionScope>.() -> ContentTransform = + { sharedAxisX(forward = true) } + +val navPopTransitionSpec: AnimatedContentTransitionScope>.() -> ContentTransform = + { sharedAxisX(forward = false) } + +val navPredictivePopTransitionSpec: AnimatedContentTransitionScope>.(Int) -> ContentTransform = + { sharedAxisX(forward = false) }