📱

[Android] Jetpack Compose Navigation Type Safe 詳細

に公開

Jetpack Compose Navigationが2.8.0から遷移する時に渡すパラメータが型安全になった。

ルート定義

ルート定義はシリアル化可能なクラスである必要があるのでKotlin Serializationプラグインが必須
遷移時に渡したい引数はデータクラスのプロパティ(下層にも@Serializableをつけ忘れないこと)

デフォルト引数の場合はクエリ、そうでない場合はパスになる

@Serializable
data class FullName(
    val firstName: String,
    val middleName: String? = null,
    val lastName: String,
)
// 生成されるパス
example://full_name/{first_name}/{last_name}?middle_name={middle_name}
@Serializable
sealed class AppDestination {
    @Serializable
    data object Splash : AppDestination()
    @Serializable
    data object ScreenA : AppDestination()
    @Serializable
    data class ScreenB(
        val id: String,
    ) : AppDestination()
    @Serializable
    data class ScreenC(
        val model: CustomModel,
    ) : AppDestination()
}

ナビゲータ定義

ナビゲータ定義は必須ではないが、あれば遷移時の挙動を制御できるので便利
今まではpathを直接書く必要があったが、navigationにルート定義したデータクラスを入れるだけでよくなった

class AppNavigator(
    val navController: NavHostController,
) {
    val navigateToScreenA: () -> Unit = {
        navController.navigate(AppDestination.ScreenA) {
            popUpTo(navController.graph.id) {
                inclusive = true
                saveState = true
            }
            launchSingleTop = true
            restoreState = true
        }
    }
    val navigateToScreenB: (String) -> Unit = {
        navController.navigate(AppDestination.ScreenB(it))
    }
}

ナビゲーション定義

引数なし

composableに型パラメータを指定するだけ

@Composable
fun AppNavGraph(
    modifier: Modifier = Modifier,
    navigator: AppNavigator,
) {
    NavHost(
        navController = navigator.navController,
        startDestination = AppDestination.Splash,
        modifier = modifier,
        enterTransition = {
            EnterTransition.None
        },
        exitTransition = {
            ExitTransition.None
        },
    ) {
        composable<AppDestination.Splash> {
            SplashScreen()
        }
        composable<AppDestination.ScreenA> { backStackEntry ->
            ScreenA()
        }
}

引数あり

composableの型パラメータを指定する
backStackEntry#toRouteで取得

composable<AppDestination.ScreenB> { backStackEntry ->
        val screenB = backStackEntry.toRoute()
            ScreenB(screenB)
        }

カスタム NavType を定義

データクラスで定義したプロパティの型全てにJetpack Compose Navigationは対応しているわけではない
対応している型は以下
https://developer.android.com/guide/navigation/use-graph/pass-data#supported_argument_types

まず、カスタムクラスに@Serializableをつける(下層まで)

@Serializable
data class CustomModel(
    id: String = "",
    data: Data = Data()
)

カスタムクラスをシリアライズ・デシリアイズするためのインライン関数を作る

serializeAsValue
シリアライズされるものによっては、JSONデータに、スラッシュ(/)、カッコ、引用符、スペースなどが含まれることがあり、それがパスと誤認識されるのでUri.encodeしてエスケープする

/**
 * シリアライズ可能な型のためのNavTypeを生成するインライン関数
 * @param T シリアライズしたい型(@Serializableアノテーションが付いている必要があります)
 * @param isNullableAllowed この型がnullを許容するかどうか(デフォルトはfalse)
 * @param json シリアライズ/デシリアライズに使用するKotlinx.serializationのJsonインスタンス
 * @return 指定された型T用にカスタマイズされたNavType
 */
inline fun <reified T : Any> serializableType(
    isNullableAllowed: Boolean = false,
    json: Json = Json,
) = object : NavType<T>(isNullableAllowed = isNullableAllowed) {
    override fun get(bundle: Bundle, key: String) = bundle.getString(key)?.let { json.decodeFromString<T>(it) }
    override fun parseValue(value: String): T = json.decodeFromString(value)
    override fun serializeAsValue(value: T): String = Uri.encode(json.encodeToString(value))
    override fun put(bundle: Bundle, key: String, value: T) = bundle.putString(key, json.encodeToString(value))
}

navigationGraphでtypeMapを指定する
あとは同じ

composable<AppDestinations.ScreenC>(
            typeMap = mapOf(typeOf<CustomModel>() to serializableType<CustomModel>()),
        ) { backStackEntry ->
            val screenC: AppDestination.ScreenC = backStackEntry.toRoute()
            ScreenC(screenC)
        }

ディープリンク

コールドスタート時から起動したい場合はAndroidManifestの設定を行う

<intent-filter android:autoVerify="true">
                <action android:name="android.intent.action.VIEW" />
                <category android:name="android.intent.category.DEFAULT" />
                <category android:name="android.intent.category.BROWSABLE" />
                <data
                    android:scheme="fogefoge"
                    android:host="webview"
                    android:pathPattern="/.*" />
            </intent-filter>

受け取る形式のパスを定義する以下の場合はfogefoge://foge
クエリパラメータが存在する場合でもパスのみで良い

object DeepLinkDestinations {
  private const val CUSTOM_SCHEME = "fogefoge://"
  object ScreenA {
    private const val HOST = "webview"
    const val CUSTOM_PATH = "$CUSTOM_SCHEME$HOST"
  }
}

ルート定義

クエリパラメータはデフォルト引数で定義する

@Serializable
    data class ScreenD(
        val webview: String,
        val a: String = ""
    )

ナビゲーション定義

composable<ScreenD>(
        deepLinks = listOf(
            navDeepLink<ScreenA>(
                basePath = DeepLinkDestinations.ScreenA.CUSTOM_PATH,
            ),
        ),
    ) { backStackEntry ->
        val screenD: ScreenD = backStackEntry.toRoute()
        // 画面UI
        ScreenA(screenD)
    }

以下で確認

adb shell am start -W -a android.intent.action.VIEW -d "fogefoge://webview/https://www.google.com/?a=テスト"

参考
https://developer.android.com/guide/navigation/design/type-safety
https://developer.android.com/develop/ui/compose/navigation
https://medium.com/mercadona-tech/type-safety-in-navigation-compose-23c03e3d74a5
https://tkhs0604.medium.com/implementation-of-deeplinks-with-type-safe-navigation-compose-apis-601b3c9e381c

Discussion