はじめに
こんにちは、BASEのPay IDチームでAndroidエンジニアをしている 小林(@eijenson)です。
ショッピングアプリ「Pay ID」のAndroid版アプリの開発を担当しています。
本アプリでは2023年4月にあと払い(Pay ID)という、新しい決済方法の支払いに対応したアプリをリリースしました。
「あと払い(Pay ID)」を提供開始 新たな自社決済ネットワークへの第一歩
これらの機能はJetpack Composeで実装していますが、その際に解決に時間のかかった箇所の紹介と、解決した内容を紹介していきます。
使用言語/ライブラリのバージョン
- Kotlin 1.7.10
- Jetpack Compose 1.3.0
- Paging 3.1.1
- paging-compose 1.0.0-alpha17
- navigation-compose 2.5.2
1.非Compose画面からCompose Navigationを使って引数付きの画面に遷移する
Compose間の画面遷移はCompose Navigationを使うと楽に書くことができます。
https://developer.android.com/jetpack/compose/navigation?hl=en
@Composable fun NavigationApp(val startDestination: String /*最初の遷移先*/) { val navController = rememberNavController() NavHost(navController = navController, startDestination = startDestination) { composable( "profile", ) { ProfileScreen( onNavigateToFriends = { navController.navigate("friendsList") }, /*略*/ ) } composable("friendslist") { FriendsListScreen(/*略*/) } } }
これで、Profile画面からFriendList画面への遷移が実現できます
また、例えばリスト画面から1つの詳細画面に飛びたいといった場合、itemId等の引数が必要になることが多いです。
その場合は url/{itemId}といった形で引数を渡すことができます.
@Composable fun NavigationApp(val startDestination: String /*最初の遷移先*/) { val navController = rememberNavController() NavHost(navController = navController, startDestination = startDestination) { composable( "profile", ) { ProfileScreen( onNavigateToFriends = { navController.navigate("friendsList") }, /*略*/ ) } composable("friendslist") { FriendsListScreen( onNavigateToFriends = { navController.navigate("friend/1234") }, /*略*/) } composable("friend/{itemId}") { FriendScreen(/*略*/) } } }
問題
しかし、Pay IDアプリは現状全画面がJetpack Navigationに移行していないため、非Compose画面からのCompose Navigationの画面遷移をすることになりました。
そこで問題になったのが、今回の機能は最初の遷移した画面から、引数が必要な画面ということでした。
アーキテクチャの原則として、アプリが最初に遷移する画面は固定であることが望ましく、それに準ずる形でCompose Navigationの制約で最初に指定する画面は固定である必要があります。
Composeを使用したナビゲーション#NavHostの作成
画面遷移をJetpack NavigationやCompose Navigationに完全移行している場合は、NavHostが遷移する最初の画面が起動時の画面になるため、問題は無いです。
ですが、今回は途中からCompose Navigationを使うため、この制約に引っかかりました。
@Composable fun NavigationApp( val startDestination:String = "friend/1234" /* friend/1234に最初に遷移したい*/ ) { val navController = rememberNavController() NavHost(navController = navController, startDestination = startDestination) { /* 略 */ composable("friend/{itemId}") { FriendScreen(/*略*/) } } }
freind/1234の画面に直接遷移しようとした場合にはstartDestinationにfriend/1234を指定したいのですが、そうすると
java.lang.IllegalArgumentException: navigation destination friend/1234 is not a direct child of this NavGraph
というエラーが表示されて失敗します。
解決策
調査や試行錯誤をした結果、
composable( "friend/itemId", arguments = listOf(navArgument("itemId") { type = NavType.StringType; defaultValue = "1234" }) ){/* 略 */}
のように
- item_idを/{itemId}ではなく/itemIdにする
- argumentsにを設定し、defaultValueに最初に遷移したい値を指定する
と期待した動作になりました。
前述のとおり、Navigation Composeでは初期遷移は固定の想定なので、ライブラリのバージョンアップによって動きが変わってしまう可能性があります。
今後もJetpack Composeへの移行と遷移処理をNavigation Composeに寄せる作業を進めていき、こちらの書き方は変えていく予定です。
2.スクショ禁止や明るさMAXをJetpack Composeで実現する
あと払い(Pay ID)ではコンビニで支払いを行うためのバーコードを表示しています。
その際にトラブル防止のためにバーコードをスクリーンショットをできないようにしていたり、読み取りやすいように画面の明るさをMAXにしたりしています。
val window: Window? = activity.window // 画面をONのままにする と スクリーンショットを撮れないようにする window?.addFlags(FLAG_KEEP_SCREEN_ON or FLAG_SECURE) // 輝度を最大に指定する window?.attributes?.screenBrightness = BRIGHTNESS_OVERRIDE_FULL
問題と解決策
しかし、この設定は同じActivityの違う画面上でも明るい状態になってしまいます。
そのため、画面を閉じた際には明るさの設定等をもとに戻す必要があります。
Jetpack Composeの画面表示で、画面が閉じられたことを検知するにはDisposableEffectのonDIsposeイベントを使用します。 https://developer.android.com/jetpack/compose/side-effects?hl=en#disposableeffect
そういったことを含めて、コードに表すと以下のような実装になりました。
@OptIn(ExperimentalMaterial3Api::class) @Composable fun MyScreen( viewModel: MyViewModel // 略 ) ){ val activity = LocalContext.current.findActivity() val uiState by viewModel.uiState.collectAsState() if (uiState is MyViewModel.UiState.Success) { activity?.let { activity -> val window: Window? = activity.window // 輝度を最大に指定する window?.attributes?.screenBrightness = BRIGHTNESS_OVERRIDE_FULL // 画面をONのままにする と スクリーンショットを撮れないようにする window?.addFlags(FLAG_KEEP_SCREEN_ON or FLAG_SECURE) } DisposableEffect(Unit) { onDispose { activity?.let { val window: Window? = activity.window // 輝度を指定しない window?.attributes?.screenBrightness = BRIGHTNESS_OVERRIDE_NONE // "画面をONのままにする と スクリーンショットを撮れないようにする" の設定を解除 window?.clearFlags(FLAG_KEEP_SCREEN_ON or FLAG_SECURE) } } } // uiStateに応じたUI表示処理等 }
uiStateはFlowのStateFlowを用いてAPIの実行結果を受け取れるようにしています。
ロード中や通信失敗時に明るくなったりスクショが撮れないようになったりしないように、通信成功時のみ設定を有効にしています。
3.ページングの最後項目取得
リストの一番下の要素にだけレイアウトを変更したいという事になり、最後の要素を検知する必要がありました。
そのため、
val pagingList = currentState.pagingData.collectAsLazyPagingItems() LazyColumn() { items(items = pagingList, key = { it.id }) { item -> val isLast = pagingList[pagingList.itemCount - 1]?.id == item.id, if(isLast){ // Text()等 } /* 略*/ } }
のように実装していました。
問題
しかし上記のやり方は良くなかったことがわかり、pagingのListは要素にアクセスしようとした際にそれが最後間近の場合、Pagingライブラリ側の必要に応じて追加読み込みをします。
つまり、
- pagingのリストの最後にアクセスする
- 追加読み込みする
- Composeの描画のため、isLastの判定するためにpagingListの最後の要素にアクセスする
- 追加読み込みが発生する
- 繰り返す
と、全ての要素を読み込む事になってしまいました。
解決策
そのため、最後の要素かどうか判定する処理を少し変えました。
val isLast = pagingList[pagingList.itemCount - 1]?.id == item.id, // ↓ val isLast = if (pagingList.loadState.append.endOfPaginationReached){ pagingList[pagingList.itemCount - 1]?.id == item.id } else{ false }
endOfPaginationReachedは通信による情報の取得の結果、中身が空だった場合にtrueが返ってくるので、判定に使用しています https://developer.android.com/topic/libraries/architecture/paging/v3-network-db?hl=ja#implement-remotemediator
補足
実装時は上記のような書き方で解決をしていましたが、本記事を書くにあたり、より調べてみたところ
val isLast = pagingList.itemSnapshotList.lastOrNull()?.id == item.id
のようにすれば同じように追加読み込みが発生することなく判定ができました。
今までのは、全てを読み込んだ最後にしか最後用のレイアウトは適応できなかったです。
ですが、こちらは、現在のリストの中で最後の要素かどうかを判定できるため、
- 1ページ目の最後の要素に最後用のレイアウトを適応する
- 追加読込して、2ページ目の結果を表示した際には2ページ目の最後の要素に最後用のレイアウトを適応する
とページそれぞれのタイミングでより適切なレイアウトを表示することができます。
まとめ
今回は、Jetpack Composeで機能を実装したときに困ったところとその解決策をいくつか紹介しました。
記事では大変だった箇所ばかり紹介していますが、Jetpack Composeは全体的には書きやすく、RecyclerViewやConstraintLayoutといった、汎用性は高いが使いこなすのに時間のかかるものを踏まえて、かんたんに似たようなことをできるようにしようといった雰囲気を感じることのできる良い仕組みだと思います。
これからも機能や対応レイアウトが増えていくのが楽しみです。
おわりに
最後になりますが、BASEではAndroid/iOSのエンジニアを募集しています。
興味のある方はぜひ気軽にご連絡ください!
Android:採用情報
iOS:採用情報