Compose Multiplatform: 画面遷移ライブラリを評価する ③ PreCompose編

本記事の概要はこちらで確認ください。今回はPreComposeを利用した画面遷移について調べていきます。

https://github.com/Tlaster/PreCompose

目次

前提

この記事では以下の環境で実装を進めています。

名称 バージョン 備考
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

セットアップ

PreComposeを利用するには依存関係のセットアップが必要になります。以下の依存関係を追加してセットアップしていきます。

依存関係

TOML
[versions]
precompose = "1.5.7"

[libraries]
compose-precompose = { module = "moe.tlaster:precompose", version.ref = "precompose" }
Kotlin
kotlin {
    // … 省略
    sourceSets {
        // … 省略
        commonMain.dependencies {
            // … 省略
            implementation(libs.compose.precompose)
        }
    }
}

画面実装

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

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

ギャラリー画面

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,
        )
    }
}

画面構造実装

ルート

PreComposeで画面遷移を実行するには、PreComposeが提供するComposable関数のPreComposeとNavHostを配置する必要があります。

Kotlin
@Composable
expect fun PreComposeApp(
    content: @Composable () -> Unit = {},
}
Kotlin
@OptIn(ExperimentalAnimationApi::class, ExperimentalMaterialApi::class)
@Composable
fun NavHost(
    modifier: Modifier = Modifier,
    navigator: Navigator,
    initialRoute: String,
    navTransition: NavTransition = remember { NavTransition() },
    swipeProperties: SwipeProperties? = null,
    persistNavState: Boolean = false,
    builder: RouteBuilder.() -> Unit,
) { ... }

そのため下記のようにアプリケーションのルートにPreComposeAppとNavHostを配置します。

Kotlin

	PreComposeApp {
        NavHost(
            navigator = navigator,
            navTransition = NavTransition(),
            initialRoute = "TODO"
        ) {
			...
        }
    }

画面を定義する

PreComposeで画面遷移するにはsceneを利用して画面を登録する必要があります。

Kotlin
     * Add the scene [Composable] to the [RouteBuilder]
     * @param route route for the destination
     * @param navTransition navigation transition for current scene
     * @param swipeProperties swipe back navigation properties for current scene
     * @param content composable for the destination
     */
    fun scene(
        route: String,
        deepLinks: List<String> = emptyList(),
        navTransition: NavTransition? = null,
        swipeProperties: SwipeProperties? = null,
        content: @Composable (BackStackEntry) -> Unit,
    ) { ... }

今回は「ギャラリー画面」と「プレビュー画面」で画面遷移したいので、下記の実装方法でsceneを利用して画面を登録します。

「ギャラリー画面」を定義する

「ギャラリー画面」は初期表示する画面で特に渡すパラメータなどはないのでsceneのrouteを/galleryとして登録しておきます。

Kotlin
    PreComposeApp {
        val navigator = rememberNavigator()
        NavHost(
            navigator = navigator,
            navTransition = NavTransition(),
            initialRoute = "TODO"
        ) {
            scene(
                route = "/gallery",
                navTransition = NavTransition(),
            ) {
                GalleryScreen(
                    pictures = Pictures.value,
                    onNavigatePreview = { /** TODO プレビュー画面への遷移を実装する */ },
                    modifier = Modifier.fillMaxSize()
                )
            }
        }
    }

「プレビュー画面」を定義する

「プレビュー画面」は「ギャラリー画面」で選択した画像によって表示する画像が決まります。そのため遷移時にどの画像を選択したかパラメータとして渡す必要があります。

PreComposeのsceneのrouteでは/preview/{name}という感じで中括弧でパラメータを指定すると画面遷移時に指定したパラメータをbackStackEntry.path<String>("name")で取得できるようになっています。

なのでこの仕組みを利用して画面遷移時に指定したパラメータを取得して画面を表示できるように定義します。

Kotlin
    PreComposeApp {
        val navigator = rememberNavigator()
        NavHost(
            navigator = navigator,
            navTransition = NavTransition(),
            initialRoute = "TODO"
        ) {
            ... 省略 
            scene(
                route = "/preview/{name}",
                navTransition = NavTransition(),
            ) { backStackEntry ->
                val name = backStackEntry.path<String>("name")
                val picture = Pictures.value.firstOrNull { it.name == name } ?: return@scene

                PreviewScreen(
                    picture = picture,
                    onNavigateDetails = { /** TODO プレビュー画面への遷移を実行する */ },
                    onBack = { /** TODO 戻る制御を実装する  */ },
                    modifier = Modifier.fillMaxSize()
                )
            }
        }
    }

ダイアログを定義する

PreComposeでダイアログを定義するにはdialogを利用してダイアログを登録する必要があります。

Kotlin
    /**
     * Add the dialog [Composable] to the [RouteBuilder], which will show over the scene
     * @param route route for the destination
     * @param content composable for the destination
     */
    fun dialog(
        route: String,
        content: @Composable (BackStackEntry) -> Unit,
    ) {
        floating(
            route,
            content,
        )
    }

今回は「詳細情報ダイアログ」に画面遷移したいので、下記の実装方法でsceneを利用して画面を登録します。

「詳細情報ダイアログ」を定義する

Kotlin
  PreComposeApp {
        val navigator = rememberNavigator()
        NavHost(
            navigator = navigator,
            navTransition = NavTransition(),
            initialRoute = "/gallery"
        ) {
            ... 省略 
            dialog(
                route = "/details/{name}"
            ) { backStackEntry ->
                val name = backStackEntry.path<String>("name")
                val picture = Pictures.value.firstOrNull { it.name == name } ?: return@dialog
                DetailsDialog(
                    picture = picture,
                    onBack = { /** TODO 戻る制御を実装する  */ },
                    modifier = Modifier.wrapContentSize()
                )
            }
        }
    }

初期表示する画面を決める

このままだと初期表示する画面が決まっていない状態です。NavHostのinitialRouteにscreenのパスを指定して、初期表示する画面を決めてあげます。

Kotlin
	PreComposeApp {
        NavHost(
            navigator = navigator,
            navTransition = NavTransition(),
            initialRoute = "path"
        ) {
			scene(route = "path") {
            	...
            }
        }
    }

今回は「ギャラリー画面」を初期表示画面として登録します。なので下記のようにNavHostのinitialRouteを/galleryにしてやります。

「ギャラリー画面」を初期表示する画面として登録する

Kotlin
    PreComposeApp {
        NavHost(
            navigator = navigator,
            navTransition = NavTransition(),
            initialRoute = "/gallery"
        ) {
        ... 省略
    }

特定の画面にパラメータを渡して遷移する

PreComposeで特定の画面にパラメータを渡して遷移するには、PreComposeが提供するクラスのNavigatorを利用します。このNavigatorはrememberNavigatorで取得できるようになっているので、rememberNavigator経由でNavigatorを取得して利用します。

Kotlin
class Navigator { }
Kotlin
@Composable
fun rememberNavigator(): Navigator {
    val stateHolder = LocalStateHolder.current
    return stateHolder.getOrPut("Navigator") {
        Navigator()
    }
}

Navigatorにはnavigateというメソッドがあり、このnavigateにrouteに遷移画面のrouteを指定して画面遷移します。

Kotlin
fun navigate(route: String, options: NavOptions? = null) {
   if (!_initialized) {
        _pendingNavigation = route
        return
    }

    stackManager.push(route, options)
}

今回は「ギャラリー画面」から「プレビュー画面」への遷移が必要になります。「プレビュー画面」への遷移時にはpreview/{name}の中括弧で指定したパラメータを渡す必要があるので、下記のようにNavigatorのnavigateでパラメータ付きのrouteを生成して画面遷移を実行してやります。

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

Kotlin
        val navigator = rememberNavigator()
        NavHost(
            navigator = navigator,
            navTransition = NavTransition(),
            initialRoute = "/gallery"
        ) {
            scene(
                route = "/gallery",
                navTransition = NavTransition(),
            ) {
                GalleryScreen(
                    pictures = Pictures.value,
					// 選択した画像のnameをパラメータに含めたrouteを生成してnavigateを呼び出す
                    onNavigatePreview = { navigator.navigate("/preview/${it.name}") },
                    modifier = Modifier.fillMaxSize()
                )
            }
		}

特定のダイアログにパラメータ付きで遷移する

PreComposeでダイアログに遷移するときは、画面の遷移するときと同じ方法で画面遷移できます。「詳細情報ダイアログ」への遷移時にはdetails/{name}の中括弧で指定したパラメータを渡す必要があるので、下記のようにNavigatorのnavigateでパラメータ付きのrouteを生成して画面遷移を実行してやります。

「プレビュー画面」から「詳細情報ダイアログ」への遷移

Kotlin
        val navigator = rememberNavigator()
        NavHost(
            navigator = navigator,
            navTransition = NavTransition(),
            initialRoute = "/gallery"
        ) {
            scene(
                route = "/preview/{name}",
                navTransition = NavTransition(),
            ) { backStackEntry ->
                val name = backStackEntry.path<String>("name")
                val picture = Pictures.value.firstOrNull { it.name == name } ?: return@scene

                PreviewScreen(
                    picture = picture,
					// プレビューしている画像のnameをパラメータに含めたrouteを生成してnavigateを呼び出す
                    onNavigateDetails = { navigator.navigate("/details/${it.name}") },
                    onBack = { /** TODO 戻る制御を実装する  */ },
                    modifier = Modifier.fillMaxSize()
                )
            }
        }
    }

1つ前の画面に戻る

PreComposeで特定の画面やダイアログから1つ前の画面に戻るには、画面遷移と同様にNavigatorを利用します。NavigatorにはpopBackStackというメソッドが用意されており、このメソッドを呼び出すことで1つ前の画面に戻ることができます。

今回は「プレビュー画面」から「ギャラリー画面」、「詳細情報ダイアログ」から「プレビュー画面」という感じで各画面やダイアログの1つ画面に戻るようにする必要があるので、下記のようにNavigatorのpopBackStackで1つ前の画面に戻るようにしてやります。

「プレビュー画面」から1つ前の「ギャラリー画面」に戻る

Kotlin
    PreComposeApp {
        val navigator = rememberNavigator()
        NavHost(
            navigator = navigator,
            navTransition = NavTransition(),
            initialRoute = "/gallery"
        ) {
			...省略
            scene(
                route = "/preview/{name}",
                navTransition = NavTransition(),
            ) { backStackEntry ->
                val name = backStackEntry.path<String>("name")
                val picture = Pictures.value.firstOrNull { it.name == name } ?: return@scene

                PreviewScreen(
                    picture = picture,
                    onNavigateDetails = { navigator.navigate("/details/${it.name}") },
					// PopBackStackで1つ前の画面に戻る
                    onBack = { navigator.popBackStack() },
                    modifier = Modifier.fillMaxSize()
                )
            }
			...省略
        }
    }

「詳細情報画面」から「プレビュー画面」に戻る

Kotlin
    PreComposeApp {
        val navigator = rememberNavigator()
        NavHost(
            navigator = navigator,
            navTransition = NavTransition(),
            initialRoute = "/gallery"
        ) {
			...省略
            dialog(
                route = "/details/{name}"
            ) { backStackEntry ->
                val name = backStackEntry.path<String>("name")
                val picture = Pictures.value.firstOrNull { it.name == name } ?: return@dialog
                DetailsDialog(
                    picture = picture,
             		// PopBackStackで1つ前の画面に戻る
		        	onBack = { navigator.popBackStack() },
                    modifier = Modifier.wrapContentSize()
                )
            }
			...省略
        }
    }

動作確認

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

Kotlin
    PreComposeApp {
        val navigator = rememberNavigator()
        NavHost(
            navigator = navigator,
            navTransition = NavTransition(),
            initialRoute = "/gallery"
        ) {
            scene(
                route = "/gallery",
                navTransition = NavTransition(),
            ) {
                GalleryScreen(
                    pictures = Pictures.value,
                    onNavigatePreview = { navigator.navigate("/preview/${it.name}") },
                    modifier = Modifier.fillMaxSize()
                )
            }

            scene(
                route = "/preview/{name}",
                navTransition = NavTransition(),
            ) { backStackEntry ->
                val name = backStackEntry.path<String>("name")
                val picture = Pictures.value.firstOrNull { it.name == name } ?: return@scene

                PreviewScreen(
                    picture = picture,
                    onNavigateDetails = { navigator.navigate("/details/${it.name}") },
                    onBack = { navigator.popBackStack() },
                    modifier = Modifier.fillMaxSize()
                )
            }

            dialog(
                route = "/details/{name}"
            ) { backStackEntry ->
                val name = backStackEntry.path<String>("name")
                val picture = Pictures.value.firstOrNull { it.name == name } ?: return@dialog
                DetailsDialog(
                    picture = picture,
                    onBack = { navigator.popBackStack() },
                    modifier = Modifier.wrapContentSize()
                )
            }
        }
    }

このコードを実行すると下記のようにiOSとAndroidの両方で問題なく画面遷移ができます。

Android
iOS

個人的な評価

PreComposeは画面遷移ライブラリの中でもJetpack ComposeでNavigation Composeを利用している人であれば簡単に導入できる画面遷移ライブラリかなと感じました。routeの設計などのノウハウは必要になりますが、画面遷移の処理を集約することができる構造になっている、また複雑な画面遷移の処理を構築することも可能なので、PreComposeは多少難しさがあるものの、どのようなアプリでも作ることができるバランスが良い画面遷移ライブラリかなと感じました。

メリット

  • PreComposeはJetpack ComposeのNavigation Composeとほぼ実装方法が同じ
  • PreComposeでは画面遷移を集約して管理することができるので、管理がしやすい&複雑な画面遷移も構築できる

デメリット

  • PreComposeではrouteを文字列で表現するため、その辺りの設計方針を決めて実装する必要がある
  • PreComposeではrouteにパラメータを埋め込む形式で、プリミティブ型でパラメータを埋め込む必要があり、data classなどのオブジェクトを渡す際には工夫が必要になる

評価詳細

項目 評価(最高・良い・課題あり) 理由など
セットアップのしやすさ 最高 ・依存関係を追加して、ルートにPreComposeApp・NavHostを配置するだけ、なので特に難しくもなく良さそう
途中で移行しやすいか 最高 ・各画面やダイアログの実装をsceneやdialogで登録するだけなので、もし画面遷移ライブラリを変えようとした場合にでも、問題なく移行が可能
画面定義の実装しやすさ 最高 ・Composable関数に画面やダイアログを実装するだけなので特に難しいことはない
画面遷移の実装しやすさ 課題あり ・sceneやdialogで画面を登録していく形式で、sceneやdialogにはrouteを指定する必要がある。routeの構造やパラメータなどの定義をするのがやや難しがある
・またNavigatorのnavigateを呼び出す時にrouteを生成して渡す必要もあるので、その辺りの実装方針を決めたりする必要があるので手間がかかりそう
画面遷移のパラメータ渡しやすさ 課題あり ・routeにパラメータを埋め込む形式であるため基本的にはプリミティブ型しか渡せない構造になっている。
・もしdata classなどのオブジェクトを渡したいときには文字列型に変換するなどの処理が必要になるため決して渡しやすい構造ではない

おわりに

今回はPreComposeについて評価してみました、次はDecompose Routerについて評価してみたいと思います。

参考文献