1. Home
  2. テクノロジー
  3. Kotlin MultiplatformとVoyagerを使った画面遷移

Kotlin MultiplatformとVoyagerを使った画面遷移

この記事は、ニフティグループ Advent Calendar 2024 24日目の記事です。

はじめに

こんにちは。「ニフティ不動産」アプリの開発を担当している、TS-Elfenです。今回はVoyagerを使った、マルチプラットフォームアプリの画面遷移の実装方法について紹介したいと思います。

Kotlin Multiplatform/ ComposeMultiplatformとは?

Voyagerの説明をする前にまず、Kotlin MultiplatformやCompose Multiplatformについての簡単な説明をします。

近年、1つの開発言語を基にWeb/iOS/Androidなどの複数のプラットフォームで動作するアプリを作るクロスプラットフォームの技術が注目されています。Kotlin Multiplatformは、その代表的なものの一つです。他の例としてはReact Native、Flutterなどが挙げられます。

Compose Multiplatformは、 Android、iOS、Web、およびデスクトップ(JVM を使用)間で UI を共有するための宣言型UIフレームワークです。AndroidネイティブアプリのUIの実装の主流となっているJetpack Composeの書き方で色々なアプリケーションのUIを記述できます。今年の6月にiOS版がベータ版に、Web版がアルファ版になっています

Voyagerとは?

Voyagerとは、Compose Multiplatform上で使用することができる、画面遷移の実装を簡単にしてくれるライブラリです。

Jetpack Composeでは、各UI部品をComposableという単位で管理します。一方、VoyagerではScreenという独自のクラスを使用するなど、記法が少し違います。

Voyagerの使い方

事前準備

Kotlin Multiplatformのプロジェクトを新規作成し、composeApp内のbuild.gradle に以下のようにインポートします。Voyagerでは、Koinを用いたDIも可能なようです。今回は割愛しますが、必要な方はインポートを追加してみてください。

/* KoinによるDIも入れたい方はこちらをインポートしてください
implementation(project.dependencies.platform(libs.koin.bom))
implementation(libs.koin.core)
implementation(libs.koin.compose)
implementation(libs.voyager.koin)
*/
implementation(libs.voyager.screen.model)
implementation(libs.voyager.navigator)

UIの定義

以下に、Voyagerを使わないUIの宣言の例と、Voyagerを使ったUIの宣言の例を示します。以下のような点が違うことがわかります。

  • 画面は Screenという独自クラスを継承してdata classとして作成する。
  • ComposeによるUIの宣言は、Content() 内に記載する。

既にJetpack Composeで作っていたUIを移植したい場合は、Content() 内に書いてあったコードを移すだけです。

// Not Voyager
@Composable
fun Sample() {
	Column(){
		Text("title")
		Text("contents1")
		Text("contents2")
	}
}
// using Voyager
data class HomeScreen(): Screen {
    @Composable
    override fun Content() {
	Column() {
		Text("title")
		Text("contents1")
		Text("contents2")
	} 
    }
}

画面表示と画面遷移

以下に、Navigation Composeによる画面表示/遷移とVoyagerによる画面表示/遷移の例を示します。ScreenAからScreenBに遷移することを想定して実装しています。

Navigation Composeによる画面表示/遷移

// Navigation Compose
class MainActivity: ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            MyApp()
        }
    }
}

@Composable
fun MyApp() {
    val navController = rememberNavController()

    // ナビゲーションホストの定義
    NavHost(navController = navController, startDestination = "screenA") {
        composable("screenA") { ScreenA(navController) }
        composable("screenB") { ScreenB() }
    }
}

@Composable
fun ScreenA(navController: NavController) {
     Column() {
        Text("This is Screen A")
        Button(
            onClick = { navController.navigate("screenB") }
        ) {
            Text("Go to Screen B")           
        }
    }
}

@Composable
fun ScreenB() {
    Column() {
        Text("This is Screen B")
   }
}

Navigation Composeでは、NavHostに画面名のStringと使用するComposableを記載していく形式になっています。また、遷移の方法は、遷移を行う画面(今回だとScreenA )にNavController を渡して、NavController 経由でnavigate による遷移を行う形式になっています。遷移を管理するNavController は、遷移をしたい画面にトップから渡す必要があります。

Voyagerによる画面表示/遷移

// Using Voyager
class MainActivity: ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            Navigator(screen = ScreenA())
        }
    }
}

data class ScreenA(): Screen {
    @Composable
    override fun Content() {
	val navigator = LocalNavigator.currentOrThrow
	Button( 
            onClick = { navigator.push(ScreenB("hoge")) }
        ) {
	    Text("Transition")
        }
    }
}
                
data class ScreenB(
    val id: String
): Screen {
    @Composable
    override fun Content() {
        Text("Hello, $id!")
    }
}

Voyagerでは、LocalNavigator.currentOrThrowNavigator を取得できて、それに対して pushpop などの操作を行うことで遷移ができます。また、画面遷移はNavigator クラスに Screen を渡す方式になっています。

画面遷移に使う Navigator は、どこからでも取得できるようなのでその点は楽かもしれません。また、画面の遷移にはFragmentの時と同様 pushpop などスタックの思想が採用されているので馴染みやすいと思いました。

ViewModelについて

Voyagerでは、従来のViewModel の代わりにScreenModelというものを使います。以下に、ScreenModelを使った画面の実装例を示します。

非同期処理を行う場合は、viewModelScopeではなくscreenModelScopeを使用します。これにより、StateFlow などに非同期で値を送信することが可能です。
画面側ではこの結果を監視することで、ViewModelを使用した場合と同じように実装できます。 

class SecondScreenModel: ScreenModel {
    fun numbers(): Flow<String> = flow {
        listOf("1", "2", "3").forEach { number ->
            emit(number)
            delay(1000) 
        }
    }

    private val _number = MutableStateFlow("0")
    val number = _number.asStateFlow()

    fun get() {
        screenModelScope.launch {
            numbers().collect { it ->
                _number.emit(it) 
            }
        }
    }
}

data class SecondScreen(
    val id: String
): Screen {
    @Composable
    override fun Content() {
        val screenModel = rememberScreenModel { SecondScreenModel() }
        val state = screenModel.number.collectAsState().value

        LaunchedEffect(Unit) {
            screenModel.get()
        }

        Text("Hello, $state!")
    }
}

まとめ

本記事では、Voyagerの使い方について紹介しました。従来の画面遷移の書き方と似通った部分があるので、簡単な遷移を書く分にはそこまで大変ではない印象でした。

また、Kotlin Multiplatformのプロジェクトの作成はJetBrains社のWizardを使うと簡単に作成できます。Hello, world!ならばクリックだけで確認が可能なので、ぜひ試してみてください!

この記事をシェア

掲載内容は、記事執筆時点の情報をもとにしています。