feat(navigation): implement fade-through and stack transition animations for navigation

This commit is contained in:
Kingkor Roy Tirtho 2026-10-18 18:31:51 +06:00
parent 75ced1285e
commit 17f2068950

View File

@ -25,6 +25,7 @@ import androidx.compose.animation.core.CubicBezierEasing
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.slideInHorizontally import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally import androidx.compose.animation.slideOutHorizontally
import androidx.compose.animation.togetherWith import androidx.compose.animation.togetherWith
@ -33,7 +34,10 @@ import androidx.navigation3.runtime.NavKey
import androidx.navigation3.scene.Scene import androidx.navigation3.scene.Scene
private const val TRANSITION_DURATION_MILLIS = 300 private const val TRANSITION_DURATION_MILLIS = 300
private const val SLIDE_FRACTION = 0.08f private const val FADE_THROUGH_OUT_MILLIS = 90
private const val FADE_THROUGH_SCALE = 0.94f
private const val SLIDE_FRACTION = 0.25f
private const val BEHIND_SLIDE_FRACTION = 0.1f
private val EmphasizedDecelerate = CubicBezierEasing(0.05f, 0.7f, 0.1f, 1f) private val EmphasizedDecelerate = CubicBezierEasing(0.05f, 0.7f, 0.1f, 1f)
// NavEntry.contentKey defaults to key.toString() // NavEntry.contentKey defaults to key.toString()
@ -49,28 +53,49 @@ private fun Scene<NavKey>.tabRoot(): Any? =
private fun AnimatedContentTransitionScope<Scene<NavKey>>.isTabSwitch(): Boolean = private fun AnimatedContentTransitionScope<Scene<NavKey>>.isTabSwitch(): Boolean =
initialState.tabRoot() != targetState.tabRoot() initialState.tabRoot() != targetState.tabRoot()
/** Material shared-axis (X) style: a short horizontal slide combined with a cross-fade. */ /** Material fade-through: the old tab fades out, the new one fades in while scaling up slightly. */
private fun AnimatedContentTransitionScope<Scene<NavKey>>.sharedAxisX( private fun fadeThrough(): ContentTransform {
val enter = fadeIn(
tween(TRANSITION_DURATION_MILLIS - FADE_THROUGH_OUT_MILLIS, FADE_THROUGH_OUT_MILLIS, EmphasizedDecelerate)
) + scaleIn(
tween(TRANSITION_DURATION_MILLIS - FADE_THROUGH_OUT_MILLIS, FADE_THROUGH_OUT_MILLIS, EmphasizedDecelerate),
initialScale = FADE_THROUGH_SCALE
)
return enter togetherWith fadeOut(tween(FADE_THROUGH_OUT_MILLIS))
}
/**
* Pushed/popped pages inside a tab: the page on top slides + fades while the page behind
* drifts slightly the other way. On pop the leaving page must stay on top (negative target
* z-index), otherwise the page being revealed is drawn over it and its exit is never seen.
*/
private fun AnimatedContentTransitionScope<Scene<NavKey>>.stackTransition(
forward: Boolean forward: Boolean
): ContentTransform { ): ContentTransform {
if (isTabSwitch()) return ContentTransform(EnterTransition.None, ExitTransition.None) if (isTabSwitch()) return fadeThrough()
val direction = if (forward) 1 else -1 val slide = tween<IntOffset>(TRANSITION_DURATION_MILLIS, easing = EmphasizedDecelerate)
val slideSpec = tween<IntOffset>( val enter: EnterTransition
TRANSITION_DURATION_MILLIS, easing = EmphasizedDecelerate val exit: ExitTransition
) if (forward) {
val enter = slideInHorizontally(slideSpec) { (it * SLIDE_FRACTION).toInt() * direction } + enter = slideInHorizontally(slide) { (it * SLIDE_FRACTION).toInt() } +
fadeIn(tween(TRANSITION_DURATION_MILLIS / 2, delayMillis = TRANSITION_DURATION_MILLIS / 6)) fadeIn(tween(TRANSITION_DURATION_MILLIS * 2 / 3))
val exit = slideOutHorizontally(slideSpec) { -(it * SLIDE_FRACTION).toInt() * direction } + exit = slideOutHorizontally(slide) { -(it * BEHIND_SLIDE_FRACTION).toInt() } +
fadeOut(tween(TRANSITION_DURATION_MILLIS / 3)) fadeOut(tween(TRANSITION_DURATION_MILLIS / 2))
return enter togetherWith exit } else {
enter = slideInHorizontally(slide) { -(it * BEHIND_SLIDE_FRACTION).toInt() } +
fadeIn(tween(TRANSITION_DURATION_MILLIS * 2 / 3))
exit = slideOutHorizontally(slide) { (it * SLIDE_FRACTION).toInt() } +
fadeOut(tween(TRANSITION_DURATION_MILLIS * 2 / 3))
}
return ContentTransform(enter, exit, targetContentZIndex = if (forward) 0f else -1f)
} }
val navPushTransitionSpec: AnimatedContentTransitionScope<Scene<NavKey>>.() -> ContentTransform = val navPushTransitionSpec: AnimatedContentTransitionScope<Scene<NavKey>>.() -> ContentTransform =
{ sharedAxisX(forward = true) } { stackTransition(forward = true) }
val navPopTransitionSpec: AnimatedContentTransitionScope<Scene<NavKey>>.() -> ContentTransform = val navPopTransitionSpec: AnimatedContentTransitionScope<Scene<NavKey>>.() -> ContentTransform =
{ sharedAxisX(forward = false) } { stackTransition(forward = false) }
val navPredictivePopTransitionSpec: AnimatedContentTransitionScope<Scene<NavKey>>.(Int) -> ContentTransform = val navPredictivePopTransitionSpec: AnimatedContentTransitionScope<Scene<NavKey>>.(Int) -> ContentTransform =
{ sharedAxisX(forward = false) } { stackTransition(forward = false) }