feat(android): directional tab transitions in the bottom nav

Tab switches slid in a fixed direction (and sometimes flipped between push/pop feel,
so the direction felt random). Now the slide follows the target tab's position in the
bottom bar: tapping a tab to the right slides in from the right, a tab to the left
slides in from the left — applied across all four enter/exit/popEnter/popExit
transitions so it's consistent regardless of how Compose classifies the move. Detail
screen push/pop transitions are unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
HoneyandClaude Opus 4.8 committed 2026-07-25 10:24:42 +02:00
1 parent 179e0eb902
commit b1a32f5130
1 file changed
+34 -4
@@ -53,6 +53,10 @@ private enum class TopLevel(val route: String, val icon: ImageVector, val label:
private val NavBg = Color(0xF7080814) private val NavBg = Color(0xF7080814)
// Position of a route among the top-level tabs (in bottom-bar order), or -1 if it isn't one.
// Used to slide tab switches toward the side the target tab sits on, instead of a push/pop feel.
private fun topLevelIndex(route: String?): Int = TopLevel.entries.indexOfFirst { it.route == route }
@Composable @Composable
fun AppNavigation() { fun AppNavigation() {
val context = LocalContext.current val context = LocalContext.current
@@ -245,10 +249,36 @@ fun AppNavigation() {
navController = navController, navController = navController,
startDestination = TopLevel.HOME.route, startDestination = TopLevel.HOME.route,
modifier = Modifier.padding(innerPadding), modifier = Modifier.padding(innerPadding),
enterTransition = { slideInHorizontally(initialOffsetX = { it }, animationSpec = tween(280, easing = FastOutSlowInEasing)) + fadeIn(tween(200)) }, // Direction of a tab switch: +1 if the target tab sits to the right of the current
exitTransition = { slideOutHorizontally(targetOffsetX = { -it / 3 }, animationSpec = tween(280, easing = FastOutSlowInEasing)) + fadeOut(tween(180)) }, // one, -1 if to the left, 0 if this isn't a tab-to-tab move (detail push/pop).
popEnterTransition = { slideInHorizontally(initialOffsetX = { -it / 3 }, animationSpec = tween(280, easing = FastOutSlowInEasing)) + fadeIn(tween(200)) }, enterTransition = {
popExitTransition = { slideOutHorizontally(targetOffsetX = { it }, animationSpec = tween(280, easing = FastOutSlowInEasing)) + fadeOut(tween(180)) }, val f = topLevelIndex(initialState.destination.route)
val t = topLevelIndex(targetState.destination.route)
val dir = if (f >= 0 && t >= 0 && f != t) (if (t > f) 1 else -1) else 0
if (dir != 0) slideInHorizontally(tween(280, easing = FastOutSlowInEasing)) { dir * it } + fadeIn(tween(200))
else slideInHorizontally(tween(280, easing = FastOutSlowInEasing)) { it } + fadeIn(tween(200))
},
exitTransition = {
val f = topLevelIndex(initialState.destination.route)
val t = topLevelIndex(targetState.destination.route)
val dir = if (f >= 0 && t >= 0 && f != t) (if (t > f) 1 else -1) else 0
if (dir != 0) slideOutHorizontally(tween(280, easing = FastOutSlowInEasing)) { -dir * it } + fadeOut(tween(180))
else slideOutHorizontally(tween(280, easing = FastOutSlowInEasing)) { -it / 3 } + fadeOut(tween(180))
},
popEnterTransition = {
val f = topLevelIndex(initialState.destination.route)
val t = topLevelIndex(targetState.destination.route)
val dir = if (f >= 0 && t >= 0 && f != t) (if (t > f) 1 else -1) else 0
if (dir != 0) slideInHorizontally(tween(280, easing = FastOutSlowInEasing)) { dir * it } + fadeIn(tween(200))
else slideInHorizontally(tween(280, easing = FastOutSlowInEasing)) { -it / 3 } + fadeIn(tween(200))
},
popExitTransition = {
val f = topLevelIndex(initialState.destination.route)
val t = topLevelIndex(targetState.destination.route)
val dir = if (f >= 0 && t >= 0 && f != t) (if (t > f) 1 else -1) else 0
if (dir != 0) slideOutHorizontally(tween(280, easing = FastOutSlowInEasing)) { -dir * it } + fadeOut(tween(180))
else slideOutHorizontally(tween(280, easing = FastOutSlowInEasing)) { it } + fadeOut(tween(180))
},
) { ) {
composable(TopLevel.HOME.route) { composable(TopLevel.HOME.route) {
HomeScreen( HomeScreen(