UI イベント

UI イベント は、UI または ViewModel によって UI レイヤで処理する必要があるアクションです。最も一般的なタイプのイベントは、ユーザー イベントです。ユーザーは画面をタップすることで、またはジェスチャーを生成することで、アプリを操作してユーザー イベントを生成します。UI は、さまざまなコンポーザブルで定義された コールバック(ラムダなど)を使用してこれらのイベントを消費します。

ViewModel は通常、特定のユーザー イベント(ユーザーがボタンをクリックしてデータを更新するなど)のビジネス ロジックを処理します。通常、ViewModel は UI が呼び出せる関数を公開することにより、この処理を行います。ユーザー イベントは、別の画面に移動する、Snackbar を表示するなど、UI が直接処理できる UI 動作ロジックを持つ場合もあります。

同じアプリを異なるモバイル プラットフォームやフォーム ファクタで使用してもビジネス ロジックは変わりませんが、UI の動作ロジックは実装の詳細であり、ケース間で異なる可能性があります。UI レイヤのページでは、このようなロジックを次のように定義しています。

  • ビジネス ロジックとは、支払いやユーザー設定の保存など、状態の変化を処理する方法を指します。通常、ドメインレイヤとデータレイヤがこのロジックを処理します。このガイドでは、ビジネス ロジックを処理するクラス向けの独自のソリューションとして、アーキテクチャ コンポーネントの ViewModel クラスを使用します。
  • UI 動作ロジックまたは UI ロジックとは、ナビゲーション ロジックやユーザーへのメッセージ表示方法など、状態の変化を表示する方法を指します。UI がこのロジックを処理します。

UI イベントの決定木

次の図は、特定のイベントのユースケースを処理するために最適なアプローチを見つける決定木を示しています。以降、このガイドではこうしたアプローチについて詳しく説明します。

イベントが ViewModel で発生した場合は、UI の状態を更新します。イベントが UI で発生し、ビジネス ロジックを必要とする場合は、ビジネス ロジックを ViewModel にデリゲートします。イベントが UI で発生し、UI の動作ロジックを必要とする場合は、UI 要素の状態を UI で直接変更します。
図 1. イベント処理の決定木。

ユーザー イベントを処理する

ユーザー イベントが UI 要素の状態(展開可能なアイテムの状態など)の変更に関連する場合、UI でユーザー イベントを直接処理できます。画面上のデータを更新するなど、イベントがビジネス ロジックを実施する必要がある場合は、ViewModel で処理する必要があります。

次の例は、さまざまなボタンを使用して、UI 要素を展開する方法(UI ロジック)と、画面上のデータを更新する方法(ビジネス ロジック)を示しています。

@Composable
fun LatestNewsScreen(viewModel: LatestNewsViewModel = viewModel()) {

    // State of whether more details should be shown
    var expanded by remember { mutableStateOf(false) }

    Column {
        Text("Some text")
        if (expanded) {
            Text("More details")
        }

        Button(
        // The expand details event is processed by the UI that
        // modifies this composable's internal state.
        onClick = { expanded = !expanded }
        ) {
        val expandText = if (expanded) "Collapse" else "Expand"
        Text("$expandText details")
        }

        // The refresh event is processed by the ViewModel that is in charge
        // of the UI's business logic.
        Button(onClick = { viewModel.refreshNews() }) {
            Text("Refresh data")
        }
    }
}

Lazy リストのユーザー イベント

LazyColumn アイテムのように、UI ツリーの下位でアクションが生成される場合であっても、ViewModel はユーザー イベントを処理する必要があります。

たとえば、クリック可能なアイテムのリストについて考えてみましょう。UI コンポーネントが実装の詳細に密接に結合されるため、ViewModel インスタンスをリスト コンポーザブル(MyList)に渡さないでください。

代わりに、コンポーザブルでイベントをラムダ関数パラメータとして公開します。 これにより、リストは、誰がどのように処理するかを知らなくてもイベントをトリガーできます。

data class MyItem(val id: Int)

@Composable
fun MyList(
    items: List<String>,
    onItemClick: (MyItem) -> Unit
) {
    Card {
        LazyColumn {
            itemsIndexed(items) { index, string ->
                ListItem(
                    modifier = Modifier.clickable {
                        onItemClick(MyItem(index))
                    },
                    headlineContent = {
                        Text(text = string)
                    }
                )
            }
        }
    }
}

このアプローチでは、MyList コンポーザブルは、表示するデータと公開するイベントのみを処理します。ViewModel にアクセスすることはできません。イベントはホイストされ、前のコンポーザブルの ViewModel に渡されます。

イベント処理の詳細については、Compose のイベントをご覧ください。

ユーザー イベント関数とイベント ハンドラの命名規則

このガイドで、ユーザー イベントを処理する ViewModel 関数には、処理するアクションに基づいた動詞で名前が付けられています(validateInput()login() など)。

Compose のイベント ハンドラは、データの流れを明確にするために標準の命名規則に従います。

  • パラメータ名: on + Verb + Target(例: onExpandClicked または onValueChange)。
  • ラムダ式: コンポーザブルを呼び出す場合、ラムダは多くの場合、そのイベントの実装にすぎません。

ViewModel イベントを処理する

ViewModel からの UI アクション(ViewModel イベント)は、常に UI の状態を更新する結果となる必要があります。これは、単方向データフローの原則に従っています。これにより、設定変更後にイベントが再現可能になり、UI アクションが失われないことが保証されます。保存済み状態モジュールを使用している場合は、プロセス終了後にイベントを再現可能にすることもできます。

UI アクションを UI の状態にマッピングすることは必ずしも簡単な処理ではありませんが、ロジックは単純になります。たとえば、UI を特定の画面に移動させる方法を決定するだけで思考プロセスを終えてはなりません。思考を進めて、ユーザーフローを UI の状態で表現する方法を検討する必要があります。つまり、UI が行う必要のあるアクションを考えるのではなく、そのアクションが UI の状態に与える影響を考えてください。

たとえば、ログイン画面について考えてみましょう。この画面の UI の状態は次のようにモデル化できます。

data class LoginUiState(
    val isLoginInProgress: Boolean = false,
    val errorMessage: String? = null,
    val isUserLoggedIn: Boolean = false
)

ログイン画面は、UI の状態の変化に反応します。

class LoginViewModel : ViewModel() {

    var uiState by mutableStateOf(LoginUiState())

    fun tryLogin(username: String, password: String) {
        viewModelScope.launch {
            // Emit a new state indicating that login is in progress
            uiState = uiState.copy(isLoginInProgress = true)

            uiState = if (login(username, password)) {
                // Emit a new state indicating that login was successful
                uiState.copy(isLoginInProgress = false, isUserLoggedIn = true)
            } else {
                // Emit a new state with the error message
                LoginUiState(isLoginInProgress = false, errorMessage = "Login failed")
            }
        }
    }

    private suspend fun login(username: String, password: String): Boolean {
        delay(1000)
        return (username == "Hello" && password == "World!")
    }
}

@Composable
fun LoginScreen(viewModel: LoginViewModel, onSuccessfulLogin: () -> Unit) {

    val uiState = viewModel.uiState

    LaunchedEffect(uiState) {
        if (uiState.isUserLoggedIn) {
            onSuccessfulLogin()
        }
    }

    if (uiState.isLoginInProgress) {
        CircularProgressIndicator()
    } else {
        LoginForm(
            onLoginAttempt = { username, password ->
                viewModel.tryLogin(username, password)
            },
            errorMessage = uiState.errorMessage
        )
    }
}

イベントの消費によって状態の更新をトリガーできる

UI で特定の ViewModel イベントを消費することで、他の UI の状態が更新される場合があります。たとえば何かが起こったことをユーザーに知らせるために、画面に一時的なメッセージを表示する場合、メッセージが画面に表示されたときに、別の状態の更新をトリガーするよう UI が ViewModel に通知する必要があります。ユーザーがメッセージを消費したとき(メッセージを閉じる処理、タイムアウト後の処理など)に発生するイベントは「ユーザー入力」として扱うことができるため、このようなイベントを ViewModel が認識する必要があります。こうした状況での UI の状態は、次のようにモデル化できます。

// Models the UI state for the Latest news screen.
data class LatestNewsUiState(
    val news: List<News> = emptyList(),
    val isLoading: Boolean = false,
    val userMessage: String? = null
)

ビジネス ロジックでユーザーに新しい一時的なメッセージを表示する必要がある場合、ViewModel は UI の状態を次のように更新します。

class LatestNewsViewModel(/* ... */) : ViewModel() {

    var uiState by mutableStateOf(LatestNewsUiState())
        private set

    fun refreshNews() {
        viewModelScope.launch {
            // If there isn't internet connection, show a new message on the screen.
            if (!internetConnection()) {
                uiState = uiState.copy(userMessage = "No Internet connection")
                return@launch
            }

            // Do something else.
        }
    }

    fun userMessageShown() {
        uiState = uiState.copy(userMessage = null)
    }
}

ViewModel は、UI がメッセージをどのように画面に表示しているかを認識する必要はありません。表示する必要のあるユーザー メッセージがあるということだけを認識します。一時的なメッセージが表示されると、UI はそれを ViewModel に通知する必要があります。これにより、別の UI の状態が更新されて、userMessage プロパティが消去されます。

@Composable
fun LatestNewsScreen(
    snackbarHostState: SnackbarHostState,
    viewModel: LatestNewsViewModel = viewModel(),
) {
    // Rest of the UI content.

    // If there are user messages to show on the screen,
    // show it and notify the ViewModel.
    viewModel.uiState.userMessage?.let { userMessage ->
        LaunchedEffect(userMessage) {
            snackbarHostState.showSnackbar(userMessage)
            // Once the message is displayed and dismissed, notify the ViewModel.
            viewModel.userMessageShown()
        }
    }
}

メッセージは一時的なものですが、UI の状態は、すべての時点で画面に表示されているものを忠実に示します。ユーザー メッセージは表示されるか、されないかのいずれかです。

UI 状態を使用して画面にユーザー メッセージを表示する方法については、イベントの消費によって状態の更新をトリガーできる をご覧ください。ナビゲーション イベントも、Android アプリで一般的なタイプのイベントです。

ユーザーがボタンをタップしたために UI でイベントがトリガーされた場合、UI は呼び出し元のコンポーザブルにイベントを公開することでこれを処理します。

@Composable
fun LoginScreen(
    onHelp: () -> Unit, // Caller navigates to the help screen
    viewModel: LoginViewModel = viewModel()
) {
    // Rest of the UI
    Button(
        onClick = dropUnlessResumed { onHelp() }
    ) {
        Text("Get help")
    }
}

dropUnlessResumed は Lifecycle ライブラリの一部であり、ライフサイクルが少なくとも RESUMED の場合にのみ onHelp 関数を実行できます。

データ入力がナビゲーションの前にビジネス ロジックの検証を必要とする場合、ViewModel はその状態を UI に公開する必要があります。UI がその状態の変化に反応し、それに応じてナビゲーションを行います。このユースケースについては、ViewModel イベントを処理する をご覧ください。同様のコードは次のとおりです。

@Composable
fun LoginScreen(
    onUserLogIn: () -> Unit, // Caller navigates to the right screen
    viewModel: LoginViewModel = viewModel()
) {
    Button(
        onClick = {
            // ViewModel validation is triggered
            viewModel.tryLogin()
        }
    ) {
        Text("Log in")
    }
    // Rest of the UI

    val lifecycle = LocalLifecycleOwner.current.lifecycle
    val currentOnUserLogIn by rememberUpdatedState(onUserLogIn)
    LaunchedEffect(viewModel, lifecycle)  {
        // Whenever the uiState changes, check if the user is logged in and
        // call the `onUserLogin` event when `lifecycle` is at least STARTED
        snapshotFlow { viewModel.uiState }
            .filter { it.isUserLoggedIn }
            .flowWithLifecycle(lifecycle)
            .collect {
                currentOnUserLogIn()
            }
    }
}

上記の例では、現在のデスティネーションである Login がバックスタックに保持されないため、アプリは想定どおりに機能します。ユーザーは、[戻る] ボタンを押しても戻れません。ただし、この場合はソリューションに追加のロジックが必要になります。

画面 A から画面 B へのナビゲーション イベントを生成する状態を ViewModel が設定し、画面 A がナビゲーション バックスタックに保持される場合、B に自動的に進まないようにするための追加のロジックが必要になる可能性があります。これを実装するには、UI が別の画面に移動するかどうかを示す追加の状態が必要です。通常、ナビゲーション ロジックは ViewModel ではなく UI の問題であるため、状態は UI に保持されます。 これを説明するために、次のユースケースを考えてみましょう。

たとえば、ユーザーがアプリの登録フローにいるとします。生年月日の検証画面で、ユーザーが日付を入力して [続行] ボタンをタップすると、ViewModel によって日付が検証されます。ViewModel は、検証ロジックをデータレイヤに委任します。日付が有効な場合、ユーザーは次の画面に進みます。追加機能として、ユーザーはデータを変更したい場合に異なる登録画面間を移動できます。そのため、登録フロー内のすべてのデスティネーションが同じバックスタックに保持されます。こうした要件がある場合には、次のようにこの画面を実装できます。

class DobValidationViewModel(/* ... */) : ViewModel() {
    var uiState by mutableStateOf(DobValidationUiState())
        private set
}

@Composable
fun DobValidationScreen(
    onNavigateToNextScreen: () -> Unit, // Caller navigates to the right screen
    viewModel: DobValidationViewModel = viewModel()
) {
    // TextField that updates the ViewModel when a date of birth is selected

    var validationInProgress by rememberSaveable { mutableStateOf(false) }

    Button(
        onClick = {
            viewModel.validateInput()
            validationInProgress = true
        }
    ) {
        Text("Continue")
    }
    // Rest of the UI

    /*
        * The following code implements the requirement of advancing automatically
        * to the next screen when a valid date of birth has been introduced
        * and the user wanted to continue with the registration process.
        */

    if (validationInProgress) {
        val lifecycle = LocalLifecycleOwner.current.lifecycle
        val currentNavigateToNextScreen by rememberUpdatedState(onNavigateToNextScreen)
        LaunchedEffect(viewModel, lifecycle) {
            // If the date of birth is valid and the validation is in progress,
            // navigate to the next screen when `lifecycle` is at least STARTED,
            // which is the default Lifecycle.State for the `flowWithLifecycle` operator.
            snapshotFlow { viewModel.uiState }
                .filter { it.isDobValid }
                .flowWithLifecycle(lifecycle)
                .collect {
                    validationInProgress = false
                    currentNavigateToNextScreen()
                }
        }
    }
}

生年月日の検証は、ViewModel が担うビジネス ロジックです。 ほとんどの場合、ViewModel はそのロジックをデータレイヤに委任します。ユーザーを次の画面に移動させるための要件は UI 構成によって異なる可能性があるため、そのようなロジックは UI ロジックです。たとえば、複数の登録ステップを同時に表示している場合に、タブレット内の別の画面に自動的に進むことは避けてください。上記のコードの validationInProgress 変数はこの機能を実装し、生年月日が有効でユーザーが次の登録ステップに進む場合に、UI が自動的に移動するかどうかを処理します。

その他のユースケース

UI イベントのユースケースを UI イベントの更新では解決できないと思われる場合は、アプリのデータフローを再検討する必要が生じることがあります。次の原則を考慮してください。

  • 各クラスは、担当する処理のみを行う必要があります。UI は、ナビゲーション呼び出し、クリック イベント、権限リクエストの取得など、画面固有の動作ロジックを担います。ViewModel にはビジネス ロジックが含まれており、階層の下位レイヤからの結果を UI の状態に変換します。
  • イベントの発生源を考えます。このガイドの冒頭で示した決定 木に沿って、各 クラスが担うものを処理するようにします。たとえば、イベントが UI から発生し、ナビゲーション イベントとなる場合、そのイベントは UI で処理する必要があります。一部のロジックは ViewModel にデリゲートされますが、イベントの処理を完全に ViewModel にデリゲートすることはできません。
  • 複数のコンシューマーが存在し、イベントが 複数回消費されることが心配な場合、アプリのアーキテクチャを再検討する必要が生じることがあります。 複数のコンシューマーが同時に存在すると、1 回だけ配信されるコントラクトを保証することが非常に難しくなるため、複雑さと微妙な動作が爆発的に増加します。この問題が発生した場合は、そうした事項を UI ツリーの上位に出すことを検討してください。階層の上位にスコープ設定された別のエンティティが必要になる場合があります。
  • 状態を消費する必要が生じるタイミングを考えます。状況によっては、アプリがバックグラウンドにあるときに状態の消費を維持したくない場合があります(Toast を表示するなど)。そのような場合は、UI がフォアグラウンドにあるときに状態を消費することを検討してください。

サンプル

次の Google サンプルは、UI レイヤでの UI イベントを示しています。このガイダンスを実践するためにご利用ください。

参考情報

UI イベントの詳細については、次の追加リソースをご覧ください。

Codelab

ドキュメント

Views のコンテンツ