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を利用するには依存関係のセットアップが必要になります。以下の依存関係を追加してセットアップしていきます。

依存関係

Kotlin
[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" }

Kotlin
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のためのセットアップ
        }
    }
}

画面実装

Decompose Routerで画面やダイアログを定義するのに特別な定義は必要ありません。

なので今回必要な「ギャラリー画面」「プレビュー画面」「詳細情報ダイアログ」を下記のように定義してやります。

ギャラリー画面

Kotlin
@Composable
fun GalleryScreen(
    pictures: List<Picture>,
    onNavigatePreview: (Picture) -> Unit,
    modifier: Modifier = Modifier,
) {
    GalleryContent(
        pictures = pictures,
        onClick = onNavigatePreview,
        modifier = modifier
    )
}

プレビュー画面

Kotlin
@Composable
fun PreviewScreen(
    picture: Picture,
    onNavigateDetails: (Picture) -> Unit,
    onBack: () -> Unit,
    modifier: Modifier = Modifier,
) {
    PreviewContent(
        picture = picture,
        onBack = onBack,
        onNavigateDetails = { onNavigateDetails(picture) },
        modifier = modifier,
    )
}

詳細情報ダイアログ

Kotlin
@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で登録するというのを各プラットフォームで実装する必要がある感じです。

Kotlin
 CompositionLocalProvider(LocalRouterContext provides routerContext) {
       ...省略
 }

今回はAndroidとiOS向けのアプリケーションを作成するので、各プラットフォーム向けに下記の処理を実装して、RouterContextを設定できるようにします。

※ ここで具体的なコードの詳細を解説していくと本記事の趣旨と大きくずれてしまうので解説はしないですが、iOSのコードはSwiftUIのライフサイクルを紐づけるためのコードが記載されているということは補足しておきます。

Android

Kotlin
class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
		... 省略
        val rootComponentContext: RouterContext = defaultRouterContext()
        setContent {
            CompositionLocalProvider(LocalRouterContext provides rootComponentContext) {
                App()
            }
        }
    }
}

iOS

Kotlin
@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) {
    }
}
Kotlin
fun MainViewController(routerContext: RouterContext): UIViewController = ComposeUIViewController {
    CompositionLocalProvider(LocalRouterContext provides routerContext) {
        App()
    }
}

ルーター設定

Decompose Routerで画面遷移を実行するには、以下の3つの実装をする必要があるので、実装を進めていく。

  1. RoutedContentを配置する
  2. 画面一覧を表現するScreensクラスを定義する
  3. Routerを生成して、RoutedContentに渡す
  4. Screensクラスごとに表示する画面を定義する

RoutedContentを配置する

Decompose Routerでは以下のようなRoutedContentというComposable関数が用意されている。

Kotlin
@Composable
fun <C : Parcelable> RoutedContent(
  router: Router<C>,
  modifier: Modifier = Modifier,
  animation: StackAnimation<C, RouterContext>? = null,
  content: @Composable (C) -> Unit,
) { }

画面遷移をするためにはRoutedContentを配置する必要があるので、下記のように配置する

Kotlin
@Composable
fun DecomposeApp() {
    RoutedContent(router = /** TODO ルーターをして指定する */) { screen ->
       ...省略
    }
}

画面一覧を表現するScreensクラスを定義する

Decompose Routerでは画面遷移するには、画面一覧を定義するParcelizeを継承したSealed Classをを定義して、Routerに登録する必要があります。

Kotlin
@Parcelize
sealed class Screens : Parcelable

今回は「ギャラリー画面」「プレビュー画面」「情報詳細ダイアログ」の3つの画面間で画面遷移するのですが、Decompose Routerではダイアログはサポートしておらず特定の画面に組み込む形で表示を切り替えるしかないので、下記のように「ギャラリー画面」「プレビュー画面」のみを定義します。

Kotlin
@Parcelize
sealed class Screens : Parcelable {
    data object Gallery : Screens()
    data class Preview(val name: String) : Screens()
}

Routerを生成して、RoutedContentに渡す

Decompose Routerの画面遷移や初期画面設定にはRouterを利用します。

Kotlin
class Router<C : Parcelable>(
  private val navigation: StackNavigation<C>,
  val stack: State<ChildStack<C, RouterContext>>,
) : StackNavigation<C> by navigation

RouterはrememberRouterで取得するのと、rememberRouterのinitialStackで初期画面を指定できるようになっています。

Kotlin
@Composable
fun <C : Parcelable> rememberRouter(
  type: KClass<C>,
  key: Any = type.key,
  handleBackButton: Boolean = true,
  initialStack: () -> List<C>,
): Router<C> {}

なのでこのrememberRouterを使ってRouterを取得して、RoutedContentにRouterを渡すようにします。\

Kotlin
@Composable
fun DecomposeApp() {
    val router: Router<Screens> = rememberRouter(Screens::class) { listOf(Screens.Gallery) }
    RoutedContent(router = router) { screen ->
    }
}

Screensクラスごとに表示する画面を定義する

ここまで実装するとRoutedContentのcontentからScreensクラスが渡されるようになるので、Screensクラスのサブクラスでどの画面を表示するか定義できるようになります。

Kotlin
@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クラスを定義したので、以下のように「ギャラリー画面」「プレビュー画面」の画面を定義していきます。

Kotlin
@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で特定の画面へ遷移するには、Routerのpushメソッドを利用します。

Kotlin
fun <C : Any> StackNavigator<C>.push(configuration: C, onComplete: () -> Unit = {}) {
    navigate(transformer = { it + configuration }, onComplete = { _, _ -> onComplete() })
}

Routerのpushメソッドにはルーター設定で定義したScreensクラスを渡せるようになっており、特定のScreensクラスを渡すことで画面遷移が実行される仕組みになっています。

Kotlin
router.push(Screens.Gallery)

今回は「ギャラリー画面」から「プレビュー画面」への遷移が必要になります。なので下記のように「ギャラリー画面」でScreens.Previewを生成して、pushメソッドに渡すようにしてやり画面遷移を実行してやります。

「ギャラリー画面」から「プレビュー画面」への遷移

Kotlin
@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メソッドを利用します。

Kotlin
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つ前の「ギャラリー画面」に戻る

Kotlin
@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()
                    )
                }
            }
        }
    }
}

動作確認

ここまで実装すると以下のようなコードになります。

Kotlin
@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の両方で問題なく画面遷移ができます。

Android
iOS

個人的な評価

Decompose RouterはScreensクラスを定義して、その定義に応じて画面定義・画面遷移を実施する方式はわかりやすい一方で、Parcelizeの導入が必要だったりでセットアップがかなり面倒だなと感じました。

Decompose RouterではRouterContextという仕組みで、各プラットフォーム向けのライフサイクルを監視したりできそうで、この辺りのカスタマイズを自分でやりたい方にとってはすごく良さそうですが、初学者向けではなく初期導入のハードルを大きくあげているなと感じました。

とはいえセットアップが完了してしまえばScreensクラスを拡張して画面定義・画面遷移を実施するだけなので、セットアップだけ乗り越えればかなり簡単に画面遷移ができるようになるのは良い点かなと思います。

メリット

  • ParcelizeなScreensクラスを利用するため、直感的な画面定義や画面遷移ができるような仕組みになっている
  • ParcelizeなScreensクラスを利用するため、余計なルート設計などがない、かつ定義を集約して管理できるようになっている

デメリット

評価詳細

項目 評価(最高・良い・課題あり) 理由など
セットアップのしやすさ 課題あり ・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つの画面遷移ライブラリでどれを使うべきか個人的な考えをまとめようかなと思います。

参考文献