Compose Multiplatform: 画面遷移ライブラリを評価する ④ Decompose Router編
本記事の概要はこちらで確認ください。今回はDecompose Routerを利用した画面遷移について調べていきます。
https://github.com/xxfast/Decompose-Router
前提
この記事では以下の環境で進めていきます。
名称 | バージョン | 備考 |
---|---|---|
Fleet(IDE) | Fleet version: build 1.26.104 OS: Mac OS X (14.1.1, aarch64) | – |
Kotlin | 1.9.20 | – |
Compose Multiplatform | 1.5.4 | – |
※もし不明点がある場合はこちらのGitHubのソースコードにて詳細を確認ください。
https://github.com/kaleidot725/Compose-Multiplatform-Playground/tree/main/projects/Navigation
セットアップ
Decompose Routerを利用するには依存関係のセットアップが必要になります。以下の依存関係を追加してセットアップしていきます。
依存関係
[versions]
decompose-router = "0.5.1"
decompose = "2.1.0-compose-experimental"
essenty = "1.1.0"
[libraries]
decompose-router = { module = "io.github.xxfast:decompose-router", version.ref = "decompose-router" }
decompose-core = { module = "com.arkivanov.decompose:decompose", version.ref = "decompose" }
decompose-compose-multiplatform = { module = "com.arkivanov.decompose:extensions-compose-jetbrains", version.ref = "decompose" }
essenty-parcelable = { module = "com.arkivanov.essenty:parcelable", version.ref = "essenty" }
plugins {
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.androidApplication)
alias(libs.plugins.jetbrainsCompose)
id("kotlin-parcelize") // ※2 Parcelizeのためのセットアップ
}
kotlin {
...省略
listOf(
iosX64(),
iosArm64(),
iosSimulatorArm64()
).forEach { iosTarget ->
iosTarget.binaries.framework {
baseName = "ComposeApp"
isStatic = true
export(libs.decompose.router) // ※3 Swiftコードから参照できるようにする
}
}
sourceSets {
...省略
commonMain.dependencies {
...省略
api(libs.decompose.router) // ※3 Swiftコードから参照できるようにする
implementation(libs.decompose.core) // ※1 Decomposeの依存関係のセットアップ
implementation(libs.decompose.compose.multiplatform) // ※1 Decomposeの依存関係のセットアップ
implementation(libs.essenty.parcelable) // ※2 Parcelizeのためのセットアップ
}
}
}
※1 Decompose RouterはDecomposeをベースにした画面遷移ライブラリであるため、Decomposeの依存関係のセットアップも合わせて実施する必要がある
※2 Decompose RouterはAndroidアプリ開発で使われるParcelizeの仕組みを利用するため、kotlin-parcelizeのGradle Pluginの有効化、KMP向けのParcelizeを実装しているessentyの依存関係追加が必要になる
※3 Decompose RouterではiOSのSwift UIのライフサイクルを監視するため、SwiftのコードにDecompose Routerの実装を組み込む必要があります。その実装のためにDecompose RouterのクラスがSwiftのコードから参照できるようにexportやapiでDecompose Routerの依存関係をセットアップしています。
画面実装
Decompose Routerで画面やダイアログを定義するのに特別な定義は必要ありません。
なので今回必要な「ギャラリー画面」「プレビュー画面」「詳細情報ダイアログ」を下記のように定義してやります。
ギャラリー画面
@Composable
fun GalleryScreen(
pictures: List<Picture>,
onNavigatePreview: (Picture) -> Unit,
modifier: Modifier = Modifier,
) {
GalleryContent(
pictures = pictures,
onClick = onNavigatePreview,
modifier = modifier
)
}
プレビュー画面
@Composable
fun PreviewScreen(
picture: Picture,
onNavigateDetails: (Picture) -> Unit,
onBack: () -> Unit,
modifier: Modifier = Modifier,
) {
PreviewContent(
picture = picture,
onBack = onBack,
onNavigateDetails = { onNavigateDetails(picture) },
modifier = modifier,
)
}
詳細情報ダイアログ
@Composable
fun DetailsDialog(
picture: Picture,
onBack: () -> Unit,
modifier: Modifier = Modifier,
) {
Dialog(onDismissRequest = onBack) {
DetailsContent(
picture = picture,
onBack = onBack,
modifier = modifier,
)
}
}
画面遷移実装
コンテキスト設定
Decompose Routerで画面遷移を実行するには、画面遷移時に利用するRouterContextをAndroidとiOSなどのプラットフォームごとに、セットアップする必要があります。
具体的な話をするとRouterContextはCompositionLocalProviderで登録する必要があるので、CompositionLocalProviderで登録するRouterContextを生成して、CompositionLocalProviderで登録するというのを各プラットフォームで実装する必要がある感じです。
CompositionLocalProvider(LocalRouterContext provides routerContext) {
...省略
}
今回はAndroidとiOS向けのアプリケーションを作成するので、各プラットフォーム向けに下記の処理を実装して、RouterContextを設定できるようにします。
※ ここで具体的なコードの詳細を解説していくと本記事の趣旨と大きくずれてしまうので解説はしないですが、iOSのコードはSwiftUIのライフサイクルを紐づけるためのコードが記載されているということは補足しておきます。
Android
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
... 省略
val rootComponentContext: RouterContext = defaultRouterContext()
setContent {
CompositionLocalProvider(LocalRouterContext provides rootComponentContext) {
App()
}
}
}
}
iOS
@main
struct SwiftUIApp: App {
@UIApplicationDelegateAdaptor var delegate: AppDelegate
@Environment(\.scenePhase) var scenePhase: ScenePhase
var defaultRouterContext: RouterContext {
delegate.holder.defaultRouterContext
}
var body: some Scene {
WindowGroup {
HomeView(routerContext: defaultRouterContext).ignoresSafeArea(edges: .all)
}
.onChange(of: scenePhase) { newPhase in
switch newPhase {
case .background: defaultRouterContext.stop()
case .inactive: defaultRouterContext.pause()
case .active: defaultRouterContext.resume()
@unknown default: break
}
}
}
}
class DefaultRouterHolder: ObservableObject {
let defaultRouterContext: RouterContext = DefaultRouterContextKt.defaultRouterContext()
deinit {
defaultRouterContext.destroy()
}
}
class AppDelegate: NSObject, UIApplicationDelegate {
let holder: DefaultRouterHolder = DefaultRouterHolder()
}
struct HomeView: UIViewControllerRepresentable {
let routerContext: RouterContext
func makeUIViewController(context: Context) -> UIViewController {
return MainViewControllerKt.MainViewController(routerContext: routerContext)
}
func updateUIViewController(_ uiViewController: UIViewController, context: Context) {
}
}
fun MainViewController(routerContext: RouterContext): UIViewController = ComposeUIViewController {
CompositionLocalProvider(LocalRouterContext provides routerContext) {
App()
}
}
ルーター設定
Decompose Routerで画面遷移を実行するには、以下の3つの実装をする必要があるので、実装を進めていく。
- RoutedContentを配置する
- 画面一覧を表現するScreensクラスを定義する
- Routerを生成して、RoutedContentに渡す
- Screensクラスごとに表示する画面を定義する
RoutedContentを配置する
Decompose Routerでは以下のようなRoutedContentというComposable関数が用意されている。
@Composable
fun <C : Parcelable> RoutedContent(
router: Router<C>,
modifier: Modifier = Modifier,
animation: StackAnimation<C, RouterContext>? = null,
content: @Composable (C) -> Unit,
) { }
画面遷移をするためにはRoutedContentを配置する必要があるので、下記のように配置する
@Composable
fun DecomposeApp() {
RoutedContent(router = /** TODO ルーターをして指定する */) { screen ->
...省略
}
}
画面一覧を表現するScreensクラスを定義する
Decompose Routerでは画面遷移するには、画面一覧を定義するParcelizeを継承したSealed Classをを定義して、Routerに登録する必要があります。
@Parcelize
sealed class Screens : Parcelable
今回は「ギャラリー画面」「プレビュー画面」「情報詳細ダイアログ」の3つの画面間で画面遷移するのですが、Decompose Routerではダイアログはサポートしておらず特定の画面に組み込む形で表示を切り替えるしかないので、下記のように「ギャラリー画面」「プレビュー画面」のみを定義します。
@Parcelize
sealed class Screens : Parcelable {
data object Gallery : Screens()
data class Preview(val name: String) : Screens()
}
Routerを生成して、RoutedContentに渡す
Decompose Routerの画面遷移や初期画面設定にはRouterを利用します。
class Router<C : Parcelable>(
private val navigation: StackNavigation<C>,
val stack: State<ChildStack<C, RouterContext>>,
) : StackNavigation<C> by navigation
RouterはrememberRouterで取得するのと、rememberRouterのinitialStackで初期画面を指定できるようになっています。
@Composable
fun <C : Parcelable> rememberRouter(
type: KClass<C>,
key: Any = type.key,
handleBackButton: Boolean = true,
initialStack: () -> List<C>,
): Router<C> {}
なのでこのrememberRouterを使ってRouterを取得して、RoutedContentにRouterを渡すようにします。
@Composable
fun DecomposeApp() {
val router: Router<Screens> = rememberRouter(Screens::class) { listOf(Screens.Gallery) }
RoutedContent(router = router) { screen ->
}
}
Screensクラスごとに表示する画面を定義する
ここまで実装するとRoutedContentのcontentからScreensクラスが渡されるようになるので、Screensクラスのサブクラスでどの画面を表示するか定義できるようになります。
@Composable
fun DecomposeApp() {
val router: Router<Screens> = rememberRouter(Screens::class) { listOf(Screens.Gallery) }
RoutedContent(router = router) { screen ->
when (screen) {
Screens.Gallery -> { /** TODO */ }
is Screens.Preview -> { /** TODO */ }
}
}
}
今回は「ギャラリー画面」「プレビュー画面」を表示できるようにScreensクラスを定義したので、以下のように「ギャラリー画面」「プレビュー画面」の画面を定義していきます。
@Composable
fun DecomposeApp() {
val router: Router<Screens> = rememberRouter(Screens::class) { listOf(Screens.Gallery) }
RoutedContent(router = router) { screen ->
when (screen) {
Screens.Gallery -> {
GalleryScreen(
pictures = Pictures.value,
onNavigatePreview = { /** TODO 画面遷移を実装する */ },
modifier = Modifier.fillMaxSize()
)
}
is Screens.Preview -> {
val picture = Pictures.value.firstOrNull { it.name == screen.name } ?: return@RoutedContent
var isShowDetails by remember { mutableStateOf(false) }
PreviewScreen(
picture = picture,
onNavigateDetails = { { /** TODO 画面遷移を実装する */ } },
onBack = { { /** TODO 1つ前に画面を戻れるようにする */ } },
modifier = Modifier.fillMaxSize()
)
if (isShowDetails) {
DetailsDialog(
picture = picture,
onBack = { isShowDetails = false },
modifier = Modifier.wrapContentSize()
)
}
}
}
}
}
※「プレビュー画面」に「詳細情報ダイアログ」の実装を組み込んでいますが、これはDecompose Routerがダイアログの画面遷移に対応していないので、このような形で表示フラグで画面を切り替える形にしています。
特定の画面に遷移する
Decompose Routerで特定の画面へ遷移するには、Routerのpushメソッドを利用します。
fun <C : Any> StackNavigator<C>.push(configuration: C, onComplete: () -> Unit = {}) {
navigate(transformer = { it + configuration }, onComplete = { _, _ -> onComplete() })
}
Routerのpushメソッドにはルーター設定で定義したScreensクラスを渡せるようになっており、特定のScreensクラスを渡すことで画面遷移が実行される仕組みになっています。
router.push(Screens.Gallery)
今回は「ギャラリー画面」から「プレビュー画面」への遷移が必要になります。なので下記のように「ギャラリー画面」でScreens.Previewを生成して、pushメソッドに渡すようにしてやり画面遷移を実行してやります。
「ギャラリー画面」から「プレビュー画面」への遷移
@Composable
fun DecomposeApp() {
val router: Router<Screens> = rememberRouter(Screens::class) { listOf(Screens.Gallery) }
RoutedContent(router = router) { screen ->
when (screen) {
Screens.Gallery -> {
GalleryScreen(
pictures = Pictures.value,
// プレビュー画面にクリックした画像の名称を渡してあげて遷移を実施する
onNavigatePreview = { router.push(Screens.Preview(it.name)) },
modifier = Modifier.fillMaxSize()
)
}
... 省略
}
}
}
1つ前の画面に戻る
Decompose Routerで1つ前の画面に戻るには、Routerのpopメソッドを利用します。
fun <C : Any> StackNavigator<C>.pop(onComplete: (isSuccess: Boolean) -> Unit = {}) {
navigate(
transformer = { stack -> stack.takeIf { it.size > 1 }?.dropLast(1) ?: stack },
onComplete = { newStack, oldStack -> onComplete(newStack.size < oldStack.size) },
)
}
今回は「プレビュー画面」から「ギャラリー画面というか何時で1つ画面に戻るようにする必要があるので、下記のようにRouterのpopで1つ前の画面に戻るようにしてやります。
「プレビュー画面」から1つ前の「ギャラリー画面」に戻る
@Composable
fun DecomposeApp() {
val router: Router<Screens> = rememberRouter(Screens::class) { listOf(Screens.Gallery) }
RoutedContent(router = router) { screen ->
when (screen) {
... 省略
is Screens.Preview -> {
val picture = Pictures.value.firstOrNull { it.name == screen.name } ?: return@RoutedContent
var isShowDetails by remember { mutableStateOf(false) }
PreviewScreen(
picture = picture,
onNavigateDetails = { isShowDetails = true },
// プレビュー画面から1つ前の画面に戻るように実装してやる
onBack = { router.pop() },
modifier = Modifier.fillMaxSize()
)
if (isShowDetails) {
DetailsDialog(
picture = picture,
onBack = { isShowDetails = false },
modifier = Modifier.wrapContentSize()
)
}
}
}
}
}
動作確認
ここまで実装すると以下のようなコードになります。
@Parcelize
sealed class Screens : Parcelable {
data object Gallery : Screens()
data class Preview(val name: String) : Screens()
}
@Composable
fun DecomposeApp() {
val router: Router<Screens> = rememberRouter(Screens::class) { listOf(Screens.Gallery) }
RoutedContent(router = router) { screen ->
when (screen) {
Screens.Gallery -> {
GalleryScreen(
pictures = Pictures.value,
onNavigatePreview = { router.push(Screens.Preview(it.name)) },
modifier = Modifier.fillMaxSize()
)
}
is Screens.Preview -> {
val picture = Pictures.value.firstOrNull { it.name == screen.name } ?: return@RoutedContent
var isShowDetails by remember { mutableStateOf(false) }
PreviewScreen(
picture = picture,
onNavigateDetails = { isShowDetails = true },
onBack = { router.pop() },
modifier = Modifier.fillMaxSize()
)
if (isShowDetails) {
DetailsDialog(
picture = picture,
onBack = { isShowDetails = false },
modifier = Modifier.wrapContentSize()
)
}
}
}
}
}
このコードを実行すると下記のようにiOSとAndroidの両方で問題なく画面遷移ができます。
個人的な評価
Decompose RouterはScreensクラスを定義して、その定義に応じて画面定義・画面遷移を実施する方式はわかりやすい一方で、Parcelizeの導入が必要だったりでセットアップがかなり面倒だなと感じました。
Decompose RouterではRouterContextという仕組みで、各プラットフォーム向けのライフサイクルを監視したりできそうで、この辺りのカスタマイズを自分でやりたい方にとってはすごく良さそうですが、初学者向けではなく初期導入のハードルを大きくあげているなと感じました。
とはいえセットアップが完了してしまえばScreensクラスを拡張して画面定義・画面遷移を実施するだけなので、セットアップだけ乗り越えればかなり簡単に画面遷移ができるようになるのは良い点かなと思います。
メリット
- ParcelizeなScreensクラスを利用するため、直感的な画面定義や画面遷移ができるような仕組みになっている
- ParcelizeなScreensクラスを利用するため、余計なルート設計などがない、かつ定義を集約して管理できるようになっている
デメリット
- ParcelizeなScreensクラスを利用するための面倒なセットアップ作業が必要になる
- またEssentyのParcelize対応がKotlin v2以降に終了する可能性があるので移行リスクがある
- https://github.com/arkivanov/Essenty#parcelable-and-parcelize-deprecated-since-v130-alpha01
- RouterContextを各プラットフォームごとにセットアップが必要になる
評価詳細
項目 | 評価(最高・良い・課題あり) | 理由など |
---|---|---|
セットアップのしやすさ | 課題あり | ・ParcelizeやRouterContextのセットアップがややわかりづらく保守がやりずらいと感じる ・Essentyが提供するParcelizeの仕組みがKotlin v2移行に終了する可能性があり移行が必要になる可能性が高そう |
途中で移行しやすいか | 最高 | ・ParcelizeなScreensクラスを定義する形式なので、もし画面遷移ライブラリを変えようとした場合にでも、問題なく移行が可能 |
画面定義の実装しやすさ | 最高 |
・ParcelizeなScreensクラスを定義して、サブクラスごとに画面を定義する方式なのでわかりやすい。 ・各画面の定義についてはComposable関数に画面やダイアログを実装するだけなので特に難しいことはない |
画面遷移の実装しやすさ | 最高 | ・ParcelizeなScreensクラスを生成してRouterして渡すことで、画面遷移を実行する形式なので、わかりやすい。 |
画面遷移のパラメータ渡しやすさ | 良い | ・ParcelizeなScreensクラスを生成する際にパラメータを渡すのでわかりやすいが、渡すパラメータはプリミティブ型かParcelizeなクラスでなければいけないので少々ややこしい |
おわりに
今回はDecompose Routerについて評価してみました、これでVoyager・PreCompose・Decompose Routerの3つの画面遷移ライブラリの評価が終わりました。次の記事ではこれら3つの画面遷移ライブラリでどれを使うべきか個人的な考えをまとめようかなと思います。