From 32157ff82f3a99f476323c44b4a5c6b37abca688 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Apr 2026 19:16:37 +0000 Subject: [PATCH 1/2] 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 { From 41aa908fbeffb871a89d073eb54068eedf416be1 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Apr 2026 19:19:10 +0000 Subject: [PATCH 2/2] refactor(nav): collapse SKIP_SLIDE_ANIMATION_KEY into BOTTOM_NAV_ROOT_KEY MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both flags were stamped at the same call site (navBottomBar) and meant the same thing — "this entry is a bottom-nav tab root". Collapse them into a single key and use isBottomNavRoot() in composableFromEnd's transition lambdas. https://claude.ai/code/session_01PrirRcL7g8iX7vTqqLTkBS --- .../ui/navigation/NavigationEffects.kt | 22 ++++++++----------- .../amethyst/ui/navigation/navs/Nav.kt | 10 +++------ 2 files changed, 12 insertions(+), 20 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 0a186f439..cf4a4ee7e 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 @@ -34,26 +34,22 @@ import androidx.navigation.NavGraphBuilder import androidx.navigation.compose.composable import androidx.navigation.toRoute -// Per-entry hint stored on NavBackStackEntry.savedStateHandle by -// Nav.navBottomBar. When present, composableFromEnd skips the horizontal -// slide so bottom-bar taps fall back to the NavHost-level fade. -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. +// reached via a bottom-nav tab. Used in two places: +// - composableFromEnd skips the horizontal slide on tab entries so +// bottom-bar taps fall back to the NavHost-level fade. +// - Nav.canPop hides the back arrow on tab roots even though Home +// sits below them in the stack. 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 }, - exitTransition = { if (targetState.skipsSlideAnimation()) null else scaleOut }, - popEnterTransition = { if (initialState.skipsSlideAnimation()) null else scaleIn }, - popExitTransition = { if (initialState.skipsSlideAnimation()) null else slideOutHorizontallyToEnd }, + enterTransition = { if (targetState.isBottomNavRoot()) null else slideInHorizontallyFromEnd }, + exitTransition = { if (targetState.isBottomNavRoot()) null else scaleOut }, + popEnterTransition = { if (initialState.isBottomNavRoot()) null else scaleIn }, + popExitTransition = { if (initialState.isBottomNavRoot()) null else slideOutHorizontallyToEnd }, content = content, ) } 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 df9c92287..3d2a94b4f 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 @@ -26,7 +26,6 @@ import androidx.compose.material3.DrawerValue import androidx.compose.runtime.Stable 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 @@ -88,12 +87,9 @@ class Nav( } launchSingleTop = 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 + // Mark this entry as a tab root: hides the back arrow in canPop + // and skips the horizontal slide in composableFromEnd. + controller.getBackStackEntry(route).savedStateHandle[BOTTOM_NAV_ROOT_KEY] = true } }