Kotlin Jetpack ComposeでX (エックス) みたいなナビゲーションを実装する

<img width="360" alt="3c8de213-ce98-9ddf-35c3-98541fdbce94.gif" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2700602/3c8de213-ce98-9ddf-35c3-98541fdbce94.gif">

## この記事について 🦔

- [**Kotlin**](https://kotlinlang.org/docs/home.html)と[**Jetpack Compose**](https://developer.android.com/jetpack?gad_source=1&gclid=Cj0KCQiApNW6BhD5ARIsACmEbkXpizOkEazCFN7T0llFOXWGWYNMrzZlb3VxXBf1u01Ww9i_EVEHe10aAkqYEALw_wcB&gclsrc=aw.ds)で「Xみたいなナビゲーション」を持ったAndroidアプリの実装例を紹介します
- [**Kotlin**](https://kotlinlang.org/docs/home.html)の文法や[**Jetpack Compose**](https://developer.android.com/jetpack?gad_source=1&gclid=Cj0KCQiApNW6BhD5ARIsACmEbkXpizOkEazCFN7T0llFOXWGWYNMrzZlb3VxXBf1u01Ww9i_EVEHe10aAkqYEALw_wcB&gclsrc=aw.ds)自体に関する詳しい説明はありません
- 実装のエッセンスは記事中で言及していますが、省略箇所も多いので、コピペの際は注意してください
- 紹介する方法は、必ずしもベストソリューション or ベストプラクティスとは限りません、もっと良い方法があれば教えてくれると喜びます！
- ソースコードは[**こちら**](https://github.com/KazmaWed/kotlin_compose_navigation_sample)
- Flutterバージョンも投稿予定です…

## Xみたいなナビゲーションとは 🐝

ここで「Xみたいなナビゲーション」とは、トップのGif画像の様なものを指しています。詳しくは、以下の様な機能を持っているものです

1. ログイン, メイン画面 , 設定画面などの**メインの画面遷移**がある
1. 画面下の**タブバー**による**画面切り替え**がある
1. 各画面に**ステート** (動的な要素) があり、タブ操作時にも**保持**されている
1. **タブ内**に**画面遷移**があり…
    4-1. routeと**画面スタックが動的** (操作した時に次のrouteが決定し、さらに**画面数が無制限**)
    4-2. 遷移時に`TopAppBar`などの状態が更新される

Amazonのアプリでも同様の機能が実装されていますが、特に4つ目の要件は実装が意外とムズかしく、使うフレームワークによって実装方法がかなり異なるため、最初は少し苦戦しました…

## 開発環境 🐥

```
compileSdk = 34
jdk = 23
```
```
kotlin = "2.0.0"
composeBom = "2024.04.01"
navigationCompose = "2.8.3"
```

`Navigation Compose`のインストールは方法[**こちら**](https://developer.android.com/guide/navigation#kts)

## 画面一覧

![all.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2700602/a8c4e6c6-2e4b-d043-8197-2bf7fcbef928.jpeg)

- **ログイン画面** (`LoginScreen`)：ダミーログイン画面です
- **メイン画面** (`MainScreen`)：下にタブバーがあり、それぞれ次の画面に対応しています
    - **インクリメント画面** (`IncrementScreen`)：ボタンを押した回数をカウントします
    - **ナビゲーション画面** (`NavigationScreen`)：ボタンを押すと次々と画面遷移します
- **設定画面** (`SettingScreen`)：ダミー設定画面です

## 1. メインの画面遷移について 🦆

ログイン > メイン > 設定画面間の遷移は基本的には至ってシンプルな実装ですが、ポイントは`AppNavHost`内の`composable()`で毎回`sharedViewModel.navigate()`を呼んでいる点です。
これにより`sharedViewModel`に「今どの画面を表示しているか」を格納し、`TopAppBar`のタイトルを更新します。

(同じ様な処理を書くなら`composable()`をラップしたメソッドを定義した方がイイとは思っていますよ、はい！)
(そもそも各画面に一つずつ`TopAppBar`を持たせて直接タイトルを渡せば、このような`ViewModel`の実装は必要ありません。今回は実験的な試みという事で！)

```ui/SharedViewModel.kt
// アプリ内で共有のUiStateとViewModel
data class SharedUiState(
    val currentScreen: AppDestination = AppScreens.Login,
)

class SharedViewModel : ViewModel() {
    private val _uiState = MutableStateFlow(SharedUiState())
    val uiState: StateFlow<SharedUiState> = _uiState

    fun navigate(screen: AppDestination) = _uiState.update {
        it.copy(currentScreen = screen)
    }
}
```

```ui/AppNavHost.kt
// 画面クラスインターフェース
interface AppDestination {
    val title: String
    val route: String
}

// メインの画面リスト
sealed class AppScreens(
    override val title: String,
    override val route: String
) : AppDestination {
    object Login : AppScreens("ログイン", "login")
    object Main : AppScreens("メイン", "main")
    object Setting : AppScreens("設定", "setting")
}

// メインのナビホスト
@Composable
fun AppNavHost(
    navController: NavHostController = rememberNavController(),
    sharedViewModel: SharedViewModel = viewModel()
) {
    NavHost(
        navController = navController,
        startDestination = AppScreens.Login.route,
    ) {
        composable(
            route = AppScreens.Login.route,
        ) { backStackEntry ->
            sharedViewModel.navigate(AppScreens.Login)
            LoginScreen(navController)
        }
        composable(
            route = AppScreens.Main.route,
        ) { backStackEntry ->
            sharedViewModel.navigate(AppScreens.Main)
            MainScreen(navController)
        }
        composable(
            route = AppScreens.Setting.route,
        ) { backStackEntry ->
            sharedViewModel.navigate(AppScreens.Setting)
            SettingScreen(navController)
        }
    }
}
```

`LoginScreen`と`SettingScreen`は画面遷移させる為だけにある空っぽビューなので割愛します。(ソースは[こちら](https://github.com/KazmaWed/kotlin_compose_navigation_sample/blob/main/app/src/main/java/com/example/composenavigationsample/ui/loginScreen/LoginScreen.kt))

## 2.`SharedViewModel`をアプリ内でシェアする方法について 🦦

通常、上記の実装だけでは`sharedViewModel`のスコープは`AppNavHost`内になるので、`sharedViewModel.navigate()`によって`sharedUiState`を更新しても、最新の状態は他のビューから参照することができません。

そこで、ひとつ上の`MainActivity`で`CompositionLocalProvider`を使って、`AppNavHost`以下のどの画面からでも単一の`shareViewModel`へアクセス出来るようにします。

```ui/MainActivity.kt
class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        setContent {
            val localSharedViewModel = compositionLocalOf<SharedViewModel> { 
                error("SharedViewModel not provided") 
            }
            CompositionLocalProvider(
                localSharedViewModel provides viewModel(factory = ViewModelProvider.Factory)
            ) {
                ComposeNavigationSampleTheme {
                    AppNavHost()
                }
            }
        }
    }
}
```

## 3. インクリメント画面の実装 🦥

<img width="240" alt="adc00f62-d390-5439-cd00-5137ad57f886.jpeg" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2700602/adc00f62-d390-5439-cd00-5137ad57f886.jpeg">

`IncrementScreen`にはボタンがあり、タップした回数が表示されるだけの画面です。
Flutterのアプリ新規作成すると現れるアレとほぼ同じです。とてもシンプルなので説明は割愛します。

```ui/incrementScreen/IncrementScreen.kt
// UiState
data class IncrementScreenUiState(
    val count: Int = 0 // ボタンタップ回数
)

// ViewModel
class IncrementScreenViewModel : ViewModel() {
    private val _uiState = MutableStateFlow(IncrementScreenUiState())
    val uiState: StateFlow<IncrementScreenUiState> = _uiState

    fun increment() = _uiState.update {
        it.copy(it.count + 1)
    }
}

// ビュー
@Composable
fun IncrementScreen(
    modifier: Modifier = Modifier,
    viewModel: IncrementScreenViewModel = viewModel(),
) {
    val uiState = viewModel.uiState.collectAsState()

    Scaffold(
        modifier = modifier
    ) { safeInsets ->
        Column(
            horizontalAlignment = Alignment.CenterHorizontally,
            modifier = Modifier
                .padding(safeInsets)
                .fillMaxSize()
        ) {
            // テキスト
            Box(
                contentAlignment = Alignment.Center,
                modifier = Modifier.weight(1F)
            ) {
                Text(
                    uiState.value.count.toString() + " クリック",
                    style = MaterialTheme.typography.titleMedium,
                    fontWeight = FontWeight.Bold
                )
            }
            // インクリメントボタン
            FloatingActionButton(
                onClick = { viewModel.increment() },
            ) {
                Icon(Icons.Default.Add, "インクリメントボタン")
            }
        }
    }
}
```

今回のアプリではインクリメント画面内で遷移はありませんが、表示時に`sharedViewModel.navigate()`を実行するのにわかりやすいため、`AppNavHost`と似た形で`IncrementScreenNavHost`を作成します。

```ui/MainScreenNavHost.kt
sealed class IncrementScreens(
    override val title: String,
    override val route: String
) : AppDestination {
    object Index : IncrementScreens("インクリメント", "increment/index")
}

@Composable
fun IncrementScreenNavHost(
    navController: NavHostController,
    sharedViewModel: SharedViewModel = viewModel(),
) {
    NavHost(
        navController = navController,
        startDestination = IncrementScreens.Index.route,
    ) {
        composable(
            route = IncrementScreens.Index.route,
        ) { backStackEntry ->
            sharedViewModel.navigate(IncrementScreens.Index)
            IncrementScreen()
        }
    }
}
```

ここまではとてもシンプルでしたね！
ちなみにこの「メインの`NavHost`とは別の`NavHost`を作る」のが、今回のミソです。

## 4. ナビゲーション画面の実装 🦭

<img width="480" alt="3558a4b5-115c-8f15-c3d2-d9b56088e7cf.jpeg" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2700602/3558a4b5-115c-8f15-c3d2-d9b56088e7cf.jpeg">

さて、問題のナビゲーション画面です。ここでは画面内のボタンをタップするたびに新しい画面に遷移します。そして遷移のたびに`TopAppBar`が更新され、タイトルと戻るボタンの表示が適宜切り替わります。

```ui/navigatorScreen/NavigatorScreen.kt
@Composable
fun NavigatorScreen(
    count: Int,
    navController: NavController, // AppNavHostのNavControllerとは異なる点に注意
    modifier: Modifier = Modifier
) {
    Scaffold(
        modifier = modifier
    ) { safeInsets ->
        Column(
            horizontalAlignment = Alignment.CenterHorizontally,
            modifier = Modifier
                .padding(safeInsets)
                .fillMaxSize()
        ) {
            // テキスト
            Box(
                contentAlignment = Alignment.Center,
                modifier = Modifier.weight(1F)
            ) {
                Text(
                    NavigatorScreens.Page(count).title,
                    style = MaterialTheme.typography.titleMedium,
                    fontWeight = FontWeight.Bold
                )
            }
            // 次のページボタン
            Button(
                onClick = {
                    val nextPage = NavigatorScreens.Page(count + 1)
                    navController.navigate(nextPage.route)
                }
            ) {
                Text("次のページ")
            }
        }
    }
}
```

ご覧の通りですが、`NavigatorScreen`は実はシンプルで、基本的には受け取った`count: Int`を表示するだけの画面です。ボタンをタップすると`navController.navigate()`で新たな`NavigatorScreen`へと再起的に (使い方合ってる？) 遷移します。

ただしこの時、`navController`に`AppNavHost`と同じ`NavController`を渡すことはできません。「Xみたい」なナビゲーションするためには、タブ下の画面スタックはまた別で管理しないといけないからです。

```ui/MainScreenNavHost.kt
sealed class NavigatorScreens(
    override val title: String,
    override val route: String
) : AppDestination {
    object Index : NavigatorScreens("ページ 1", "$navigatorScreenRoute/index")
    data class Page(val count: Int) : NavigatorScreens(
        "ページ $count ",
        navigatorScreen(count)
    )
}

// routeの共通部分
private val navigatorScreensRoute
    get() = "navigator"

// arguments付きroute
private fun navigatorScreensRouteWithCount(count: Int): String {
    return navigatorScreensRoute + "?count=${count.toString()}"
}

// arguments付きrouteのフォーマット
private val navigatorScreensRouteFormat
    get() = "$navigatorScreensRoute?count={count}"

@Composable
fun NavigatorScreenNavHost(
    navController: NavHostController, // NavigatorScreen用のNavController
    sharedViewModel: SharedViewModel = viewModel(),
) {
    NavHost(
        navController = navController,
        startDestination = NavigatorScreens.Index.route,
    ) {
        // 1ページ目
        composable(
            route = NavigatorScreens.Index.route,
        ) { backStackEntry ->
            sharedViewModel.navigate(NavigatorScreens.Page(1))
            NavigatorScreen(1, navController)
        }
        // 1または2ページ目以降
        composable(
            route = navigatorScreenComposableRoute,
        ) { backStackEntry ->
            val count = backStackEntry.arguments?.getString("count")!!.toInt()
            sharedViewModel.navigate(NavigatorScreens.Page(count))
            NavigatorScreen(count, navController)
        }
    }
}
```

`private`メソッドはいずれも`route`用の`String`を生成するための便利メソッドです。
`NavigatorScreens`には`Index`以外に`Page(val count: Int)`を設けて、引数から動的に生成できる様にしています。

`NavigatorScreenNavHost`内の２つ目の`composable()`は`NavigatorScreens`の`route`からページ数を動的に決定し、`NavigatorScreen`を生成します。この実装によって、いくらでも次の画面がある、**今時のSNS**っぽい画面遷移ができるわけです。

ここでも毎回`sharedViewModel.navigate()`を実行しています。

## 5. メイン画面の実装 🦫

<img width="480" alt="5d12ff19-aafb-b6d3-07c6-cc21348ab164.jpeg" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2700602/5d12ff19-aafb-b6d3-07c6-cc21348ab164.jpeg">

**メイン画面**を実装して、ここまでに実装した２つの画面をタブでポチポチ切り替えられるようにします。

`IncrementScreen`と`NavigatorScreen`それぞれの`NavController`を`MainScreen`に生成させています。これにより (`MainScreen`が破棄されない限り) それぞれのタブ下の画面スタックを保持する機能が実現します。

```ui/mainScreen/MainScreen.kt
@Composable
fun MainScreen(
    globalNavController: NavController,
    modifier: Modifier = Modifier,
    viewModel: MainScreenViewModel = viewModel(),
    sharedViewModel: SharedViewModel = viewModel(),
    incrementScreenNavController: NavHostController = rememberNavController(),
    navigatorScreenNavController: NavHostController = rememberNavController(),
) {
    val uiState = viewModel.uiState.collectAsState()
    val sharedUiState = sharedViewModel.uiState.collectAsState()

    // 表示中の画面を管理しているのNavController
    fun currentScreenNavController(): NavController {
        return if (uiState.value.selectedItem is IncrementScreens) {
            incrementScreenNavController
        } else {
            navigatorScreenNavController
        }
    }

    Scaffold(
        topBar = {
            MainScreenTopAppBar(
                title = sharedUiState.value.currentScreen.title,
                navController = currentScreenNavController(),
                onClickSetting = {
                    globalNavController.navigate(AppScreens.Setting.route)
                }
            )
        },
        bottomBar = {
            MainScreenBottomAppBar(
                selectedItem = uiState.value.selectedItem,
                onClick = { destination ->
                    if (destination == uiState.value.selectedItem) {
                        if (destination is NavigatorScreens) {
                            navigatorScreenNavController.popBackStack(NavigatorScreens.Index.route, false)
                        }
                    } else {
                        viewModel.selectBottomBarItem(destination)
                    }
                }
            )
        },
        modifier = modifier
    ) {safeInsets ->
        Box(
            contentAlignment = Alignment.Center,
            modifier = Modifier.padding(safeInsets)
        ) {
            when (uiState.value.selectedItem) {
                is IncrementScreens -> IncrementScreenNavHost(incrementScreenNavController)
                is NavigatorScreens -> NavigatorScreenNavHost(navigatorScreenNavController)
            }
        }
    }
}
```

`MainScreenTopAppBar`はカスタムビューで、`sharedViewModel`から表示中の画面を取得し、また**戻る画面がある時**にのみ**戻るボタン**を表示します。(実装は[**こちら**](https://github.com/KazmaWed/kotlin_compose_navigation_sample/blob/7a3a2451d2cad1541ad11505458a2cf29c82ad4b/app/src/main/java/com/example/composenavigationsample/ui/mainScreen/MainScreenComponents.kt#L29-L37))

 `MainScreenBottomAppBar`もまたカスタムビューで、選択中のタブをもう一度タップすると、戻る画面がある時にのみ最初のページに戻ります。(つまり「Xみたい」な動きです)

 それぞれ詳しい実装が気になる方は[**ソースコード**](https://github.com/KazmaWed/kotlin_compose_navigation_sample)をご覧ください。

 ## 完成 🐛

 <img width="240" alt="3c8de213-ce98-9ddf-35c3-98541fdbce94.gif" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2700602/3c8de213-ce98-9ddf-35c3-98541fdbce94.gif">

細かな実装が気になる方は[**ソースコード**](https://github.com/KazmaWed/kotlin_compose_navigation_sample)をご覧ください。シュミレーターでも動くのでお試しあれ。

XやAmazonなどのアプリ以外で、今回の様なタブ切り替えや画面遷移をするケースは多くないかもしれませんが、Kotlinのナビゲーションは実装の自由度が高く、やりたい事が比較的すぐに実現できました。
Flutterでも同じことをしようとすると意外と大変だったのですが、近日中にそちらも投稿しようと思いますので、お楽しみに！