【Compose Multiplatform】ナビゲーションを対応させる(NavigationCompose -> Voyager)
はじめに
現在業務でCompose Multiplatformを用いたクロスプラットフォーム化を進めております。通常のAndroidアプリとして、ナビゲーションには公式でもCodeLabを出しているNavigation-compose
を使っています。
しかし、残念ながらこちらはCompose Multiplatformに対応していないとのこと。
そこで、Compose Multiplatformで推奨されているナビゲーションを調べたところ、JetBrains社の社員の方が出しているツールの「Compose Multiplatform Wizard」では、Voyager
(ボイジャー)というライブラリが推奨されているということだったので、こちらを使うことにしました。
なお、Compose Multiplatform Wizardの存在は、こちらの記事にて初めて知りました。
上記の記事や公式サイトでも、たいへん分かりやすく説明してくださっているのですが、Voyager
に特化した詳細な日本語の備忘録として、記事にさせていただきます。
Voyagerの公式サイト
VoyagerとNavigation-composeの違い
違いを表に簡単にまとめると、以下の通りです。
Navigation-compose | Voyager | |
---|---|---|
各画面 | 通常のComposable関数 | Screenという独自のクラス |
遷移の管理 | NavHost内で画面名の文字列で分岐 | NavigatorにScreenクラスのリストを渡す |
ViewModel | androidx.lifecycle.ViewModelなど | ScreenModelという独自のクラス |
ViewModel内でのCoroutine | viewModelScope() {} | coroutineScope() {} |
画面に移動 | NavController.navigate(String) | Navigator.push(Screen) |
画面を戻る | NavControlller.popBackStack() | Navigator.pop() |
ルート画面に移動 | NavControlller.navigate(String) { popUpTo(0) } | Navigator.popUntilRoot() Navigator.replace(Screen) |
Voyagerの導入
mavenCentral
がまだ追加されていない場合は、追加します。(最近のProjectだとsettings.gradle.kts
内に書かれている?)
repositories {
mavenCentral()
}
下記のように依存関係を追加します。なお、執筆時の最新バージョンは1.0.0-rc05
でした。
implementation("cafe.adriel.voyager:voyager-navigator:$voyagerVersion")
クロスプラットフォームでの使用を前提とし、かつボトムシートやタブ、トランジションなどを追加しなくていい場合は、これだけで十分でした。
各画面(Composable関数)をScreenクラスに変更
各画面をVoyagerで扱えるようにするには、Composable関数を、Voyager独自のクラスであるScreenクラスを継承したクラスとして定義しなおす必要があります。例としては、以下の通りです。
これを...
@Composable
fun HogeScreen() {
Text("Hello World!")
}
こう。
class HogeScreen(): Screen {
@Composable // <- 書き忘れがち
override fun Content() {
Text("Hello World!")
}
}
引数を渡したい時は、クラスの引数として渡せるみたいです。
ViewModelをScreenModelクラスに変更
Voyager
では、ScreenModelというクラスがViewModelの代わりになります。おかげで、Hilt
などの依存性注入なしでも、ViewModel系だけならよしなにやってくれます。
ScreenModelという名前ですが、確かにJetpack Composeでは各画面をViewよりScreenと呼ぶ方がメジャーな気がするので、この名前の方が適切なのかも知れません。
例としては、以下の通りです。
これを...
class HogeViewModel(): ViewModel {
// 各種処理
}
こう。
- class HogeViewModel(): ViewModel {
+ class HogeScreenModel(): ScreenModel {
// 各種処理
}
ScreenModelを各画面で使うには、
class HogeScreen(): Screen {
@Composable
override fun Content() {
val viewModel = rememberScreenModel { HogeScreenModel() } // <- シンプル!
Text("Hello World!")
}
}
のように、rememberScreenModel関数で簡単に取得できます。個人的には、Voyager
のスタイルの方が、同じようにどこからでもViewModelを取得するにはHilt
とHilt-navigation-compose
を両方を必要とする、従来のスタイルよりも好きです。
画面を遷移させる
Voyagerの遷移の基本的な考え方として、Screenクラスのリストを、pushやpopで更新して、一番最後の要素のScreenクラスが表示される、といったもののようです。遷移の履歴がそのリストに反映されるということで、単純明快です。以下でわかりやすく図示してくださっています。
最初に表示する画面を定義する
Navigation-compose
を使った遷移はNavHost
を使って、以下のようにRouteの文字列を渡す形で記述していましたが、
const val HOGE_SCREEN = "hogeScreen"
@Composable
fun MainApp() {
AppTheme {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
val navController = rememberNavController()
NavHost(navController, startDestination = HOGE_SCREEN) {
composable(HOME_SCREEN) { HogeScreen() }
// 以下、その他の画面
}
}
}
}
Voyager
では以下のようにScreenクラスのリストを渡す形で記述します。
@Composable
fun MainApp() {
AppTheme {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
val screens = listOf(HogeScreen())
Navigator(screens)
}
}
}
ここで、HomeScreen以外の画面はどこで定義する?という話なのですが、各画面で
val navigator = LocalNavigator.currentOrThrow
という形で取得できるNavigator
クラスのインスタンスで、このScreenクラスのリストが共通で管理されるため、一番最初の画面を定義するだけで大丈夫になっています。
指定した画面に移動する
Navigation-compose
では、以下のように移動していました。(navControllerをもっている画面からしか、呼び出せない。)
val navController = rememberNavController()
navController.navigate(HOGE_SCREEN) { launchSingleTop = true }
Voyager
では、以下のように移動します。(どこからでも呼び出せる。)
val navigator = LocalNavigator.currentOrThrow
navigator.push(HogeScreen())
画面を1つ戻る
戻るボタンを押したときなどの、画面を1つ戻る挙動です。
Navigation-compose
では、以下のように戻っていました。(navControllerをもっている画面からしか、呼び出せない。)
val navController = rememberNavController()
navController.popBackStack()
Voyager
では、以下のように戻ります。(どこからでも呼び出せる。)
val navigator = LocalNavigator.currentOrThrow
navigator.pop()
ルート画面に移動(指定した画面に移動し、履歴を削除する)
指定した画面に移動させた後に、戻るボタンで直前の画面に移動させないようにしたい場合があります。(例えば、設定画面からホーム画面に移動した際など。)
Navigation-compose
では、以下のように移動していました。(navControllerをもっている画面からしか、呼び出せない。)
val navController = rememberNavController()
navController.navigate(HOGE_SCREEN) {
launchSingleTop = true
popUpTo(0) { inclusive = true }
}
Voyager
では、以下のように戻ります。(どこからでも呼び出せる。)
val navigator = LocalNavigator.currentOrThrow
navigator.popUntilRoot()
navigator.replace(HogeScreen())
まずpopUntilRoot
で、Screenクラスのリストのうち、一番最初の要素のみ残すようにします。
そしてその一個の要素をreplace
によって指定の画面に置き換えます。(ここがポイントで、仮にpush
を使ってしまうと、一番最初の要素は古いままで、2つ目の画面として指定した画面が追加されてしまいます。)
より具体的に説明すると、以下のようなイメージです。
// 最初
val navigator = LocalNavigator.currentOrThrow
// navigator.items
// 🍇, 🍉, 🍌, 🍐, 🥝, 🍋
// popUntilRootを実行
navigator.popUntilRoot()
// navigator.items
// 🍇
// replaceを実行
navigator.replace("🍓")
// navigator.items
// 🍓
// replaceの代わりにpushを使ってしまうと...
navigator.push("🍓")
// navigator.items
// 🍇, 🍓
【おまけ】String型を渡して遷移できるようにする
Voyager
は、Navigation-compose
と比較して、遷移の考え方やScreenModelの注入などがシンプルで、個人的には取り回しのよさが非常に好みです。
しかし、一点だけ惜しいと感じるのが、Screenクラスを引数にして遷移させる必要がある点です。Routeの文字列を渡す方が、何かと便利ですし、今までの実装をいじる量も少なくて済みます。
そこで、String型を引数にできるように工夫をしてみましたので、下記に紹介させていただきます。
Routeの文字列をまとめたファイルを作る
Navigation-compose
の使用の際に、すでに作成してあるとは思いますが、以下のような内容でRouteの文字列をまとめたファイルを作成します。
const val HOGE_SCREEN = "hogeScreen"
const val FUGA_SCREEN = "fugaScreen"
...
Routeの文字列とScreenのマッピングをする
下記のようなObject
を作成し、Routeの文字列とScreenクラスの橋渡しを行います。
object RouteScreenMap {
val value = mapOf(
HOGE_SCREEN to HogeScreen(),
FUGA_SCREEN to FugaScreen(),
...
)
}
Navigatorの拡張関数を定義する
画面遷移などを簡単にできるようにするために、Navigator
クラスの拡張関数を以下のように定義します。
fun Navigator.popUp() {
this.pop()
}
fun Navigator.openScreen(route: String) {
val newScreen = RouteScreenMap.value[route]
newScreen?.let {
this.push(it)
return
}
}
fun Navigator.openAndClear(route: String) {
val newScreen = RouteScreenMap.value[route]
newScreen?.let {
this.popUntilRoot()
this.replace(it)
return
}
}
画面遷移の例
下記のように、Routeの文字列を渡すだけで、画面を移動できるようになりました。
val navigator = LocalNavigator.currentOrThrow
navigator.openScreen(FUGA_SCREEN)
navigator.popUp()
navigator.openAndClear(FUGA_SCREEN)
おわりに
Voyager
の取り回しの良さが普通に気に入ってしまったので、クロスプラットフォームのアプリに関わらず、ナビゲーションにはVoyager
を使っていきたいと思います。
記事の内容に間違いなどございましたら、遠慮なくご指摘いただけますと幸いです。
Discussion