はじめに
Jetpack Composeで画面を実装していると、ViewModelからUIに対して「何かをしてほしい」場面があります。
例えば、
- 保存に成功したらSnackbarを表示する
- 保存に失敗したことを通知する
- 別の画面へ遷移する
- Toastを表示する
といった処理です。
個人開発しているFoodLogでは、登録した店舗を一覧表示し、追加画面から店舗名や訪問日、評価などを登録できます。
保存処理を実装する際、保存に失敗した場合はSnackbarを表示するようにしました。
このとき、最初に考えたのは、Snackbarに表示するメッセージをUiStateに持たせる方法でした。
例えば、保存に失敗したら、
「保存に失敗しました」という状態にして、それをUI側で検知してSnackbarを表示するような方法です。
しかし、Snackbarは画面に表示し続ける状態というよりも、
「保存に失敗したタイミングで、一度だけ表示してほしい」
という性質のほうが強いと感じました。
そこで今回は、SnackbarをUiStateとは分けて、一度だけ発生するイベントとして扱うことにしました。
この記事では、この実装を例に、
-
UiStateとして扱うもの - 一度だけ処理したいイベントとして扱うもの
- SnackbarやNavigationをどのように考えられるか
について整理します。
UiStateはUIの表示を決める情報
まず、店舗のログを一覧表示するホーム画面を例に考えてみます。
この画面では、例えば次のような情報があります。
- 入力されている検索条件
- 画面に表示する店舗の一覧
- 検索結果が0件かどうか
今回の実装では、検索条件と検索結果をHomeUiStateとして扱っています。
data class HomeUiState(
val query: String = "",
val foodLogs: List<FoodLogUiModel> = emptyList()
) {
val isEmptyState: Boolean
get() = query.isBlank() && foodLogs.isEmpty()
}
例えば、検索条件が「カフェ」で、検索結果が3件だったとします。
画面を再コンポジションする場合も、この情報をもとに、
- 検索欄には「カフェ」を表示する
- 店舗を3件表示する
といったように、現在のUIを組み立てられます。
このように、UiStateは今のUIをどのように表示するかを決める情報として考えています。
Snackbarの表示はUIの状態なのか?
では、保存処理でエラーが発生し、Snackbarを表示する場合はどうでしょうか。
追加画面で保存ボタンを押した結果、保存に失敗したとします。
このとき必要なのは、「保存に失敗した状態」を画面に表示し続けることではありません。
必要なのは、保存に失敗したタイミングでSnackbarを一度表示することです。
ここで、
-
UiStateはUIをどのように表示するかを決める情報 - Snackbarの表示は、あるタイミングで一度だけ実行したい処理
という違いがあります。
この違いから、今回はSnackbarの表示をUiStateには持たせず、イベントとして扱うことにしました。
一度だけ処理したいことをUiEventとして扱う
今回の実装では、追加画面で発生する一度だけ処理したいことをAddEventとして定義しています。
例えば、保存処理の成功や失敗を表す場合は、次のようにできます。
sealed interface AddEvent {
data object Saved : AddEvent
data object SaveFailed : AddEvent
}
SavedやSaveFailedは、現在の画面の状態を表しているわけではありません。
「現在の画面がSaved状態になった」という意味ではなく、保存に成功した、または失敗したという出来事が発生したことを表しています。
ViewModelでは、イベントをSharedFlowとして公開します。
private val _events = MutableSharedFlow<AddEvent>()
val events = _events.asSharedFlow()
StateFlowは最新の値を保持し、UIはその値をもとに現在の表示を組み立てることができます。
一方、今回のSavedやSaveFailedは、UIが現在どのような状態なのかを表す情報ではありません。
保存成功や保存失敗という出来事が発生したタイミングでUI側に伝え、その結果としてSnackbarの表示や画面遷移を行いたいため、今回はイベントをSharedFlowで扱うことにしました。
保存処理でエラーが発生した場合は、イベントを発行します。
viewModelScope.launch {
try {
repository.insert(
FoodLog(
storeName = state.storeName,
visitDate = selectedDate,
rating = state.rating,
memo = state.memo
)
)
_events.emit(AddEvent.Saved)
} catch (e: Exception) {
_events.emit(AddEvent.SaveFailed)
}
}
ここでは、ViewModel自身がSnackbarを表示しているわけではありません。
ViewModelが行っているのは、保存に失敗したというイベントを発行することだけです。
Snackbarを実際に表示する処理は、UI側で行います。
Composable側でイベントを受け取る
AddScreenを呼び出しているAddRouteでは、LaunchedEffectの中でイベントを受け取ります。
LaunchedEffect(viewModel) {
viewModel.events.collect { event ->
when (event) {
AddEvent.SaveFailed -> {
snackbarHostState.showSnackbar(
message = "保存に失敗しました"
)
}
AddEvent.Saved -> {
onSaved()
}
}
}
}
このようにすると、
- 保存処理でエラーが発生する
- ViewModelが
SaveFailedイベントを発行する - UI側がイベントを受け取る
- Snackbarを一度表示する
という流れになります。
LaunchedEffectの中でcollectしているため、Composableの再コンポジションごとにSnackbar表示処理がそのまま実行されるわけではありません。
ここで重要なのは、UIの状態を見てSnackbarを表示するのではなく、発生したイベントを受け取ってSnackbarを表示していることです。
なぜSnackbarをUiStateに持たなかったのか
例えば、SnackbarのメッセージをUiStateに持たせる方法も考えられます。
data class AddUiState(
val storeName: String = "",
val snackbarMessage: String? = null
)
保存に失敗したときに、次のように状態を更新する方法です。
_uiState.update {
it.copy(
snackbarMessage = "保存に失敗しました"
)
}
UI側では、snackbarMessageの値をもとにSnackbarを表示することになります。
例えば、次のような実装です。
LaunchedEffect(uiState.snackbarMessage) {
uiState.snackbarMessage?.let { message ->
snackbarHostState.showSnackbar(message)
viewModel.onSnackbarShown()
}
}
この場合、Snackbarを表示したあとに、snackbarMessageをnullに戻します。
fun onSnackbarShown() {
_uiState.update {
it.copy(snackbarMessage = null)
}
}
この方法でも実装はできます。
ただし、Snackbarを表示するためだけに状態を持つ場合、表示後にその値をどのタイミングで消すかを考える必要があります。
例えば、Snackbarを表示したあともsnackbarMessageが残り続けると、その値は「現在のUIを表示するための状態」というよりも、「すでに処理された出来事の記録」になってしまいます。
そのため、
- Snackbarを表示したら
nullに戻す - 表示済みであることを別途管理する
といった処理が必要になる場合があります。
もちろん、このような設計が必ず悪いというわけではありません。
ただ、今回のケースではSnackbarの表示自体を「現在の状態」として保持するよりも、一度だけ発生する出来事として扱うほうが意図に合っていると感じました。
Navigationも「一度だけ処理したいこと」と考えられる
Snackbar以外にも、一度だけ処理したいものがあります。
例えば、
- 画面遷移
- Toastの表示
- ダイアログの表示
- 特定の画面を閉じる
などです。
例えば、保存成功時に追加画面からホーム画面へ戻る処理があります。
これも「現在の状態」というより、
保存が成功したので、このタイミングで画面を戻したい
という処理として考えることができます。
そのため、Snackbarと同じようにイベントとして扱う設計もあります。
ただし、Navigationは常にイベントとして扱うとは限りません。
例えば、ログイン状態によって表示する画面そのものを決定する場合は、現在の状態に応じてUIを切り替える設計もできます。
そのため、
「SnackbarやNavigationだからイベントにする」
と決めるのではなく、現在のUIを表示するために必要な情報なのか、それともあるタイミングで一度だけ実行したいことなのかを考えることが重要だと思います。
UiStateとイベントをどう使い分けるか
今回の実装では、次のように考えています。
UiState
現在のUIを表示するために必要なものです。
例えば、
- 入力されている店舗名
- 選択されている訪問日
- 評価
- メモ
- 入力エラーの表示状態
- 保存中かどうか
- 現在表示する店舗一覧
- 検索条件
などです。
例えば、
val isSaving: Boolean = false
はUiStateとして扱っています。
保存中である間はボタンを無効化したり、ボタンの表示を「保存中…」に変更したりするため、現在のUIを表示するために必要な状態だからです。
UiEvent
あるタイミングで一度だけ処理したいことです。
例えば、
- Snackbarを表示する
- 保存成功後に画面を戻る
- エラー通知を表示する
などがあります。
これらは「現在のUIがどうなっているか」を表すものではなく、ある出来事が発生したことに対して処理を行うものとして考えています。
まとめ
今回、自作アプリでSnackbarを実装する際に、UiStateと一度だけ処理したいイベントを分けて考えるようにしました。
簡単に整理すると、次のようになります。
| 種類 | 役割 | 今回の例 |
|---|---|---|
UiState |
今のUIの表示を決める情報 | 入力値、検索条件、店舗一覧、保存中 |
UiEvent |
あるタイミングで一度だけ実行したいこと | Snackbar表示、保存成功後の画面遷移 |
すべてを明確にどちらか一方に分類できるとは限りません。
ただ、「これは画面を再表示するときにも必要な現在の状態なのか」
それとも、「あるタイミングで一度だけ何かを実行したいのか」
と考えることで、UiStateに持たせるべきものとイベントとして扱うべきものを整理しやすくなりました。
SnackbarやNavigationを実装する際に、UiStateにどこまで持たせるか迷ったときの参考になれば幸いです。