События пользовательского интерфейса

События пользовательского интерфейса — это действия, которые должны обрабатываться на уровне пользовательского интерфейса, либо самим пользовательским интерфейсом, либо ViewModel. Наиболее распространенный тип событий — это пользовательские события . Пользователь генерирует пользовательские события, взаимодействуя с приложением, например, касаясь экрана или выполняя жесты. Затем пользовательский интерфейс обрабатывает эти события с помощью коллбэков, таких как лямбда-функции, определенные в различных компонуемых объектах.

В ViewModel обычно отвечает за обработку бизнес-логики конкретного пользовательского события — например, нажатия пользователем кнопки для обновления данных. Как правило, ViewModel делает это, предоставляя функции, которые может вызывать пользовательский интерфейс. Пользовательские события также могут содержать логику поведения пользовательского интерфейса, которую интерфейс может обрабатывать напрямую — например, переход на другой экран или отображение Snackbar .

Хотя бизнес-логика остается неизменной для одного и того же приложения на разных мобильных платформах или форм-факторах, логика поведения пользовательского интерфейса — это деталь реализации, которая может отличаться в зависимости от ситуации. На странице уровня пользовательского интерфейса эти типы логики определяются следующим образом:

  • Бизнес-логика определяет, что делать с изменениями состояния — например, совершить платеж или сохранить пользовательские настройки. Обычно эта логика обрабатывается на уровне предметной области и данных. В данном руководстве класс ViewModel из архитектурных компонентов используется как продуманное решение для классов, обрабатывающих бизнес-логику.
  • Логика поведения пользовательского интерфейса , или логика UI, описывает, как отображать изменения состояния — например, логику навигации или способ отображения сообщений пользователю. Эту логику обрабатывает сам пользовательский интерфейс.

Дерево принятия решений по событиям пользовательского интерфейса

На следующей диаграмме представлено дерево решений для поиска наилучшего подхода к обработке конкретного сценария использования события. В остальной части этого руководства эти подходы объясняются подробно.

Если событие возникло в ViewModel, обновите состояние пользовательского интерфейса. Если событие возникло в пользовательском интерфейсе и требует бизнес-логики, делегируйте бизнес-логику ViewModel. Если событие возникло в пользовательском интерфейсе и требует логики поведения пользовательского интерфейса, измените состояние элемента пользовательского интерфейса непосредственно в пользовательском интерфейсе.
Рисунок 1. Дерево решений для обработки событий.

Обработка событий пользователя

Пользовательский интерфейс может обрабатывать события напрямую, если эти события связаны с изменением состояния элемента интерфейса — например, состояния раскрывающегося элемента. Если событие требует выполнения бизнес-логики, такой как обновление данных на экране, оно должно обрабатываться ViewModel.

В следующем примере показано, как различные кнопки используются для развертывания элемента пользовательского интерфейса (логика пользовательского интерфейса) и для обновления данных на экране (бизнес-логика):

@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")
        }
    }
}

Пользовательские события в ленивых списках

Если действие выполняется ниже по дереву пользовательского интерфейса, например, в элементе LazyColumn , то обработку событий пользователя по-прежнему должна осуществлять ViewModel .

Например, рассмотрим список кликабельных элементов. Не передавайте экземпляр 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 (события ViewModel), всегда должны приводить к обновлению состояния пользовательского интерфейса . Это соответствует принципам однонаправленного потока данных . Это обеспечивает воспроизводимость событий после изменения конфигурации и гарантирует, что действия пользовательского интерфейса не будут потеряны. При желании вы также можете обеспечить воспроизводимость событий после завершения процесса, используя модуль сохранения состояния .

Сопоставление действий пользовательского интерфейса с состоянием интерфейса — не всегда простой процесс, но он приводит к более простой логике. Ваш мыслительный процесс не должен ограничиваться, например, определением того, как перевести интерфейс на определенный экран. Вам нужно думать дальше и учитывать, как представить этот пользовательский поток в состоянии интерфейса. Другими словами: не думайте о том, какие действия должен совершать интерфейс; думайте о том, как эти действия влияют на состояние интерфейса.

Например, рассмотрим экран входа в систему. Состояние пользовательского интерфейса этого экрана можно смоделировать следующим образом:

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

Экран входа в систему реагирует на изменения состояния пользовательского интерфейса.

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
        )
    }
}

Потребление событий может инициировать обновление состояния.

Обработка определенных событий ViewModel в пользовательском интерфейсе может привести к другим обновлениям состояния интерфейса. Например, при отображении временных сообщений на экране, чтобы сообщить пользователю о произошедшем событии, пользовательский интерфейс должен уведомить ViewModel о необходимости запуска обновления состояния после отображения сообщения на экране. Событие, происходящее после того, как пользователь обработал сообщение (закрыв его или по истечении тайм-аута), можно рассматривать как «пользовательский ввод», и, следовательно, ViewModel должен это учитывать. В этой ситуации состояние пользовательского интерфейса можно смоделировать следующим образом:

// 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 будет обновлять состояние пользовательского интерфейса следующим образом:

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 не нужно знать, как пользовательский интерфейс отображает сообщение на экране; он просто знает, что есть сообщение от пользователя, которое необходимо показать. После того, как временное сообщение будет показано, пользовательский интерфейс должен уведомить об этом ViewModel, что вызовет еще одно обновление состояния пользовательского интерфейса для очистки свойства 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()
        }
    }
}

Несмотря на то, что сообщение носит временный характер, состояние пользовательского интерфейса точно отражает то, что отображается на экране в каждый конкретный момент времени. Либо сообщение пользователя отображается, либо нет.

В разделе « Использование событий может запускать обновления состояния» подробно описано, как использовать состояние пользовательского интерфейса для отображения сообщений пользователя на экране. События навигации также являются распространенным типом событий в приложениях Android.

Если событие срабатывает в пользовательском интерфейсе из-за нажатия пользователем кнопки, то пользовательский интерфейс обрабатывает это, предоставляя доступ к событию вызывающему компоненту.

@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 и позволяет запускать функцию onHelp только тогда, когда жизненный цикл как минимум находится RESUMED .

Если ввод данных требует проверки с помощью бизнес-логики перед переходом по странице, ViewModel должен предоставить это состояние пользовательскому интерфейсу. Пользовательский интерфейс будет реагировать на изменение этого состояния и выполнять соответствующую навигацию. Этот вариант использования описан в разделе «Обработка событий 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) не сохраняется в стеке возврата. Пользователи не могут вернуться к ней, нажав кнопку «Назад». Однако в случаях, когда это может произойти, решение потребует дополнительной логики.

Когда ViewModel устанавливает некоторое состояние, которое генерирует событие навигации с экрана A на экран B, и экран A остается в стеке возврата навигации, может потребоваться дополнительная логика, чтобы предотвратить автоматический переход к экрану B. Для реализации этого необходимо дополнительное состояние, указывающее, следует ли пользовательскому интерфейсу переходить на другой экран. Обычно это состояние хранится в пользовательском интерфейсе, поскольку логика навигации относится к пользовательскому интерфейсу, а не к ViewModel. Для иллюстрации рассмотрим следующий пример использования.

Предположим, вы находитесь на этапе регистрации в вашем приложении. На экране проверки даты рождения , когда пользователь вводит дату, она проверяется 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 делегирует эту логику слою данных. Логика перехода пользователя на следующий экран — это логика пользовательского интерфейса , поскольку эти требования могут меняться в зависимости от конфигурации интерфейса. Например, на планшете может не потребоваться автоматический переход на другой экран, если одновременно отображается несколько этапов регистрации. Переменная validationInProgress в приведенном выше коде реализует эту функциональность и определяет, должен ли пользовательский интерфейс автоматически переходить на следующий этап регистрации, если дата рождения действительна.

Другие варианты использования

Если вы считаете, что вашу задачу, связанную с событиями пользовательского интерфейса, нельзя решить с помощью обновлений состояния интерфейса, вам, возможно, следует пересмотреть способ передачи данных в вашем приложении. Учитывайте следующие принципы:

  • Каждый класс должен выполнять свои функции, не более того. Пользовательский интерфейс отвечает за логику поведения, специфичную для каждого экрана, такую ​​как вызовы навигации, события кликов и получение запросов на разрешение. Модель представления содержит бизнес-логику и преобразует результаты из нижних уровней иерархии в состояние пользовательского интерфейса.
  • Подумайте о том, откуда исходит событие. Следуйте дереву решений , представленному в начале этого руководства, и пусть каждый класс обрабатывает то, за что он отвечает. Например, если событие исходит из пользовательского интерфейса и приводит к событию навигации, то это событие должно быть обработано в пользовательском интерфейсе. Часть логики может быть делегирована ViewModel, но обработка события не может быть полностью делегирована ViewModel.
  • Если у вас несколько потребителей и вы беспокоитесь о том, что событие будет обработано несколько раз, вам, возможно, следует пересмотреть архитектуру вашего приложения. Наличие нескольких одновременно работающих потребителей приводит к тому, что гарантировать доставку события ровно один раз становится крайне сложно, поэтому уровень сложности и тонкостей поведения резко возрастает. Если у вас возникла эта проблема, подумайте о том, чтобы перенести эти проблемы на более высокий уровень в дереве пользовательского интерфейса; вам может потребоваться другая сущность, расположенная выше в иерархии.
  • Подумайте о том, когда необходимо использовать состояние приложения. В некоторых ситуациях вам может не понадобиться постоянно использовать состояние, когда приложение находится в фоновом режиме — например, при отображении Toast . В таких случаях лучше использовать состояние, когда пользовательский интерфейс находится на переднем плане.

Образцы

Приведенные ниже примеры от Google демонстрируют события пользовательского интерфейса на уровне пользовательского интерфейса. Изучите их, чтобы увидеть эти рекомендации на практике:

Дополнительные ресурсы

Для получения дополнительной информации о событиях пользовательского интерфейса см. следующие дополнительные ресурсы:

Кодлабс

Документация

Просмотры контента

{% verbatim %} {% endverbatim %} {% verbatim %} {% endverbatim %}