Navigation3でボトムナビゲーションを実装する——タブごとのバックスタック管理と SceneDecoratorStrategy の活用

2026年5月16日土曜日

Android 技術記事 手順チェッカー

t f B! P L

概要

Navigation3を使用してボトムナビゲーションを実装する際、複雑になりやすいのがタブごとのバックスタック管理と UI レイアウトの構成です。この記事では、手順チェッカーアプリで採用している実装方法を紹介します。タブごとに独立したバックスタックを用意し、SceneDecoratorStrategy でボトムナビゲーションを組み込むことで、保守性の高い構成を実現できます。

Navigation3 のボトムナビゲーション実装の課題

Navigation3 で複数タブのナビゲーションを実装する場合、従来のシングルバックスタック方式では、タブを切り替えるたびにナビゲーション履歴が共有されてしまい、ユーザーの操作が直感的ではなくなります。また、親のバックスタックに複数の NavDisplay を配置する方法もありますが、この場合はバックスタック管理が複雑になり、画面遷移時の状態管理が煩雑になりやすいです。

タブごとのバックスタック管理の採用

手順チェッカーでは、タブごとに独立したバックスタックを用意する方法を採用しました。これにより、各タブが独立したナビゲーション履歴を持つため、タブ切り替え時にバックスタックが保持されます。実装は公式ドキュメントの「Multiple back stacks」レシピ(https://developer.android.com/guide/navigation/navigation-3/recipes/multiple-backstacks?hl=ja)を参照し、このサンプルのコードを流用しています。

実装の中心となるのが NavigationState クラスです。以下のように topLevelRoute ごとにバックスタックを管理します。


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() }
    }
}

backStacks は Map<NavKey, NavBackStack<NavKey>> として各タブのバックスタックを保持します。rememberSaveableStateHolderNavEntryDecorator と rememberViewModelStoreNavEntryDecorator の組み合わせにより、タブ切り替え時に UI 状態と ViewModel が保持されます。

バックスタックの初期化と復元

MainActivity の MainContent 関数では、rememberNavigationState を呼び出してナビゲーション状態を初期化します。


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

startRoute で初期表示タブを指定し、topLevelRoutes で全タブを登録します。rememberNavigationState 内部では、各タブに対して rememberNavBackStack を呼び出し、バックスタック履歴が必要に応じて復元されます。

バックスタックは最大 2 段階まで保持するように設定されており、これによってメモリ使用量を抑制しつつ、十分なナビゲーション履歴を保持できます。

レイアウト実装の方針:SceneDecoratorStrategy での ボトムナビゲーション組み込み

ボトムナビゲーションのレイアウトを直接 Scaffold で実装すると、複数 Scaffold の入れ子や if 分岐が増えて、コードの複雑性が高まります。手順チェッカーでは、代わりに SceneDecoratorStrategy を利用してボトムナビゲーションを組み込む方式を採用しました。

各 Screen では Scaffold を個別に実装し、ボトムナビゲーションは AppSceneDecoratorStrategy で一元管理します。これにより、タブレット対応などで UI 戦略を変更する際に、Strategy を差し替えるだけで対応できる拡張性を確保しています。単一の Scaffold に対して NavKey で大量の if 分岐を実装することも避けることができます。


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] で、各画面がボトムナビゲーションを表示するかを判定します。メタデータ経由で制御することで、レイアウト判定が明示的になり、コードの可読性が向上します。

ボトムナビゲーション UI の実装

ボトムナビゲーション UI は BottomBarDecoratedScene で実装されます。Column でレイアウトを構成し、Box で元のコンテンツを Modifier.weight(1f) で配置します。この方式により、Scaffold の入れ子を避け、フラットな構造を保つことができます。


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) },
            )
            // その他のタブ...
        }
    }
}

タブ選択時は navigator.navigate() で対応するトップレベルルートに遷移します。前のタブ画面のバックスタックが保持されているため、タブ切り替え後、再度そのタブに戻ると、以前の画面状態が復元されます。

アニメーション制御とメタデータの活用

ボトムナビゲーション表示有無に応じて、画面遷移のアニメーションを変え分けています。メタデータを確認し、ボトムナビゲーションが表示される画面の場合はフェードアニメーション、それ以外はスライドアニメーションを適用します。


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

これにより、ユーザーに対して「トップレベルのタブ間の遷移」と「詳細画面への遷移」という異なる操作を、視覚的に区別することができます。

まとめ

Navigation3 でボトムナビゲーションを実装する際、タブごとのバックスタック管理と SceneDecoratorStrategy の組み合わせが有効です。この方式により、以下のメリットが得られます。

まず、タブごとに独立したバックスタックを用意することで、ユーザーの操作が直感的になり、タブ切り替え時に状態が保持されます。次に、SceneDecoratorStrategy でボトムナビゲーションを一元管理することで、Screen レベルでの Scaffold 入れ子を避けられ、コードが複雑化しません。さらに、メタデータ経由で画面ごとのボトムナビゲーション表示要否を制御できるため、拡張性も高まります。

特に、タブレット対応のように UI 戦略を変更する際も、Strategy を差し替えるだけで対応できるため、将来の機能拡張を見据えた堅牢な設計が実現できます。

このブログを検索

Smart Hide Launcher の名称を Hitoma Launcher に変更しました

Smart Hide Launcher の名称を、Hitoma Launcher に変更しました。アプリアイコンも新しくなります。 コンセプトは「必要なアプリだけ」です。 これまでの名前は、アプリを「隠す」という機能を中心に表していました。今回の名称変更では、必要なアプリを選...

QooQ