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 を組み合わせてナビゲーション構造を組み立てるライブラリへと進化しています。

 

🤔 参考


Navigation 3 時代の「二重遷移」を防ぐ正しい方法 - dropUnlessResumed は debounce ではない

Android アプリでボタンを素早く連打すると、同じ画面が何度も積み重なってしまうことがあります。


Home
  ↓
Detail
  ↓
Detail
  ↓
Detail

この問題を防ぐために debounce や throttle を使うケースは少なくありません。

しかし、Navigation 3 が提供する dropUnlessResumed は考え方がまったく異なります。

これは「一定時間タップを無視する」のではなく、画面が遷移できる状態かどうかを見てイベントを受け付ける API です。

 

🤔 debounce との違い

一見すると似ていますが、見ているものが違います。

つまり、


debounce
    ↓
「まだ500ms経ってないから無視」

dropUnlessResumed
    ↓
「この画面はもう操作できないから無視」

という違いがあります。

 

🤔 Navigationでは「時間」より「状態」が重要

画面遷移が始まると、現在の画面はすぐに RESUMED ではなくなります。


RESUMED
    │
ボタン押下
    │
navigate()
    │
STARTED
    │
STOPPED

この間にもう一度クリックされても、


dropUnlessResumed {
    navController.navigate(...)
}

であれば実行されません。

つまり、


クリック1回目
    ↓
navigate()

クリック2回目
    ↓
画面はもう RESUMED じゃない
    ↓
無視

となります。

 

🤔 なぜ debounce より自然なのか

例えば画面遷移アニメーションが長くなったとします。

debounce は

500ms待つ

という固定時間なので、

- アニメーションが300ms
- アニメーションが700ms

どちらにも最適とは限りません。

一方 dropUnlessResumed は


画面が操作可能
      ↓
受け付ける

画面遷移中
      ↓
受け付けない

戻ってきた
      ↓
再び受け付ける

と、Lifecycle に合わせて自動的に動作します。

 

🤔 内部では何をしているの?

実装は驚くほどシンプルです。

概念的には次のような処理です。


if (lifecycle.currentState == Lifecycle.State.RESUMED) {
    block()
}

つまり、

- 現在の Lifecycle を確認する
- RESUMED のときだけラムダを実行する

それだけです。

タイマーも、Coroutine も、待ち時間もありません。

 

🤔 Navigation 3 での使い方

Compose ではクリックイベントをそのまま包むだけです。


val onClick = dropUnlessResumed {
    navController.navigate(Detail)
}

Button(
    onClick = onClick
) {
    Text("Open")
}

これだけで、

- 二重 Push
- 二重画面生成
- 連打による BackStack の重複

を簡単に防げます。

 

🤔 debounce を使うべき場面

もちろん debounce が不要になったわけではありません。

例えば

- 検索ボックス
- API リクエスト
- テキスト入力
- リアルタイム検索

のように「連続イベントを間引く」目的なら debounce が適しています。

一方、

- Navigation
- Dialog を開く
- BottomSheet を表示する

など Lifecycle に依存する UI 操作では dropUnlessResumed の方が自然です。

 

🤔 まとめ

dropUnlessResumed は debounce の代替ではありません。

見る対象が時間ではなく Lifecycle だからです。

Navigation では「今この画面は操作可能か」が最も重要になります。

そのため Navigation 3 では、時間ベースの制御ではなく Lifecycle ベースの制御で二重遷移を防ぐ設計になっています。

一度仕組みを理解すると、「なぜ Navigation 用に専用 API が用意されているのか」がよく分かるはずです。

 

🤔 参考


Composeのコンポーネントツリーにおけるバケツリレーを回避する方法

深くネストされたUIツリーの可読性と保守性を維持するための4つのComposeパターンを紹介します。

Jetpack Composeでは、小さく再利用可能なコンポーザブルを組み合わせてUIを構築することが推奨されています。しかし、アプリケーションが成長するにつれて、それらのコンポーザブルは自然と深くネスト(階層化)されていくものです。

その結果としてよく起こるのが、「プロップドリル(データのバケツリレー)」です。これは、特定のデータやコールバックを、それらを実際には必要としない中間層のコンポーザブルをいくつも経由して、下層へと引き渡していく現象を指します。


Parent
  ↓
Root
  ↓
Content
  ↓
Card
  ↓
UserName

この例では、Root、Content、Card は単に UserName にパラメータを転送しているだけです。彼ら自身はそのデータを利用しておらず、単なる「中継役」として機能しています。

Composeがこの問題を完全に消し去ってくれるわけではありませんが、問題を軽減または回避するための洗練された方法がいくつか用意されています。今回は、私が特によく使う4つのパターンを見ていきましょう。

 

🤔 レイアウト用コンポーネントには「Slot API」を使う

中間層のコンポーザブルが単にレイアウト(配置)を定義しているだけなら、通常は「Slot API」を使うのがもっともクリーンな解決策です。

対応前
すべてのコンポーザブルが、同じパラメータをひたすらバケツリレーしています。


@Composable
fun ParentScreen() {
    val userName = "Alice"

    Root(userName)
}

@Composable
fun Root(userName: String) {
    Content(userName)
}

@Composable
fun Content(userName: String) {
    Card(userName)
}

@Composable
fun Card(userName: String) {
    UserName(userName)
}

@Composable
fun UserName(userName: String) {
    Text(userName)
}

実際には、UserName だけが userName を必要としています。

対応後
代わりに、親(呼び出し側)に子コンポーザブルを組み立てさせます。


@Composable
fun ParentScreen() {
    val userName = "Alice"

    CardLayout {
        UserName(userName)
    }
}

@Composable
fun CardLayout(
    content: @Composable () -> Unit
) {
    Card {
        content()
    }
}

これでレイアウト用コンポーネント CardLayout は、userName について何も知る必要がなくなりました。単に、受け取ったコンテンツを「どこに表示するか」を決めているだけです。

Scaffold や LazyColumn、Button といったComposeの標準APIが、何十個ものパラメータを個別に公開するのではなく、スロット(content ラムダ)を採用しているのはまさにこれが理由です。

コンポーザブルの役割が「データ」の処理ではなく「レイアウト」である場合は、いつでもSlot APIの採用を検討しましょう。

 

🤔 共有オブジェクトには「CompositionLocal」を使う

オブジェクトの中には、特定のUIブランチ(階層)だけでなく、コンポジション全体で共有されるべきものがあります。

たとえば以下のようなものです。

- Navigator(画面遷移)
- Theme(テーマ・デザインシステム)
- Analytics(ログ分析ツール)
- User session(ユーザーセッション情報)
- Density(画面密度)

これらをすべてのコンポーザブルに引数で渡そうとすると、すぐに同じコードの繰り返しになってしまいます。

対応前


@Composable
fun ParentScreen() {
    Root(navigator)
}

@Composable
fun Root(navigator: Navigator) {
    Content(navigator)
}

@Composable
fun Content(navigator: Navigator) {
    Detail(navigator)
}

@Composable
fun Detail(navigator: Navigator) {
    Button(
        onClick = { navigator.pop() }
    ) {
        Text("Back")
    }
}

対応後


// 1. CompositionLocalを定義する
val LocalNavigator = staticCompositionLocalOf<Navigator> { 
    error("No Navigator provided") 
}

@Composable
fun ParentScreen() {
    // 2. 最上位で値をプロバイドする
    CompositionLocalProvider(LocalNavigator provides navigator) {
        Root()
    }
}

@Composable fun Root() { Content() }
@Composable fun Content() { Detail() }

@Composable
fun Detail() {
    // 3. 必要な場所で直接呼び出す
    val navigator = LocalNavigator.current
    Button(
        onClick = { navigator.pop() }
    ) {
        Text("Back")
    }
}

これにより、中間層にあるすべてのコンポーザブルが依存関係のチェーンから解放され、コードがすっきりします。

ただし、トレードオフとして「依存関係が暗黙的(コードの表面上は見えにくく)になる」という点には注意が必要です。そのため、CompositionLocal の使用は、UIの大部分で本当に広く共有される値だけに限定するのがベストです。

 

🤔 状態(State)とイベント(Event)をまとめる

プロップドリルが問題になるのは、状態(データ)を渡すときだけではありません。

実は、コールバック(関数)のバケツリレーのほうが、より大きな問題になりがちです。

対応前
引数が増えるたびに、中間層のすべてのコンポーザブルで同じコールバックを転送し続けなければなりません。


Child(
    userName = state.userName,
    isLoading = state.isLoading,
    onRefresh = viewModel::refresh,
    onRetry = viewModel::retry,
    onDelete = viewModel::delete,
    onRename = viewModel::rename,
    onLogout = viewModel::logout
)


@Composable
fun Content(
    userName: String,
    isLoading: Boolean,
    onRefresh: () -> Unit,
    onRetry: () -> Unit,
    onDelete: () -> Unit,
    onRename: (String) -> Unit,
    onLogout: () -> Unit
) {
    Child(
        userName,
        isLoading,
        onRefresh,
        onRetry,
        onDelete,
        onRename,
        onLogout
    )
}

対応後
複数のコールバックを個別に公開するのではなく、単一の「イベントディスパッチャー(イベント通知用ラムダ)」にまとめます。


sealed interface ScreenEvent {
    data object Refresh : ScreenEvent
    data object Retry : ScreenEvent
    data object Delete : ScreenEvent
    data object Logout : ScreenEvent
    data class Rename(val name: String) : ScreenEvent
}


Child(
    state = state,
    onEvent = viewModel::onEvent
)


Button(
    onClick = {
        onEvent(ScreenEvent.Refresh)
    }
) {
    Text("Refresh")
}

これにより、コンポーザブルが公開するAPI(引数)が圧倒的にすっきりします。さらに、新しいユーザーアクションを追加したくなったときも、中間層の関数をすべて書き直す必要がなくなるのが大きなメリットです。

 

🤔 巨大なViewModelを分割する

時には、プロップドリル(バケツリレー)が根本的な原因ではなく、別の問題から生じている「症状」に過ぎないこともあります。

もし、単一の ScreenViewModel が画面全体のあらゆる状態(State)を管理しているとしたら、すべてのデータがその1つのオブジェクトから流れ出すことになるため、バケツリレーが発生するのは当然と言えます。

対応前
すべての下層コンポーネントが、同じ単一のViewModelに依存しています。


ScreenViewModel
  ↓
Screen
├── Toolbar
├── Content
│   ├── Tab
│   │   ├── BottomSheet
│   │   └── Dialog

対応後
ViewModelを分割し、UIの各パーツにスコープを合わせます。


ScreenViewModel
  ↓
Screen
├── Toolbar
├── Content
│   ├── Tab
│   │
│   ├── BottomSheetViewModel
│   │     ↓
│   │   BottomSheet
│   │
│   └── DialogViewModel
│         ↓
│       Dialog


@Composable
fun BottomSheet() {
    val viewModel: BottomSheetViewModel = viewModel()

    val state by viewModel.state.collectAsState()

    // ...
}

Navigation 3やネストされたナビゲショングラフ(Nested Navigation Graphs)の登場により、UIのより小さな単位(パーツ)に対してViewModelのスコープを制限することが、以前よりもはるかに簡単になりました。

その結果、不要な共有状態(State)が減り、プロップドリルも大幅に解消されます。

 

🧑🏻‍💻 まとめ

プロップドリル(データのバケツリレー)が起きているからといって、必ずしも設計(アーキテクチャ)が悪いとは限りません。UIツリーが成長していく過程で、自然と発生してしまうケースも多々あります。

ここで重要になるのは、「なぜそのデータが、これほど多くの階層を経由しているのか」を掘り下げて考えることです。

これらのパターンは、どれか一つしか選べないというものではありません。事実、洗練されたComposeのコードベースでは、適材適所でこれら4つのアプローチがすべて組み合わされて使われています。


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

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

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

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

一方で、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】スクロール位置を正しく復元する

スクロール位置の復元は、一見するととても簡単そうに見えます。


val gridState = rememberLazyStaggeredGridState( 
    initialFirstVisibleItemIndex = savedIndex, 
    initialFirstVisibleItemScrollOffset = savedOffset
)

しかし、この方法が正しく動作するのは、すでにアイテムのレイアウトが完了している場合だけです。

データを非同期で読み込む画面では、LazyVerticalStaggeredGrid は最初はアイテム数が 0 の状態で生成されることが多くあります。

そのため、指定した初期スクロール位置は反映されません。

この問題を解決するため、多くの開発者は LaunchedEffect の中で scrollToItem() を呼び出します。


LaunchedEffect(Unit) { 
    gridState.scrollToItem(savedIndex, savedOffset)
}

しかし、これにも問題があります。

LaunchedEffect はコンポーズ直後に実行されるため、レイアウトがまだ完了していないタイミングで scrollToItem() が呼ばれてしまう可能性があります。

 

🧑🏻‍💻 グリッドの準備が完了するまで待つ

レイアウトの完了タイミングを推測するのではなく、目的のアイテムが実際にレイアウトされるまで待機するのが確実です。


LaunchedEffect(Unit) { 
    val savedPosition = viewModel.savedScrollPosition

    // 目的のアイテムがレイアウトされるまで待機 
    snapshotFlow { gridState.layoutInfo.totalItemsCount }
        .first { it > savedPosition.index }
 
    gridState.scrollToItem(savedPosition.index, savedPosition.offset) 
}

このコードでは、layoutInfo.totalItemsCount が保存していたインデックスより大きくなるまで処理を一時停止します。

つまり、復元したいアイテムが実際にレイアウトされたことを確認してから scrollToItem() を実行するため、タイミングに依存せず、安定してスクロール位置を復元できます。

 

💡 追記: タブを切り替えても rememberSaveable を機能させる

複数の NavBackStack を使う場合、rememberDecoratedNavEntries を外出しにして、NavDisplay(entries) を使うと rememberSaveable がタブ切り替え時にも機能する。

上に書いたような「ViewModel 内に保持させて UI の re-compose 時に読み込んで」のような処理はいらない。

 

🧑🏻‍💻 参考