🚀

【Compose Multiplatform】ナビゲーションを対応させる(NavigationCompose -> Voyager)

2023/09/04に公開

はじめに

現在業務でCompose Multiplatformを用いたクロスプラットフォーム化を進めております。通常のAndroidアプリとして、ナビゲーションには公式でもCodeLabを出しているNavigation-composeを使っています。
しかし、残念ながらこちらはCompose Multiplatformに対応していないとのこと。

そこで、Compose Multiplatformで推奨されているナビゲーションを調べたところ、JetBrains社の社員の方が出しているツールの「Compose Multiplatform Wizard」では、Voyager(ボイジャー)というライブラリが推奨されているということだったので、こちらを使うことにしました。
https://terrakok.github.io/Compose-Multiplatform-Wizard/

なお、Compose Multiplatform Wizardの存在は、こちらの記事にて初めて知りました。
上記の記事や公式サイトでも、たいへん分かりやすく説明してくださっているのですが、Voyagerに特化した詳細な日本語の備忘録として、記事にさせていただきます。

Voyagerの公式サイト

https://github.com/adrielcafe/voyager
https://voyager.adriel.cafe/

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内に書かれている?)

settings.gradle.kts
repositories {
    mavenCentral()
}

下記のように依存関係を追加します。なお、執筆時の最新バージョンは1.0.0-rc05でした。

app/build.gradle.kts
implementation("cafe.adriel.voyager:voyager-navigator:$voyagerVersion")

クロスプラットフォームでの使用を前提とし、かつボトムシートやタブ、トランジションなどを追加しなくていい場合は、これだけで十分でした。

各画面(Composable関数)をScreenクラスに変更

各画面をVoyagerで扱えるようにするには、Composable関数を、Voyager独自のクラスであるScreenクラスを継承したクラスとして定義しなおす必要があります。例としては、以下の通りです。

これを...

HogeScreen.kt
@Composable
fun HogeScreen() {
    Text("Hello World!")
}

こう。

HogeScreen.kt
class HogeScreen(): Screen {
    @Composable // <- 書き忘れがち
    override fun Content() {
        Text("Hello World!")
    }
}

引数を渡したい時は、クラスの引数として渡せるみたいです。

ViewModelをScreenModelクラスに変更

Voyagerでは、ScreenModelというクラスがViewModelの代わりになります。おかげで、Hiltなどの依存性注入なしでも、ViewModel系だけならよしなにやってくれます。
ScreenModelという名前ですが、確かにJetpack Composeでは各画面をViewよりScreenと呼ぶ方がメジャーな気がするので、この名前の方が適切なのかも知れません。
例としては、以下の通りです。

これを...

HogeViewModel
class HogeViewModel(): ViewModel {
    // 各種処理
}

こう。

HogeScreenModel
- class HogeViewModel(): ViewModel {
+ class HogeScreenModel(): ScreenModel {
    // 各種処理
}

ScreenModelを各画面で使うには、

class HogeScreen(): Screen {
    @Composable
    override fun Content() {
        val viewModel = rememberScreenModel { HogeScreenModel() } // <- シンプル!
	
        Text("Hello World!")
    }
}

のように、rememberScreenModel関数で簡単に取得できます。個人的には、Voyagerのスタイルの方が、同じようにどこからでもViewModelを取得するにはHiltHilt-navigation-composeを両方を必要とする、従来のスタイルよりも好きです。

画面を遷移させる

Voyagerの遷移の基本的な考え方として、Screenクラスのリストを、pushやpopで更新して、一番最後の要素のScreenクラスが表示される、といったもののようです。遷移の履歴がそのリストに反映されるということで、単純明快です。以下でわかりやすく図示してくださっています。
https://voyager.adriel.cafe/stack-api

最初に表示する画面を定義する

Navigation-composeを使った遷移はNavHostを使って、以下のようにRouteの文字列を渡す形で記述していましたが、

Route.kt
const val HOGE_SCREEN = "hogeScreen"
MainApp.kt
@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クラスのリストを渡す形で記述します。

MainApp.kt
@Composable
fun MainApp() {
    AppTheme {
        Surface(
            modifier = Modifier.fillMaxSize(),
            color = MaterialTheme.colorScheme.background
        ) {
            val screens = listOf(HogeScreen())
	    
	    Navigator(screens)
        }
    }
}

ここで、HomeScreen以外の画面はどこで定義する?という話なのですが、各画面で

HogeScreen.kt
val navigator = LocalNavigator.currentOrThrow

という形で取得できるNavigatorクラスのインスタンスで、このScreenクラスのリストが共通で管理されるため、一番最初の画面を定義するだけで大丈夫になっています。

指定した画面に移動する

Navigation-composeでは、以下のように移動していました。(navControllerをもっている画面からしか、呼び出せない。)

MainApp.kt
val navController = rememberNavController()

navController.navigate(HOGE_SCREEN) { launchSingleTop = true }

Voyagerでは、以下のように移動します。(どこからでも呼び出せる。)

HogeScreen.kt
val navigator = LocalNavigator.currentOrThrow

navigator.push(HogeScreen())

画面を1つ戻る

戻るボタンを押したときなどの、画面を1つ戻る挙動です。
Navigation-composeでは、以下のように戻っていました。(navControllerをもっている画面からしか、呼び出せない。)

MainApp.kt
val navController = rememberNavController()

navController.popBackStack()

Voyagerでは、以下のように戻ります。(どこからでも呼び出せる。)

MainApp.kt
val navigator = LocalNavigator.currentOrThrow

navigator.pop()

ルート画面に移動(指定した画面に移動し、履歴を削除する)

指定した画面に移動させた後に、戻るボタンで直前の画面に移動させないようにしたい場合があります。(例えば、設定画面からホーム画面に移動した際など。)
Navigation-composeでは、以下のように移動していました。(navControllerをもっている画面からしか、呼び出せない。)

MainApp.kt
val navController = rememberNavController()

navController.navigate(HOGE_SCREEN) {
    launchSingleTop = true
    popUpTo(0) { inclusive = true }
}

Voyagerでは、以下のように戻ります。(どこからでも呼び出せる。)

MainApp.kt
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の文字列をまとめたファイルを作成します。

Route.kt
const val HOGE_SCREEN = "hogeScreen"
const val FUGA_SCREEN = "fugaScreen"
...

Routeの文字列とScreenのマッピングをする

下記のようなObjectを作成し、Routeの文字列とScreenクラスの橋渡しを行います。

RouteScreenMap.kt
object RouteScreenMap {
    val value = mapOf(
        HOGE_SCREEN to HogeScreen(),
	FUGA_SCREEN to FugaScreen(),
	...
    )
}

画面遷移などを簡単にできるようにするために、Navigatorクラスの拡張関数を以下のように定義します。

NavigatorExt.kt
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の文字列を渡すだけで、画面を移動できるようになりました。

HogeScreen.kt
val navigator = LocalNavigator.currentOrThrow

navigator.openScreen(FUGA_SCREEN)

navigator.popUp()

navigator.openAndClear(FUGA_SCREEN)

おわりに

Voyagerの取り回しの良さが普通に気に入ってしまったので、クロスプラットフォームのアプリに関わらず、ナビゲーションにはVoyagerを使っていきたいと思います。
記事の内容に間違いなどございましたら、遠慮なくご指摘いただけますと幸いです。

Discussion