私たちのチームは最近、2つの別々のAndroidアプリケーションを必要とするクライアントとのコラボレーションで、Jetpack Composeと全く新しいプロジェクトを行う機会を得ました。このプロジェクトは無事リリースされ、クライアントは最終製品に非常に満足していました。 このブログでは、Jetpack Compose ナビゲーションのパラメータの扱いについて、特に最初はかなり厄介だったので、その過程で得たいくつかの学びを共有したいと思います。
執筆時点では、Jetpack Compose Navigationの最新バージョンは 2.5.3 でしたが, しかし、私たちは2.5.0を使用しました。
この記事に記載するすべてのコードは、カスタムのビジネスロジックを削除し、必要最低限に絞り込み、教育目的にのみ使用されています。
アプリのデザイン
ロジックの流れ
ログイン画面では、ログインに成功した後、データベースに初回ログインかどうかのフラグがあるかどうかをチェックする。初回ログインの場合は、「パスワード変更画面」に遷移し、パスワードを変更する。それ以外の場合は、「ホーム画面」に送られる。
ただし、パスワードの変更画面は、ホーム画面からもいつでも呼び出すことができます。
パスワードの変更画面で、もし firstLogin
が false ならば、ユーザーはまず現在のパスワードを入力するよう求められます。true の場合、現在のパスワードの入力は必要ありません。
主な課題 : ダイナミックな startDestination
を実現する。
Googleは、startDestination
を:
ランチャーからアプリを起動したときに、ユーザーが最初に見る画面です。また、「戻る」ボタンを押した後にランチャーに戻ったときにも、この画面が表示されます。
そしてさらに、次のように明記しています :
作るすべてのアプリは、固定されたスタート地点 を持っています。
しかし、私たちのアプリの設計では:
パスワード変更画面でAndroidの戻るボタンが押された場合。
- firstLogin == trueの場合、アプリを終了します。
- firstLogin == falseの場合、通常通り(ホーム画面へ)戻る。
したがって、startDestination
を変更する方法を見つけなければなりません。
Jetpack Composeのナビゲーション
プロジェクトの基本レイアウト
Jetpack Composeプロジェクトの基本的な構造(パラメータなし)は、次のようなものです:
@Composable
fun MainScreen(
mainViewModel: MainViewModel = viewModel()
) {
val navController = rememberNavController()
Scaffold {
val startDestination = mainViewModel.startDestination.collectAsState().value
NavHost(
navController = navController,
startDestination = startDestination
) {
composable(AppRoute.LOGIN.route) {
LoginScreen(
navController = navController
)
}
composable(AppRoute.HOME.route) {
HomeScreen(
navController = navController
)
}
composable(AppRoute.CHANGE_PASSWORD.route) {
ChangePasswordScreen(
navController = navController
)
}
}
}
}
@Composable
fun LoginScreen(navController: NavController) { ... }
@Composable
fun HomeScreen(navController: NavController) { ... }
@Composable
fun ChangePasswordScreen(navController: NavController) { ... }
この文脈では、MainViewModel
は以下のようになります:
class MainViewModel(application: Application) : AndroidViewModel(application) {
private val _startDestination = MutableStateFlow(AppRoute.LOGIN.route)
val startDestination: StateFlow<String> get() = _startDestination
private fun updateStartDestination(value: String) {
_startDestination.value = value
}
}
また、AppRoute
enumは以下のように定義されています:
enum class AppRoute(
val route: String
) {
LOGIN("login"),
HOME("home"),
CHANGE_PASSWORD("change_password")
}
この設定では、デフォルトの startDestination
は AppRoute.LOGIN.route
に設定されているので、アプリが起動しログイン画面が表示されることになります。
パラメータの追加
将来的に複数のパラメータを追加したい場合は、すべてのパラメータをデータクラスに格納することをお勧めします。データクラスは、GSONライブラリを使って、JSONとの変換や解析が簡単にできるのが便利です。
data class ChangePasswordScreenArguments(
val isFirstLogin: Boolean
)
JSONのシリアライズ/デシリアライズを抽象化するための便利なJSON/String拡張ファイル:
object ExtensionJSON {
fun ChangePasswordScreenArguments.toJson(): String =
URLEncoder.encode(Gson().toJson(this), StandardCharsets.UTF_8.toString())
fun String.toChangePasswordScreenArguments(): ChangePasswordScreenArguments =
Gson().fromJson(this, ChangePasswordScreenArguments::class.java)
}
パスワード変更画面NavHostの構成定義に、初回ログインを表すパラメータを追加する必要があるようになります:
NavHost(/**/) {
/**/
composable("${AppRoute.CHANGE_PASSWORD.route}/{${AppRouteParameter.CHANGE_PASSWORD_SCREEN_ARGS.value}}") { backStackEntry ->
val changePasswordScreenJson =
backStackEntry.arguments?.get(AppRouteParameter.CHANGE_PASSWORD_SCREEN_ARGS.value) as String
ChangePasswordScreen(
navController = navController,
arguments = changePasswordScreenJson.toChangePasswordScreenArguments()
)
}
}
@Composable
fun ChangePasswordScreen(navController: NavController, arguments: ChangePasswordScreenArguments) { /**/ }
このパラメータを追加することで、このパラメータに依存する ChangePasswordScreen
のビジネスロジックを実装することができます。しかし、動的な startDestination
の問題はまだ解決していません。
パラメータを持つ startDestination
1度目の挑戦:クラッシュ
MainViewModel
では、init
ブロックに、アプリが起動した後に、現在ログインしているユーザーの firstLogin
の値を取得し、それに応じて startDestination を更新するコードを追加します。
class MainViewModel(application: Application) : AndroidViewModel(application) {
// ユーザーデータを含むFirestoreコレクション用のレポです。このブログとは関係ない内容です。
private val userRepository: UserRepository =
getApplication<App>().userRepository
init {
viewModelScope.launch {
if (userRepository.isFirstLogin()) {
updateStartDestination(
"${AppRoute.CHANGE_PASSWORD.route}/{${AppRouteParameter.CHANGE_PASSWORD_SCREEN_ARGS.value}}"
)
} else {
updateStartDestination(AppRoute.HOME.route)
}
}
}
}
現在、firstLogin
を false
に設定すると、アプリは正常にホーム画面にナビゲートされます。
しかし、firstLogin
を true
に設定すると、アプリは次のような例外を発生してクラッシュします:
java.lang.IllegalArgumentException: navigation destination change_password/{isFirstLogin:true} is not a direct child of this NavGraph
調べてみると、Jetpack Compose Navigation は Navhost 内のコンポーザブルの定義と startDestination
が一致する必要があり、ルートに引数が含まれるとややこしいことになるようです。
2度目の挑戦:成功
解決策は、startDestinationとして使用する引数に defaultValue
を指定し、さらにその type
を指定する必要があります。今回の場合、引数はJSONで、これはStringです。
composable(
route = "${AppRoute.CHANGE_PASSWORD.route}/{${AppRouteParameter.CHANGE_PASSWORD_SCREEN_ARGS.value }}",
arguments = listOf(navArgument(AppRouteParameter.CHANGE_PASSWORD_SCREEN_ARGS.value ) {
//必要な変更点は以下の2行です。
type = NavType.StringType; defaultValue = ChangePasswordScreenArguments(
isFirstLogin = true
).toJson()
})
) {
backStackEntry ->
val changePasswordScreenJson =
backStackEntry.arguments?.get(AppRouteParameter.CHANGE_PASSWORD_SCREEN_ARGS.value) as String
ChangePasswordScreen(
navController = navController,
arguments = changePasswordScreenJson.toChangePasswordScreenArguments()
)
}
Credits
Article Photo by Leue, Holger
Jetpack Compose logo Google Developers
Sextant Image Freepik