結論
Type Safety に Navigation Compose を使う場合、SavedStateHandle 経由で NavArg を受け取るときは typeMap
を渡しましょう!
Type Safe な Navigation Compose に関するドキュメントはこちら 👇
Type safety in Kotlin DSL and Navigation Compose
ハマっていたこと
NavGraph を定義するとき、typeMap
を渡しているのにアプリがランタイムで落ちる事象が発生しました。
具体的には次のエラーメッセージを確認しました。
java.lang.IllegalArgumentException: Cannot cast page of type XXX to a NavType. Make sure to provide custom NavType for this argument.
サンプルコードは次のとおりです。
Arg.kt
enum class Arg {
X,
Y,
}
Route.kt
sealed interface Route {
data object Screen1 : Route
data class Screen2(
val arg: Arg,
) : Route
}
Screen1.kt
fun NavGraphBuilder.screen1(
onClick: () -> Unit,
) {
composable<Route.Screen1> {
Screen1(onClick = onClick)
}
}
@Composable
fun Screen1(
onClick: () -> Unit,
) {
Button(onClick = onClick) {
Text(text = "Screen2 に遷移")
}
}
Screen2ViewModel.kt
@HiltViewModel
class Screen2ViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
) : ViewModel() {
// 🚨 ここでも typeMap を渡す必要がある
val navArg = savedStateHandle.toRoute<Route.Screen2>()
}
Screen2.kt
fun NavGraphBuilder.screen2() {
// 🚨 ここで typeMap を渡していたから OK だと思っていた
composable<Route.Screen2>(
typeMap = mapOf(typeOf<Arg>() to NavType.EnumType(Arg::class.java)),
) {
Screen2()
}
}
@Composable
fun Screen2(
viewModel: Screen2ViewModel = hiltViewModel(),
) {
Text(text = viewModel.navArg.arg.name)
}
App.kt
@Composable
fun App() {
val navController = rememberNavController()
NavHost(
navController = navController,
startDestination = Route.Screen1,
) {
screen1(
onClick = {
val route = Route.Screen2(arg = Arg.X)
navController.navigate(route)
},
)
screen2()
}
}
解決方法
SavedStateHandle 経由で NavArg を受け取るときにも typeMap
を渡す必要があります。
今回の例では次の修正が必要です。
@HiltViewModel
class Screen2ViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
) : ViewModel() {
- val navArg = savedStateHandle.toRoute<Route.Screen2>()
+ val navArg = savedStateHandle.toRoute<Route.Screen2>(
+ typeMap = mapOf(typeOf<Arg>() to NavType.EnumType(Arg::class.java)),
+ )
}
ハマってしまった原因
Type Safety を使用しないケースでは次のように、Bundle 経由で NavArg を受け取れていました。
※ Navigation with Compose | Jetpack Compose | Android Developers の Retrieve complex data when navigating セクション から転載
class UserViewModel(
savedStateHandle: SavedStateHandle,
private val userInfoRepository: UserInfoRepository
) : ViewModel() {
private val userId: String = checkNotNull(savedStateHandle["userId"])
// Fetch the relevant user information from the data layer,
// ie. userInfoRepository, based on the passed userId argument
private val userInfo: Flow<UserInfo> = userInfoRepository.getUserInfo(userId)
// …
}
そのため、このタイミングで KType と NavType のマッピングを考える必要があるとは思ってもみませんでした。
また SavedStateHandle#toRoute
の typeMap
にはデフォルト引数として emptyMap
が渡されていることもあり、typeMap
の存在を完全に見落としていました。
ちなみに SavedStateHandle#toRoute
から typeMap
の行方を追っていくと次のコードに行き着きます。
※ AndroidX Tech: Source Code for RouteSerializer.kt から転載
@Suppress("UNCHECKED_CAST")
private fun SerialDescriptor.computeNavType(
name: String,
typeMap: Map<KType, NavType<*>>
): NavType<Any?> {
val customType =
typeMap.keys.find { kType -> matchKType(kType) }?.let { typeMap[it] } as? NavType<Any?>
val result = customType ?: getNavType()
if (result == UNKNOWN) {
throw IllegalArgumentException(
"Cannot cast $name of type $serialName to a NavType. Make sure " +
"to provide custom NavType for this argument."
)
}
return result as NavType<Any?>
}
このメソッドは KType に該当する NavType がない場合、 "Cannot cast..."
というエラーメッセージを吐いてくれます。
ハマっていたことで示したエラーメッセージです。
実はこのメソッドは SavedStateHandle#toRoute
のタイミングのみならず、 NavGraph が作成されるときも呼び出されます。(AndroidX Tech: Source Code for NavDestinationBuilder.kt 参照)
今回の例でブレイクポイントを張ってみると、 SerialDescriptor#computeNavType
が2回以上呼び出されることが確認できます。
NavGraph の定義が実行されたときは typeMap
が渡ってきており、SavedStateHandle#toRoute
から呼び出されたときは emptyMap
が使用されていました。
ハマってしまった原因で考えていたように、SavedStateHandle#toRoute
でも KType と NavType のマッピングを考える必要があるとは思ってもみなかったことと、NavGraph を定義するときに typeMap
を渡していたことから、emptyMap
が使用されている事実に数時間悩まれました。
今考えると当然なのですが…
なぜ typeMap
を渡しているのに emptyMap
が使用されることがあるのかと!
まとめ
Type Safety に Navigation Compose を使う場合、SavedStateHandle 経由で NavArg を受け取るときは typeMap
を渡しましょう!
思い込みから抜け出すことは難しいですね。
久しぶりに、コードを書いていて発狂しました。
話は逸れますが、光が粒子と波の二重性をもつと気づいた人は本当にすごいですよね。
わたしも常識を疑っていきます。