3
1

Type Safety に Navigation Compose を使う場合、SavedStateHandle 経由で NavArg を受け取るときは typeMap を渡そう

Last updated at Posted at 2024-08-14

結論

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
Arg.kt
enum class Arg {
  X,
  Y,
}
Route.kt
Route.kt
sealed interface Route {
  data object Screen1 : Route

  data class Screen2(
    val arg: Arg,
  ) : Route
}
Screen1.kt
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
Screen2ViewModel.kt
@HiltViewModel
class Screen2ViewModel @Inject constructor(
  savedStateHandle: SavedStateHandle,
) : ViewModel() {
  // 🚨 ここでも typeMap を渡す必要がある
  val navArg = savedStateHandle.toRoute<Route.Screen2>()
}
Screen2.kt
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
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 を渡す必要があります。

今回の例では次の修正が必要です。

Screen2ViewModel.kt
 @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#toRoutetypeMap にはデフォルト引数として emptyMap が渡されていることもあり、typeMap の存在を完全に見落としていました。

ちなみに SavedStateHandle#toRoute から typeMap の行方を追っていくと次のコードに行き着きます。

AndroidX Tech: Source Code for RouteSerializer.kt から転載

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 を渡しましょう!

思い込みから抜け出すことは難しいですね。
久しぶりに、コードを書いていて発狂しました。

話は逸れますが、光が粒子と波の二重性をもつと気づいた人は本当にすごいですよね。
わたしも常識を疑っていきます。

3
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
1