ComposeでNavigationを実装する

# 初めに

[Android Advent Calendar 2022](https://qiita.com/advent-calendar/2022/android)の16日目の記事です。

今年自分が担当しているプロジェクトにJetpack Composeを導入しました！！
悪戦苦闘しながら、新規画面をComposeで書きつつも、既存の画面もComposeに置き換えてます。
そこで知見を得た、ComposeのNavigationについてお話ししています！

## Navigation Compose

Composeで画面遷移をする際は、[Navigation Compose](https://developer.android.com/jetpack/compose/navigation)を使用します。

Google公式が、ComposeのNavigationのpathway(= tutorial)を公開しているので、そちらも進めていただければ、理解できるかなと思います！

https://developer.android.com/codelabs/jetpack-compose-navigation?continue=https%3A%2F%2Fdeveloper.android.com%2Fcourses%2Fpathways%2Fjetpack-compose-for-android-developers-3%23codelab-https%3A%2F%2Fdeveloper.android.com%2Fcodelabs%2Fjetpack-compose-navigation#0

https://github.com/googlecodelabs/android-compose-codelabs/tree/main/NavigationCodelab

### セットアップ

viewを構築するモジュール（今回は`app`モジュールとして進めます。）に、`androidx.navigation:navigation-compose`を追加します。

```gradle:app/build.gradle
android {

    /* 中略 */

    kotlinOptions {
        jvmTarget = '1.8'
    }

    composeOptions {
        kotlinCompilerExtensionVersion = "1.3.2"
    }

    buildFeatures {
        compose true
    }
}

dependencies {

    /* 中略 */

    // Compose
    implementation platform("androidx.compose:compose-bom:2022.10.00")
    implementation "androidx.compose.ui:ui"
    implementation "androidx.compose.ui:ui-tooling-preview"
    implementation "androidx.compose.foundation:foundation"
    implementation "androidx.compose.foundation:foundation-layout"
    implementation "androidx.compose.material:material"
    implementation "androidx.compose.material:material-icons-extended"

    debugImplementation "androidx.compose.ui:ui-tooling"
 
    // Navigation Compose
    implementation "androidx.navigation:navigation-compose:2.5.3"
}
```

### 画面設定
#### NavController

Androidの画面遷移は、[NavController](https://developer.android.com/reference/androidx/navigation/NavController)を用いて、遷移する画面の状態やバックスタックにある画面の状態を管理します。

[rememberNavController](https://developer.android.com/reference/kotlin/androidx/navigation/compose/package-summary#rememberNavController(kotlin.Array))メソッドを使用して、`NavController`をrememberAPIで監視できるようにします。
このオブジェクトは、状態ホイスティングの原則より、最上位のコンポーネントで定義し、全てのComponentからアクセスできるようにするのが、公式から推奨されています。

```kotlin:MainActivity.kt
class MainActivity: ComponentActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        setContent {
            MyAppScreen()
        }
    }

    @Composable
    fun MyAppScreen() {
        val navController = rememberNavController() // NavControllerを定義

        MyApptheme {
            // 画面
        }
    }
}
```

#### NavHost

`NavController`を作成したら、[NavHost](https://developer.android.com/reference/androidx/navigation/NavHost)を定義します。

`NavHost`は、既存の`Navigation Graph`に相当するもので、画面遷移の設定を定義できます。
引数の`navController`に先ほど定義したnavControllerを、`startDestination`にActivity起動時に表示する初期画面を設定します。

lambda内で表示する画面の定義を行います。

```kotlin:MainActivity.kt
class MainActivity: ComponentActivity() {

    /* 中略 */

    @Composable
    fun MyAppScreen() {
        val navController = rememberNavController() // NavControllerを定義

        MyApptheme {
            Scaffold(
                bottomBar = { /* TODO: 画面遷移の操作をします */ }
            ) { padding ->
                NavHost(
                    navController = navController,
                    startDestination = Route.First.name, // 初期表示する画面
                    modifier = Modifier.padding(padding)
                ) {
                  // 表示する画面の定義
                } 
            }
        }
    }

    private enum Route {
        FIRST,
        SECOND,
        THIRD;
    }
}
```

##### composable()

`NavHost`のlambda内で表示する画面の定義するには、[composable](https://developer.android.com/reference/kotlin/androidx/navigation/compose/package-summary#(androidx.navigation.NavGraphBuilder).composable(kotlin.String,kotlin.collections.List,kotlin.collections.List,kotlin.Function1))メソッドを使用します。

引数の`route`で、ルート名を定義します。このルート名を使用して画面遷移する先を決めます。
lambda内に表示する画面を定義します。

遷移先に渡したいデータがある場合は、`arguments`を指定する必要があります。
`route`に渡したい値の変数名を引数プレースホルダーとして追加し、`arguments`で渡したい値の設定を行います。
`arguments`には、[navArgument](https://developer.android.com/reference/kotlin/androidx/navigation/package-summary#navArgument(kotlin.String,kotlin.Function1))メソッドを使用して、`NamedNavArgument`を渡してあげます。`navArgument`のlambdaでは、渡したい値の型やnullかどうか、デフォルト値を設定できます。

値は、[NavBackStackEntry](https://developer.android.com/reference/kotlin/androidx/navigation/NavBackStackEntry)から取得できるので、それを画面遷移先の画面に渡してあげると、値の受け渡しができます。

```kotlin:MainActivity.kt
class MainActivity: ComponentActivity() {

    /* 中略 */

    @Composable
    fun MyAppScreen() {
        val navController = rememberNavController() // NavControllerを定義

        MyApptheme {
            Scaffold(
                bottomBar = { /* TODO: 画面遷移の操作をします */ }
            ) { padding ->
                NavHost(
                    navController = navController,
                    startDestination = Route.FIRST.name,
                    modifier = Modifier.padding(padding)
                ) {
                    // 画面1
                    composable(route = Route.FIRST.name) {
                        FirstScreen()
                    }

                    // 画面2
                    composable(route = Route.SECOND.name) {
                        SecondScreen()
                    }

                    // 画面3
                    composable(
                        route = "${Route.THIRD.name}/{isFirstOpen}", // 渡したい値がある場合は、routeに引数プレースホルダーを追加する
                        arguments = listOf(
                            navArgument("isFirstOpen") {
                                // 渡したい値の設定
                                type = NavType.BoolType
                                nullable = false
                                defaultValue = true
                            }
                        )
                    ) { entry ->
                        // NavBackStackEntryから値を取得して、次の画面に渡す。
                        val isFirstOpen = entry.arguments?.getBoolean("isFirstOpen")
                        ThirdScreen(isFirstOpen ?: true)
                    }
                } 
            }
        }
    }

    @Composable
    fun FirstScreen() {
    }

    @Composable
    fun SecondScreen() {
    }

    @Composable
    fun ThirdScreen(isFirstOpen: Boolean) {
    }

    private enum Route {
        FIRST,
        SECOND,
        THIRD;
    }
}
```

###### NavType

渡すことができる型については、今までのNavigationと同じものを扱えます。[NavType](https://developer.android.com/reference/kotlin/androidx/navigation/NavType)のドキュメントに扱える型が載っています。

https://developer.android.com/guide/navigation/navigation-pass-data#supported_argument_types

```kotlin
// Enumを渡したい場合
navArgument("enum") {
    type = NavType.EnumType(Route::class.java)
}

// Serializableを渡したい場合
navArgument("serializable") {
    type = NavType.SerializableType(Route::class.java)
}

// Parcelableを渡したい場合
navArgument("parcelable") {
    type = NavType.ParcelableType(Route::class.java)
}

// Referenceを渡したい場合
navArgument("isFirstOpen") {
    type = NavType.ReferenceType
}
```

### 画面遷移

#### navigate

画面遷移する場合は、[navigate](https://developer.android.com/reference/androidx/navigation/NavController#navigate(kotlin.String,androidx.navigation.NavOptions,androidx.navigation.Navigator.Extras))メソッドを使用します。

画面遷移するトリガーで`navigate`メソッドを呼び出すと、画面が切り替わります。
値を渡す場合は、先ほど設定したrouteの引数プレースホルダーに値を渡します。

```kotlin
navController.navigate("route名")

navController.navigate("route名/$渡す値")
```

#### NavBackStackEntry

`NavController`の`currentBackStackEntryAsState`メソッドで現在の`NavBackStackEntry`をState型として取得します。

このオブジェクトから、現在のデスティネーションを見て、そのデスティネーション階層の親が今表示されている画面のルート名と一致するかどうかを確認します。
このロジックが、今表示されている画面かどうかの判定になります。

```kotlin
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentDestination = navBackStackEntry?.destination // 現在のデスティネーション

Route.values().forEach { screen ->
  // 今表示されている画面かどうか
  val selected = currentDestination?.hierarchy?.any { it.route == screen.name } == true
}
```

### 全体コード

Navigation Componentのコードを記載しておきます！
これで、画面遷移ができるはずです！

```kotlin:MainActivity.kt
class MainActivity : ComponentActivity() {

    /* 中略 */  

    @Composable
    fun MyAppScreen() {
        val navController = rememberNavController()
        var isThirdScreenFirstOpen by remember { mutableStateOf(true) }

        NavigationComposeAppTheme {
            Scaffold(
                bottomBar = {
                    BottomNavigation {
                        val navBackStackEntry by navController.currentBackStackEntryAsState()
                        val currentDestination = navBackStackEntry?.destination

                        Route.values().forEach { screen ->
                            val selected =
                                currentDestination?.hierarchy?.any { it.route == screen.name } == true

                            BottomNavigationItem(
                                icon = { Icon(Icons.Filled.Favorite, contentDescription = null) },
                                label = { Text(screen.name) },
                                selected = selected,
                                onClick = {
                                    if (screen == Route.THIRD) {
                                        navController.navigate("${screen.name}/$isThirdScreenFirstOpen")
                                        isThirdScreenFirstOpen = false
                                    } else {
                                        navController.navigate(screen.name)
                                    }
                                }
                            )
                        }
                    }
                }
            ) { padding ->
                NavHost(
                    navController = navController,
                    startDestination = Route.FIRST.name,
                    modifier = Modifier.padding(padding)
                ) {
                    composable(route = Route.FIRST.name) {
                        FirstScreen()
                    }

                    composable(route = Route.SECOND.name) {
                        SecondScreen()
                    }

                    composable(
                        route = "${Route.THIRD.name}/{isFirstOpen}",
                        arguments = listOf(
                            navArgument("isFirstOpen") {
                                type = NavType.BoolType
                                nullable = false
                                defaultValue = true
                            }
                        )
                    ) { entry ->
                        val isFirstOpen = entry.arguments?.getBoolean("isFirstOpen")
                        ThirdScreen(isFirstOpen ?: true)
                    }
                }
            }
        }
    }

    @Composable
    fun FirstScreen() {
        Text(
            text = Route.FIRST.name,
            modifier = Modifier.fillMaxSize()
        )
    }

    @Composable
    fun SecondScreen() {
        Text(text = Route.SECOND.name)
    }

    @Composable
    fun ThirdScreen(isFirstOpen: Boolean) {
        Text(
            text = if (isFirstOpen) {
                "Hello World!!"
            } else {
                Route.THIRD.name
            }
        )
    }

    private enum class Route {
        FIRST,
        SECOND,
        THIRD;
    }
}
```

また、今回のサンプルコードをgithubに置いておきます！ 参考までに🙏

https://github.com/shxun6934/NavigationComposeApp_sample