Implementing Bottom Navigation with Navigation3 — Per-Tab Back Stack Management and Leveraging SceneDecoratorStrategy

Saturday, 16 May 2026

Android Procedure Checker Technical

t f B! P L

Overview

When implementing bottom navigation with Navigation3, managing per-tab back stacks and UI layout can become complex. This article describes the implementation approach used in the Procedure Checker app. By providing separate back stacks for each tab and integrating the bottom navigation via SceneDecoratorStrategy, a maintainable architecture can be achieved.

Challenges in Implementing Bottom Navigation with Navigation3

When implementing multi-tab navigation with Navigation3, a traditional single back stack approach causes navigation history to be shared across tabs, making user interactions unintuitive. Another option is to place multiple NavDisplay instances on the parent back stack, but this approach complicates back stack management and makes state handling during navigation transitions cumbersome.

Adopting Per-Tab Back Stack Management

In Procedure Checker, we adopted an approach that provides independent back stacks for each tab. This allows each tab to maintain its own navigation history so that the back stack is preserved when switching tabs. The implementation follows the official "Multiple back stacks" recipe (https://developer.android.com/guide/navigation/navigation-3/recipes/multiple-backstacks?hl=ja) and reuses the sample code.

At the core of the implementation is the NavigationState class. It manages a back stack for each topLevelRoute as shown below.


class NavigationState(
    val startRoute: NavKey,
    topLevelRoute: MutableState<NavKey>,
    val backStacks: Map<NavKey, NavBackStack<NavKey>>,
) {
    var topLevelRoute: NavKey by topLevelRoute

    @Composable
    fun toDecoratedEntries(
        entryProvider: (NavKey) -> NavEntry<NavKey>,
    ): List<NavEntry<NavKey>> {
        val decoratedEntries = backStacks.mapValues { (_, stack) ->
            val decorators = listOf(
                rememberSaveableStateHolderNavEntryDecorator<NavKey>(),
                rememberViewModelStoreNavEntryDecorator<NavKey>(),
            )
            rememberDecoratedNavEntries(
                backStack = stack,
                entryDecorators = decorators,
                entryProvider = entryProvider,
            )
        }
        return getTopLevelRoutesInUse().flatMap { decoratedEntries[it] ?: emptyList() }
    }
}

The backStacks field holds each tab's back stack as a Map<NavKey, NavBackStack<NavKey>>. The combination of rememberSaveableStateHolderNavEntryDecorator and rememberViewModelStoreNavEntryDecorator preserves UI state and ViewModel when switching tabs.

Initializing and Restoring Back Stacks

In MainActivity's MainContent function, rememberNavigationState is called to initialize the navigation state.


val navState = rememberNavigationState(
    startRoute = HomeRoute.Procedures,
    topLevelRoutes = setOf(
        HomeRoute.Procedures,
        HomeRoute.ProcedureCheckerList,
        HomeRoute.Settings,
    ),
)

startRoute specifies the initially displayed tab and topLevelRoutes registers all tabs. Inside rememberNavigationState, rememberNavBackStack is called for each tab so that back stack history is restored as needed.

Back stacks are configured to retain up to two levels, which reduces memory usage while maintaining sufficient navigation history.

Layout Strategy: Incorporating Bottom Navigation via SceneDecoratorStrategy

Implementing the bottom navigation layout directly with Scaffold can increase complexity due to nested Scaffolds and conditional branches. Instead, Procedure Checker adopts an approach that incorporates bottom navigation using SceneDecoratorStrategy.

Each Screen implements its own Scaffold, while the bottom navigation is centrally managed by AppSceneDecoratorStrategy. This provides flexibility to change UI strategies—such as for tablets—simply by swapping the strategy. It also avoids implementing a large number of conditional NavKey branches in a single Scaffold.


class AppSceneDecoratorStrategy(
    private val navigator: Navigator,
    private val navState: NavigationState,
) : SceneDecoratorStrategy<NavKey> {

    override fun SceneDecoratorStrategyScope<NavKey>.decorateScene(
        scene: Scene<NavKey>,
    ): Scene<NavKey> {
        val showBottomBar = scene.metadata[ShowBottomBarKey] == true

        return BottomBarDecoratedScene(
            original = scene,
            navigator = navigator,
            navState = navState,
            showBottomBar = showBottomBar,
        )
    }
}

scene.metadata[ShowBottomBarKey] determines whether each screen displays the bottom navigation. Controlling this via metadata makes layout decisions explicit and improves code readability.

Implementing the Bottom Navigation UI

The bottom navigation UI is implemented in BottomBarDecoratedScene. The layout uses a Column with the original content placed in a Box using Modifier.weight(1f). This approach avoids nested Scaffolds and maintains a flat structure.


Column(modifier = Modifier.fillMaxSize()) {
    Box(modifier = Modifier.weight(1f)) {
        original.content()
    }
    if (showBottomBar) {
        NavigationBar(windowInsets = WindowInsets(0)) {
            NavigationBarItem(
                icon = { Icon(painterResource(R.drawable.ic_menu_procedure), contentDescription = null) },
                label = { Text(stringResource(R.string.procedure)) },
                selected = current is HomeRoute.Procedures,
                onClick = dropUnlessResumed { navigator.navigate(HomeRoute.Procedures) },
            )
            // その他のタブ...
        }
    }
}

When a tab is selected, navigator.navigate() transitions to the corresponding top-level route. Because the previous tab's back stack is preserved, returning to that tab restores its previous screen state.

Animation Control and Using Metadata

Transitions use different animations depending on whether the bottom navigation is shown. By checking metadata, fade animations are applied for screens where the bottom navigation is visible, and slide animations are used otherwise.


transitionSpec = { if (targetState.metadata[ShowBottomBarKey] == true) fadeTransition else forwardTransition },
popTransitionSpec = { if (initialState.metadata[ShowBottomBarKey] == true) fadeTransition else backwardTransition },

This visually distinguishes between transitions among top-level tabs and transitions into detail screens for the user.

Conclusion

Combining per-tab back stack management with SceneDecoratorStrategy is effective when implementing bottom navigation with Navigation3. This approach provides the following benefits.

First, providing independent back stacks for each tab makes user interactions more intuitive and preserves state when switching tabs. Second, centrally managing bottom navigation with SceneDecoratorStrategy avoids nested Scaffolds at the Screen level and prevents code complexity. Additionally, controlling bottom navigation visibility via metadata increases extensibility.

In particular, when changing UI strategies, such as for tablet support, simply swapping the strategy suffices, enabling a robust design that accommodates future feature expansions.

QooQ