From 32157ff82f3a99f476323c44b4a5c6b37abca688 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Apr 2026 19:16:37 +0000 Subject: [PATCH] fix(nav): keep Home below tab roots, hide back arrow via per-entry flag Previous attempt cleared Home from the back stack on every bottom-nav tap, which broke back-swipe: the user expects swipe-back from any tab to return to Home, and swipe-back from Home to leave the app. Restore that contract while still hiding the back arrow on tab roots: - navBottomBar pops up to (but not including) Route.Home, so sibling tabs are cleared while Home stays at the bottom of the stack. - The new entry is stamped with BOTTOM_NAV_ROOT_KEY on its savedStateHandle. - Nav.canPop returns false when the current entry carries that flag, even though previousBackStackEntry (Home) is non-null. Home itself is the graph's start destination and has no previous entry, so canPop returns false there without needing the flag. https://claude.ai/code/session_01PrirRcL7g8iX7vTqqLTkBS --- .../ui/navigation/NavigationEffects.kt | 7 +++++ .../amethyst/ui/navigation/navs/Nav.kt | 29 ++++++++++++------- 2 files changed, 25 insertions(+), 11 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/NavigationEffects.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/NavigationEffects.kt index 7b113393d..0a186f439 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/NavigationEffects.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/NavigationEffects.kt @@ -41,6 +41,13 @@ const val SKIP_SLIDE_ANIMATION_KEY = "skipSlideAnimation" fun NavBackStackEntry.skipsSlideAnimation(): Boolean = savedStateHandle.get(SKIP_SLIDE_ANIMATION_KEY) == true +// Per-entry hint stamped by Nav.navBottomBar marking that the entry was +// reached via a bottom-nav tab. Used by Nav.canPop so top bars can hide +// the back arrow on tab roots even though Home sits below them. +const val BOTTOM_NAV_ROOT_KEY = "bottomNavRoot" + +fun NavBackStackEntry.isBottomNavRoot(): Boolean = savedStateHandle.get(BOTTOM_NAV_ROOT_KEY) == true + inline fun NavGraphBuilder.composableFromEnd(noinline content: @Composable AnimatedContentScope.(NavBackStackEntry) -> Unit) { composable( enterTransition = { if (targetState.skipsSlideAnimation()) null else slideInHorizontallyFromEnd }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/navs/Nav.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/navs/Nav.kt index 89f05c7ab..df9c92287 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/navs/Nav.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/navs/Nav.kt @@ -24,9 +24,10 @@ import android.annotation.SuppressLint import androidx.compose.material3.DrawerState import androidx.compose.material3.DrawerValue import androidx.compose.runtime.Stable -import androidx.navigation.NavGraph.Companion.findStartDestination import androidx.navigation.NavHostController +import com.vitorpamplona.amethyst.ui.navigation.BOTTOM_NAV_ROOT_KEY import com.vitorpamplona.amethyst.ui.navigation.SKIP_SLIDE_ANIMATION_KEY +import com.vitorpamplona.amethyst.ui.navigation.isBottomNavRoot import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.navigation.routes.getRouteWithArguments import kotlinx.coroutines.CoroutineScope @@ -79,22 +80,28 @@ class Nav( override fun navBottomBar(route: Route) { navigationScope.launch { controller.navigate(route) { - // Clear the back stack down to and including the graph's start - // destination so a bottom-nav tap leaves only the new route in - // the stack. Without inclusive=true, Home would remain below - // the new tab and canPop() would wrongly show a back arrow. - popUpTo(controller.graph.findStartDestination().id) { - inclusive = true + // Clear sibling bottom-nav entries but keep Home (the start + // destination) below, so back-swipe from any tab returns to + // Home and back-swipe from Home leaves the app. + popUpTo(Route.Home) { + inclusive = false } launchSingleTop = true } - // Stamp the entry so composableFromEnd's transition lambdas can - // skip the slide animation when this entry enters or exits. - controller.getBackStackEntry(route).savedStateHandle[SKIP_SLIDE_ANIMATION_KEY] = true + val entry = controller.getBackStackEntry(route) + // Skip the horizontal slide for composableFromEnd transitions. + entry.savedStateHandle[SKIP_SLIDE_ANIMATION_KEY] = true + // Mark this entry as a tab root so canPop hides the back arrow + // even though Home sits below it. + entry.savedStateHandle[BOTTOM_NAV_ROOT_KEY] = true } } - override fun canPop(): Boolean = controller.previousBackStackEntry != null + override fun canPop(): Boolean { + val current = controller.currentBackStackEntry ?: return false + if (current.isBottomNavRoot()) return false + return controller.previousBackStackEntry != null + } override fun popBack() { navigationScope.launch {