はじめに
2024年5月1日にリリースされたNavigationのバージョン2.8.0-alpha08から、型安全なobjectでNavHostのdestinationを指定することができるようになりました。
これに伴い、NavHostに渡すstartDestinationもobjectで指定するようになりました。
公式のサンプルコードを見た際に、startDestinationの指定の仕方が気になったので調べてみました。
サンプルコード引用
// Define a home destination that doesn't take any arguments
@Serializable
object Home
// Define a profile destination that takes an ID
@Serializable
data class Profile(val id: String)
// Now define your NavHost using type safe objects
NavHost(navController, startDestination = Home) { // HomeもProfileも受け取れるなら、あらゆる型を受け取れてしまうのでは?
composable<Home> {
HomeScreen(onNavigateToProfile = { id ->
navController.navigate(Profile(id))
})
}
composable<Profile> { backStackEntry ->
val profile: Profile = backStackEntry.toRoute()
ProfileScreen(profile)
}
}
新しく追加されたNavHostのシグネチャ
実際にどう変わったのか、まずはシグネチャで比較してみます。
@Composable
public fun NavHost(
navController: NavHostController,
startDestination: String,
modifier: Modifier = Modifier,
contentAlignment: Alignment = Alignment.TopStart,
route: String? = null,
// トランジション関連は省略
builder: NavGraphBuilder.() -> Unit
)
@Composable
public fun NavHost(
navController: NavHostController,
startDestination: Any,
modifier: Modifier = Modifier,
contentAlignment: Alignment = Alignment.TopStart,
route: KClass<*>? = null,
typeMap: Map<KType, @JvmSuppressWildcards NavType<*>> = emptyMap(),
// トランジション関連は省略
builder: NavGraphBuilder.() -> Unit
)
比較してみると、startDestinationがStringからAnyに変わっているのが分かります。
やはり想像した通り、startDestinationにはあらゆる型を指定できてしまうようです。
startDestinationがAnyであることへの不安
startDestinationの型がAnyだからといって、誤った値が設定される可能性は低いですし、万が一誤った値が入ったとしても、アプリを起動すればすぐに実行時エラーで検出できます。そのため、過度に心配する必要はないかもしれません。
それでも、型安全を確保するためには、できるだけコンパイル時に誤った値を排除したいものです。
解決策
startDestinationの型がないならstartDestination用のインターフェースを用意すればいいじゃない。
ということで専用のインターフェースを用意し、startDestinationには特定の型の値しか入らないことを保証できるようにしました。
interface StartDestination
// startDestinationの判定処理が完了するまでの間に表示する画面
@Serializable
private object Loading : StartDestination
// アプリの使い方を説明する画面
@Serializable
private object Onboarding : StartDestination
// メイン画面
@Serializable
private object Main : StartDestination
@Composable
fun MTransNavHost(
modifier: Modifier = Modifier,
navController: NavHostController,
) {
// 開始画面を表す状態
val startDestinationState: MutableState<StartDestination> = remember {
mutableStateOf(Loading)
}
// startDestination判定
LaunchedEffect(Unit) {
startDestination(
startDestinationState = startDestinationState,
)
}
NavHost(
navController = navController,
startDestination = startDestinationState.value
) {
composable<Loading> {
// 初回起動判定がすぐに完了する場合は、あえて何も表示しない方がチラつきがなくて良い。
}
composable<Onboarding> {
OnboardingScreen()
}
composable<Main> {
MainScreen()
}
}
}
// 非同期で初回起動を判定するロジック
private suspend fun startDestination(
startDestinationState: MutableState<StartDestination>,
) {
val isFirstLaunch = // DataStoreからフラグを取得する等、初回起動を判定するロジック
startDestinationState.value = if (isFirstLaunch) {
Onboarding
} else {
Main
}
}
説明の都合上、destinationを表すobjectをすべて同じ場所に記述していますが、実務ではそれぞれの画面Composableと同じファイル内で管理したほうがよいでしょう。
まとめ
上記の実装を行うことでstartDestinationの型を固定することができました。
また、副次的なメリットですが、開始画面となるobjectは必ずStartDestination型を実装しているので、どの画面がstartDestinationになりうるか?というのを明示的にコードで表せるようになっています。
参考