[KMP]Navigation Composeで型安全＆ネスト構造の画面遷移を実装してみた

# はじめに
4月からKotlin Multiplatformを用いた開発に取り組んでいる者です。

Jetpack Composeでアプリ開発を進めていくと、避けて通れないのがナビゲーションの設計。
この記事では私たちのチームが公式ドキュメントを参考にしつつ、議論の末導き出したナビゲーションの設計についてご紹介したいと思います。

## 参考にした公式ドキュメント

* [Type-safe Navigation](https://developer.android.com/guide/navigation/design/type-safety?hl=ja)
* [Nested Navigation Graphs](https://developer.android.com/guide/navigation/design/nested-graphs?hl=ja)
* [Navigation in Compose](https://www.jetbrains.com/help/kotlin-multiplatform-dev/compose-navigation.html)

## 目指すナビゲーション構成

例として、語学学習アプリを作成するとして考えてみます。

以下のような画面構成を想定してみましょう。

```plaintext
- Login
- Main (Bottom Nav)
   ├── Library
   │     ├── Search
   │     └── Lesson(lessonIdを引数に受け取る)
   └── Account
         ├── Settings
         └── AboutApp
```

ログイン後にメインの画面に遷移し、ボトムナビゲーションによってレッスン一覧(Library)とアカウント情報画面(Account)を切り替えられるようになっています。
また、LibraryとAccountそれぞれの画面から別の画面への遷移も持っています。

これをCompose Navigationでどう表現するか？が今回のテーマです。

## SerializableなRoute定義で型安全なナビゲーション

まず、各画面のルートを`@Serializable`なオブジェクトやデータクラスで定義します。

```kotlin
@Serializable
object Login

@Serializable
object Main {

    @Serializable
    object LibraryGraph {
        @Serializable
        object Library
        
        @Serializable
        object Search
        
        @Serializable
        data class Lesson(val lessonId: Int)
    }

    @Serializable
    object AccountGraph {
        @Serializable
        object Account
        
        @Serializable
        object Settings
        
        @Serializable
        object AboutApp
    }
}
```

オブジェクトのネスト構造によってナビゲーション全体の構造も視覚的に把握しやすくなっています。
データクラスを使用することで、引数の受け渡しも型安全に行えます。

## NavHostの分割とネスト構造

ルートのNavHostを2つに分けて実装しています。

* `AppNavHost`: ログイン → メイン画面への遷移を管理
* `MainNavHost`: BottomNavigationによるのLibrary / Account画面遷移を管理

```kotlin
@Composable
fun AppNavHost(navController: NavHostController, startDestination: Any) {
    NavHost(
        navController = navController,
        startDestination = startDestination,
    ) {
        loginGraph(navController)
    }
}

fun NavGraphBuilder.loginGraph(
    navController: NavHostController,
) {
    composable<Login> {
        LoginScreen(
            navigateToMain = {
                navController.navigate(Main) {
                    popUpTo(Login) { inclusive = true }
                }
            },
        )
    }
    composable<Main> {
        val mainNavController = rememberNavController()
        MainScreen(
            navigateToLibrary = { mainNavController.navigate(Main.LibraryGraph) },
            navigateToAccount = { mainNavController.navigate(Main.AccountGraph) },
        )
    }
}
```

`MainNavHost`では`libraryGraph`と`accountGraph`をネストしており、それぞれのナビゲーション責務を分離しています。

```kotlin
@Composable
fun MainNavHost(
    appState: DirectBooksAppState,
    modifier: Modifier,
) {
    val mainNavController = appState.mainNavController
    NavHost(
        navController = mainNavController,
        startDestination = Main.LibraryGraph,
        modifier = modifier,
    ) {
        libraryGraph(navController = mainNavController)
        accountGraph(
            navController = mainNavController,
        )
    }
}
```

## LibraryGraphの定義

```kotlin
fun NavGraphBuilder.libraryGraph(navController: NavHostController) {
    navigation<Main.LibraryGraph>(startDestination = Main.LibraryGraph.Library) {
        composable<Main.LibraryGraph.Library> {
            LibraryScreen(
                navigateToSearch = { navController.navigate(Main.LibraryGraph.Search) },
                navigateToLesson = { lessonId ->
                    navController.navigate(Main.LibraryGraph.Lesson(lessonId))
                }
            )
        }
        composable<Main.LibraryGraph.Search> {
            SearchScreen(navigateToBack = { navController.popBackStack() })
        }
    }

    lessonGraph(navController)
}
```

lessonGraph は Lesson 画面への遷移をまとめた別関数として定義しています（本記事では割愛）。
また、Lesson画面は`data class`として定義しているので、引数を安全に渡せます。

## AccountGraphの定義

こちらも同様にネストされた構造で記述しています。

```kotlin
fun NavGraphBuilder.accountGraph(navController: NavHostController) {
    navigation<Main.AccountGraph>(startDestination = Main.AccountGraph.Account) {
        composable<Main.AccountGraph.Account> {
            AccountScreen(
                navigateToSettings = {
                    navController.navigate(Main.AccountGraph.Settings)
                },
                navigateToAboutApp = {
                    navController.navigate(Main.AccountGraph.AboutApp)
                }
            )
        }
        composable<Main.AccountGraph.Settings> {
            SettingsScreen(navigateToBack = { navController.popBackStack() })
        }
        composable<Main.AccountGraph.AboutApp> {
            AboutAppScreen(navigateToBack = { navController.popBackStack() })
        }
    }
}
```

## まとめ

* ナビゲーションのネスト構造をオブジェクトで自然に表現できる
* `navigation<T>`と`composable<T>`でネスト構造もシンプルに実装できる
* `AppNavHost`と`MainNavHost`、および`libraryGraph`と`accountGraph`の分割により責務が明確になる
* 引数付き画面（例：Lesson）もデータクラスで安全に遷移可能

ナビゲーションの実装に試行錯誤を繰り返しましたが、直感的にわかりやすい設計になったのではないかと思います。

ぜひご自身のプロジェクトでも試してみてください！