Navigation 3 の Navigation Graph パターンまとめ

 

🤔 Navigation Graph はもう navigation() だけじゃない

Jetpack Compose 向けの Navigation 3 では、従来の Navigation Component にあった navigation() を使ったネストした Navigation Graph の考え方がなくなりました。

その代わりに、「コードをどう整理するか」と「BackStack をどう分けるか」を分離して考える設計になっています。

この記事では、Navigation 3 でよく使われる 3 つの Navigation Graph パターンを紹介します。

 

🤔 Navigation 3 の3つのパターン


Navigation 3
│
├─ コードを整理したい
│   ├─ Extension Functions
│   └─ Sealed NavKeys
│
└─ BackStack を分けたい
    └─ Nested NavDisplay

ポイントは、

- コードを整理する方法
- BackStack を分離する方法

は別の話だということです。

 

🤔 Extension Functions で画面を機能ごとに分割する

Navigation 3 では entry() を並べて画面を登録します。

画面数が増えてくると、1つのファイルに全部書くのは見づらくなります。

そんなときは Extension Function へ切り出すのが最もシンプルです。


// AuthModule.kt
fun EntryProviderScope<NavKey>.authGraph() {
    entry<Login> {
        LoginScreen()
    }

    entry<SignUp> {
        SignUpScreen()
    }
}

メイン側では呼び出すだけです。


val entryProvider = entryProvider {
    authGraph()

    entry<Home> {
        HomeScreen()
    }
}

NavDisplay(
    backStack = backStack,
    entryProvider = entryProvider
)

これだけで認証画面を別ファイルへ分離できます。

メリット
- Feature 単位で管理できる
- モジュール分割しやすい
- BackStack は1つなので構成はシンプル

 

🤔 Sealed NavKey で画面をグループ化する

もう1つの整理方法が NavKey をグループ化する 方法です。


sealed interface AuthKey : NavKey {

    @Serializable
    data object Login : AuthKey

    @Serializable
    data object SignUp : AuthKey
}

sealed interface MainKey : NavKey {

    @Serializable
    data object Home : MainKey

    @Serializable
    data class Detail(
        val id: String
    ) : MainKey
}

画面遷移も名前空間付きで分かりやすくなります。

Navigation 3 では Graph は存在しませんが、

- Auth 系
- Main 系
- Settings 系

のように論理的に整理できます。

メリット
- 名前空間が分かりやすい
- 型安全
- Destination が探しやすい

 

🤔 NavDisplay をネストする

ここだけは少し意味が変わります。

これはコード整理ではなく BackStack を分離する方法です。

例えば

- Bottom Navigation
- タブ
- Onboarding
- 独立したフロー

では、それぞれ独自の履歴を持たせたいことがあります。

その場合は NavDisplay をネストします。


entry<MainTabs> {
    MainTabsScreen()
}


@Composable
fun MainTabsScreen() {

    val tabBackStack =
        rememberNavBackStack(HomeTab)

    NavDisplay(
        backStack = tabBackStack,
        entryProvider = entryProvider {

            entry<HomeTab> {
                HomeScreen()
            }

            entry<SearchTab> {
                SearchScreen()
            }

            entry<ProfileTab> {
                ProfileScreen()
            }
        }
    )
}

ここでは親とは別の NavBackStack を持っています。


Root 
NavDisplay
    │
    └── MainTabs
             │
             └── NavDisplay
                     │
                     ├── Home
                     ├── Search
                     └── Profile

つまり、

NavDisplay = NavBackStack の管理単位

という考え方になります。

Bottom Navigation で各タブの履歴を保持できるのも、この仕組みによるものです。

 

🤔 どれを選べばいい?

次のように考えると分かりやすいでしょう。


画面を整理したい?
│
├─ Yes
│   ├─ Extension Functions
│   └─ Sealed NavKeys
│
└─ BackStack を分けたい?
    └─ Nested NavDisplay

 

🤔 まとめ

Navigation 3 では、従来の navigation() による Graph のネストはありません。

その代わりに、

- Extension Functions で entry() を機能ごとに分割する
- Sealed NavKey で Destination を整理する
- Nested NavDisplay で独立した NavBackStack を持つ

という3つのパターンを組み合わせて設計します。

特に重要なのは、コードの整理と BackStack の分離は別の概念という点です。

Navigation 3 は Graph をネストするライブラリではなく、NavDisplay と NavBackStack を組み合わせてナビゲーション構造を組み立てるライブラリへと進化しています。

 

🤔 参考


Composeの初期化処理を snapshotFlow と first で待つ

Composeで画面を初期化するとき、次のような問題に遭遇することがあります。


画面が表示される
 ↓ 
Stateを復元する
 ↓ 
非同期データを読み込む
 ↓
UIにデータが反映される
 ↓ 
初期処理を実行する

例えば、保存していたスクロール位置を復元したいとします。

しかし、LaunchedEffect が実行された時点では、まだ Grid にアイテムが存在しないかもしれません。


LaunchedEffect(Unit) { 
    gridState.scrollToItem( 
        savedPosition.index, 
        savedPosition.offset 
    ) 
}

この場合、保存された位置が100番目だったとしても、初期状態のGridにはまだ20個しかアイテムがない可能性があります。


LaunchedEffect
 ↓ 
totalItemsCount = 20
 ↓ 
scrollToItem(100)
 ↓ 
⚠️ まだ100番目のアイテムが存在しない

そこで、snapshotFlowfirst を使います。

 

🧑🏻‍💻 snapshotFlow { }.first { }というパターン


snapshotFlow { 
    composeState 
}.first { 
    condition 
}

これは、次のような処理です。


composeState を監視
 ↓ 
State が変化する
 ↓ 
条件を確認
 ↓ 
condition を満たす
 ↓
次の処理へ進む

first は、条件を満たす最初の値を受け取るとFlowの収集を終了します。

そのため、

UIの状態が初期処理に必要な状態になるまで待つ

のような用途に向いています。

 

🧑🏻‍💻 スクロール位置を復元する

次のように書けます。


LaunchedEffect(Unit) { 
    val savedPosition = viewModel.savedScrollPosition 

    snapshotFlow {
        gridState.layoutInfo.totalItemsCount 
    }.first { 
        it > savedPosition.index 
    } 

    gridState.scrollToItem( 
        savedPosition.index, 
        savedPosition.offset 
    ) 
}

ここで重要なのは、次の2つの状態は同じではないことです。


データが存在する
  ≠
UIのLayoutにアイテムが反映されている

ViewModel がデータを保持していても、Compose の Layout がまだ計算されていないことがあります。

このコードでは、


gridState.layoutInfo.totalItemsCount

を監視しています。

そして、


.first { 
    it > savedPosition.index 
}

によって、保存された位置までアイテムがGridに反映されるのを待ちます。

例えば、保存位置が100の場合は次のようになります。


totalItemsCount = 0
 ↓ 
totalItemsCount = 50
 ↓ 
totalItemsCount = 100
 ↓ 
totalItemsCount = 120
 ↓ 
条件成立
 ↓ 
scrollToItem(100)

このように、scrollToItem() を実行するタイミングを、単なる画面表示時ではなく、UIが実際に必要な状態になったタイミングに合わせることができます。

 

🧑🏻‍💻 なぜ collect ではないのか

snapshotFlow は Flow なので、通常は次のように collect できます。


snapshotFlow {
    gridState.layoutInfo.totalItemsCount
}.collect { count ->
    // countが変化するたびに実行
}

しかし、初期化処理では、継続的に監視したいわけではありません。


必要な状態になるまで待つ
 ↓
初期処理を実行する
 ↓ 
監視を終了する

この場合は、


.first { condition }

が適しています。


snapshotFlow { 
    gridState.layoutInfo.totalItemsCount 
}.first { 
    it > savedPosition.index 
} 

gridState.scrollToItem(...)

first が条件を満たすと、次の処理に進みます。


snapshotFlow
  ↓
first
  ↓
条件成立
  ↓
Flow終了
  ↓
scrollToItem()

 

🧑🏻‍💻 collect と first の使い分け

例えば、スクロール位置を継続的に監視する場合は、collect が自然です。


LaunchedEffect(listState) { 
    snapshotFlow { 
        listState.firstVisibleItemIndex 
    }.collect { index -> 
        // スクロールするたびに処理 
    } 
}

一方、初期状態が揃うまで待つ場合は、first が自然です。


LaunchedEffect(Unit) {
    snapshotFlow {
        gridState.layoutInfo.totalItemsCount
    }.first {
        it > savedPosition.index
    }

    // 初期処理
}

 

🧑🏻‍💻 まとめ

snapshotFlow { }.first { } は、次のようなコードとして理解すると分かりやすいです。


snapshotFlow {
    UIの状態
}.first {
    初期処理に必要な状態になった
}

初期処理()

つまり、

Compose UIの状態が揃うまで待ってから、初期処理を実行する

ためのパターンです。

特に、非同期データの読み込みと Compose の Layout 計算のタイミングがずれるケースでは、非常に有効です。

LazyGridState.layoutInfo のようなLayout情報は、再レイアウトやスクロールによって更新される Observable な状態であり、Android公式ドキュメントでも副作用で扱う場合は snapshotFlow を使う方法が示されています。


Compose State を Flow に変換する唯一の方法、それが snapshotFlow

snapshotFlow がなぜCompose専用に用意されているのか、その仕組みと実践的な使い方を紹介します。

Jetpack ComposeにはFlowを扱う機会が数多くあります。

Repositoryからデータを受け取るために Flow を collect したり、ViewModel が公開する StateFlowcollectAsState() したりすることは、すでに日常的なパターンになっています。

一方で、Compose State を逆に Flow へ変換したいと思ったことはないでしょうか。

例えば、

- スクロール位置を監視したい。
- 選択中のタブが変わったことを Analytics へ送信したい。
- TextField の入力を debounce() したい。
- Compose State を Flow の演算子で加工したい。

このような場面で登場するのが snapshotFlow です。

そして実は、Compose StateをFlowへ変換するCompose専用のAPIは snapshotFlow だけです。

 

🧑🏻‍💻 なぜflow {}ではダメなのか

最初に思い付くのは、普通の flow ではないでしょうか。


flow {
    emit(state.value)
}

もちろんこれは動きます。

しかし、一度値を送信するだけです。

その後 state.value が変化しても、新しい値は流れません。

なぜなら、flow {} はCompose Stateの変更を監視する仕組みを持っていないからです。

 

🧑🏻‍💻 snapshotFlow は Compose Snapshot を監視する

snapshotFlow は Compose の Snapshot システムと統合されています。


LaunchedEffect(Unit) {
    snapshotFlow {
        listState.firstVisibleItemIndex
    }.collect { index ->
        println(index)
    }
}

ラムダ内で読み取った Compose State を Compose 自身が監視し、

値が変化すると新しい値を Flow へ流します。

つまり、自分で emit() を書く必要はありません。

 

🧑🏻‍💻 イメージするとこうなる


Compose State
     │
     ▼
Compose Snapshot
     │
     ▼
snapshotFlow
     │
     ▼
  Kotlin Flow
     │
     ▼
map / filter / debounce / collect

Compose の世界と Flow の世界をつないでいるのが snapshotFlow です。

 

🧑🏻‍💻 Cold Flowであることも重要

snapshotFlow は Cold Flow です。

つまり、


val flow = snapshotFlow {
    state.value
}

これだけでは監視は始まりません。

実際に監視が始まるのは collect() された瞬間です。


LaunchedEffect(Unit) {
    snapshotFlow {
        state.value
    }.collect {
        // Side Effect
    }
}

そのため、Compose では LaunchedEffect と組み合わせて使うのが一般的です。

 

🧑🏻‍💻 実践例① スクロール位置を監視する

最もよく使われる例です。


LaunchedEffect(Unit) {
    snapshotFlow {
        listState.firstVisibleItemIndex
    }.collect(viewModel::onScrollChanged)
}

例えば、

- Toolbarの表示・非表示
- FABの表示切り替え
- Analytics送信

などに利用できます。

 

🧑🏻‍💻 実践例② Analyticsを送信する

Compose State の変化をイベントとして扱えます。


LaunchedEffect(Unit) {
    snapshotFlow {
        selectedTab
    }.collect(analytics::logTabSelected)
}

UIロジックを汚さず、副作用だけを分離できます。

 

🧑🏻‍💻 実践例③ Flow演算子を組み合わせる

snapshotFlow は通常の Flow なので、そのまま演算子を利用できます。


LaunchedEffect(Unit) {
    snapshotFlow {
        query
    }
        .debounce(300)
        .distinctUntilChanged()
        .collect(viewModel::search)
}

Compose Stateを、そのままリアクティブな Flow パイプラインへ接続できます。

 

🧑🏻‍💻 snapshotFlow が監視するのは Compose State だけ

重要なのは、監視対象はラムダ内で読み取ったCompose Stateだけという点です。


snapshotFlow {
    listState.firstVisibleItemIndex
}

このようなCompose Stateは監視できます。

一方、


var count = 0

snapshotFlow {
    count
}

通常の変数は Compose Snapshot が管理していないため、変更しても Flow は新しい値を流しません。

 

🧑🏻‍💻 まとめ

Compose にはさまざまな Flow API があります。

しかし、Compose State を Flow へ変換するために設計された Compose 専用 API は snapshotFlow だけです。

その役割は単に State を Flow へ変換することではありません。

Compose Snapshot とKotlin Flow を橋渡しし、

- スクロール監視
- Analytics
- TextField の入力監視
- Flow 演算子との連携

など、Compose で副作用を書くための基盤となるAPIです。

snapshotFlow を理解すると、「Compose State を Flowとして扱う」という考え方が自然になり、Composeらしい副作用の書き方が身に付くはずです。


Jetpack Composeで snapshotFlow の使いどころ

Jetpack Composeにおいて、Composeの「状態(State)」と「非ComposeのAPI(Flowなど)」を連携させたい場合に snapshotFlow が非常に有効です。

具体的には、以下のようなケースで使用します。

 

🧑🏻‍💻 1. Composeの状態変化をFlowとして監視したいとき

Composeの State オブジェクトの値が変化したタイミングで、何らかの副作用(Side-effect)を起こしたい場合に使用します。

コード例:TextFieldの入力値を監視して検索APIを呼ぶ


LaunchedEffect(textState) {
    snapshotFlow { textState.value }
        .debounce(300L) // 300ms入力が止まったら処理
        .distinctUntilChanged()
        .collect { query ->
            viewModel.search(query)
        }
}

 

🧑🏻‍💻 2. 非ComposeのAPIと値を同期させたいとき

Composeの管理外にあるライブラリやシステムAPIに対し、Compose側の状態の変化を伝えたい場合です。

コード例:スクロール位置をトラッキングツールへ送信


LaunchedEffect(listState) {
    snapshotFlow { listState.firstVisibleItemIndex }
        .filter { it > 0 }
        .collect { index ->
            Analytics.log("ScrollPosition", index)
        }
}

 

🧑🏻‍💻 3. 複雑な条件で「状態の変化」をトリガーにしたいとき

LaunchedEffect は状態が更新されるたびに毎回実行されますが、snapshotFlow を使うと、

Flowの演算子(filter, distinctUntilChanged など)を組み合わせる

ことで、「特定の値になったときだけ」といった細かい条件付けが容易になります。

 

🧑🏻‍💻 注意点:使うべきではないケース

すべての状態監視に snapshotFlow を使う必要はありません。

- 単純なUIの更新: 単にComposeのUIを再描画したいだけであれば、snapshotFlow を使わず、Composeの State をそのまま読み取ってUIを構成するのが最も効率的です。

- 計算処理: 高負荷な計算をFlowの中で行う場合は、適切なスレッド(Dispatchers.Defaultなど)で処理を行うよう注意が必要です。

 

🧑🏻‍💻 まとめ

snapshotFlow は、

「Composeのリアクティブな状態」を「Kotlin Flowの強力な演算子」とブリッジさせるためのツール

です。

「状態が変化した時に、何か別のアクション(通信、ログ記録、画面遷移など)を発生させたい」と考えたときが、この機能の使いどころです。

 

🧑🏻‍💻 参考


Jetpack Compose Navigation3 複数 NavBackStack 超シンプル実装

公式サンプルは複雑すぎない?

と思ったので。

 

🤔 実際のアプリはボトムナビゲーションをどう扱っているか

実装に入る前に、主要アプリの挙動を見てみましょう。

ほとんどのアプリは以下のどれかです:

- タブごとに独立した BackStack(最も一般的)
- 1つの共有 BackStack(最もシンプル)
- タブ切り替え時にリセット(常にルートに戻る)

 

🤔 バックボタンの挙動


タブ内の履歴

  ↓

タブのルート

  ↓

アプリ終了

- タブの切り替えは手動(バックボタンでは行わない)
- 各タブは独自の履歴を持つ
- 履歴が空になったらアプリを終了
- 同じタブを再度タップ → そのタブをルートにリセット
- タブ数は固定

 

🤔 考え方

タブをキーとした BackStack の Map を使う

これだけです。複雑な状態ホルダーは一切不要です。

 

🤔 主要コンポーネント

- NavKey
- NavBackStack
- NavigationBar / NavigationBarItem
- NavDisplay

 

🤔 ナビゲーションモデル


NavKey
└── TabRoot (エントリポイント)
     ├── Home
     ├── Search
     │    ├── Result (検索結果画面)
     │    └── Detail (検索詳細画面)
     └── Profile



@Serializable
sealed interface TabRoot : NavKey {
    val label: String
    val selectedIcon: ImageVector
    val unselectedIcon: ImageVector

    companion object {
        val entries = listOf(Home, Search, Profile)
    }
}

@Serializable
data object Home : TabRoot {
    override val label = "Home"
    override val selectedIcon = Icons.Filled.Home
    override val unselectedIcon = Icons.Outlined.Home
}

@Serializable
data object Search : TabRoot { ... }

@Serializable
data object Profile : TabRoot { ... }

@Serializable
data class Result(val keyword: String) : NavKey

@Serializable
data class Detail(val id: String) : NavKey

なぜこれがうまくいくか

- 単一の型安全なナビゲーションモデル
- タブと画面が同じシステムを共有
- @Serializable で状態復元が可能
- sealed + object でコンパイル時安全性

 

🤔 状態管理

1. 選択中のタブ


var currentTab by rememberSerializable {
    mutableStateOf<TabRoot>(Home)
}


var currentTab by rememberSerializable {
    mutableStateOf<TabRoot>(Home)
}

2. 複数の NavBackStack


val stacks = TabRoot.entries.associateWith { root ->
    rememberNavBackStack(root)
}

重要なポイント
- Map は再コンポーズごとに再作成されが生成コストは小さい
- しかし各 NavBackStack 維持されて再作成されない

これで安全かつシンプルに実現できます。

3. 現在の NavBackStack


val currentStack = stacks[currentTab]!!

Compose では currentTab が変わると自動的に更新されます。

4. タブ切り替え


onClick = {
    currentTab = root
}

これだけです。

5. 画面遷移(Push)kotlin


onClick = {
    currentStack.add(Result(keyword))
}

6. 戻る操作(Pop)


NavDisplay(
    onBack = {
        currentStack.removeAt(currentStack.lastIndex)
    }
)

- ルートを削除しようとしたらアプリ終了(Android 標準挙動)

7. 同じタブを再度タップしたときのリセット


if (selected) {
    currentStack.clear()
    currentStack.add(root)
}

- Instagram や Twitter(X)と同じ挙動になります。

 

🧑🏻‍💻 最終的な最小パターン


var currentTab by rememberSerializable {
    mutableStateOf<TabRoot>(Home)
}

val stacks = TabRoot.entries.associateWith { root ->
    rememberNavBackStack(root)
}

val currentStack = stacks[currentTab]!!

 

🚀 このアプローチが優れている理由

- コード量が最小
- 実際のアプリ挙動にマッチ
- 完全に Compose ネイティブ
- 将来的に拡張しやすい
- Process Death 後も完全な状態復元が可能

というかんじでどうでしょうか。

 

🚗💨 参考