はじめに
4月からKotlin Multiplatformを用いた開発に取り組んでいる者です。
Jetpack Composeでアプリ開発を進めていくと、避けて通れないのがナビゲーションの設計。
この記事では私たちのチームが公式ドキュメントを参考にしつつ、議論の末導き出したナビゲーションの設計についてご紹介したいと思います。
参考にした公式ドキュメント
目指すナビゲーション構成
例として、語学学習アプリを作成するとして考えてみます。
以下のような画面構成を想定してみましょう。
- Login
- Main (Bottom Nav)
├── Library
│ ├── Search
│ └── Lesson(lessonIdを引数に受け取る)
└── Account
├── Settings
└── AboutApp
ログイン後にメインの画面に遷移し、ボトムナビゲーションによってレッスン一覧(Library)とアカウント情報画面(Account)を切り替えられるようになっています。
また、LibraryとAccountそれぞれの画面から別の画面への遷移も持っています。
これをCompose Navigationでどう表現するか?が今回のテーマです。
SerializableなRoute定義で型安全なナビゲーション
まず、各画面のルートを@Serializable
なオブジェクトやデータクラスで定義します。
@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画面遷移を管理
@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
をネストしており、それぞれのナビゲーション責務を分離しています。
@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の定義
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の定義
こちらも同様にネストされた構造で記述しています。
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)もデータクラスで安全に遷移可能
ナビゲーションの実装に試行錯誤を繰り返しましたが、直感的にわかりやすい設計になったのではないかと思います。
ぜひご自身のプロジェクトでも試してみてください!