Eventos de la IU

Los eventos de la IU son acciones que deben controlarse en la capa de la IU, ya sea mediante la IU o el ViewModel. El tipo de evento más común es el de evento de usuario. El usuario produce eventos de usuario cuando interactúa con la app, por ejemplo, si presiona la pantalla o genera gestos. Luego, la IU consume estos eventos mediante devoluciones de llamada, como expresiones lambda definidas en diferentes elementos componibles.

ViewModel normalmente es responsable de controlar la lógica empresarial de un evento de usuario en particular, por ejemplo, el clic en un botón para actualizar algunos datos. Por lo general, ViewModel controla esto mostrando las funciones que la IU puede llamar. Los eventos de usuario también pueden tener una lógica de comportamiento de IU que la IU puede controlar directamente, por ejemplo, navegar a una pantalla diferente o mostrar un Snackbar.

Si bien la lógica empresarial se mantiene igual para la misma app en diferentes plataformas móviles o factores de forma, la lógica del comportamiento de la IU es un detalle de implementación que puede variar entre esos casos. La página de capas de IU define estos tipos de lógica de la siguiente manera:

  • La lógica empresarial se refiere a qué hacer con los cambios de estado. Por ejemplo, realizar un pago o almacenar las preferencias del usuario. Por lo general, las capas de dominio y los datos controlan esta lógica. En esta guía, se usa la clase ViewModel de componentes de arquitectura como una solución ofrecida para clases que manejan la lógica empresarial.
  • La lógica del comportamiento de la IU o la lógica de la IU se refiere a cómo mostrar los cambios de estado. Por ejemplo, la lógica de navegación o cómo mostrar mensajes al usuario. La IU controla esta lógica.

Árbol de decisión de eventos de la IU

En el siguiente diagrama, se muestra un árbol de decisión a fin de encontrar el mejor enfoque para controlar un caso de uso de un evento en particular. En el resto de esta guía, se explican estos enfoques en detalle.

Si el evento se originó en ViewModel, actualiza el estado de la IU. Si el evento se originó en la IU y requiere lógica empresarial, debes delegar esa lógica a ViewModel. Si el evento se originó en la IU y requiere lógica de comportamiento de la IU, modifica el estado del elemento de la IU directamente en ella.
Figura 1: Árbol de decisión para controlar eventos.

Cómo controlar eventos de usuario

La IU puede controlar eventos de usuario directamente si esos eventos se relacionan con la modificación del estado de un elemento de la IU; por ejemplo, el estado de un elemento expandible. Si el evento requiere lógica empresarial, como actualizar los datos en la pantalla, ViewModel debería procesarlo.

El siguiente ejemplo muestra cómo se usan los diferentes botones para expandir un elemento de la IU (lógica de la IU) y cómo se actualizan los datos en la pantalla (lógica empresarial):

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

Eventos de usuario en listas diferidas

Si la acción se produce más abajo en el árbol de IU, como en un elemento LazyColumn, el elemento ViewModel debería seguir siendo el que controle los eventos del usuario.

Por ejemplo, considera una lista de elementos en los que se puede hacer clic. No pases la instancia ViewModel al elemento componible de la lista (MyList), ya que esto vincula estrechamente el componente de la IU a los detalles de implementación.

En cambio, expón el evento como un parámetro de función lambda en el elemento componible. Esto permite que la lista active el evento sin saber quién lo controla ni cómo.

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

Con este enfoque, el elemento componible MyList solo funciona con los datos que muestra y los eventos que expone. No tiene acceso a ViewModel. El evento se eleva y se pasa a un ViewModel en un elemento componible anterior.

Para obtener más información sobre el control de eventos, consulta Eventos en Compose.

Convenciones de nombres para las funciones de eventos de usuario y los controladores de eventos

En esta guía, las funciones ViewModel que controlan los eventos de usuario se nombran con un verbo en función de la acción que manejan, como validateInput() o login().

Los controladores de eventos en Compose siguen una convención de nombres estándar para que el flujo de datos sea obvio:

  • Nombre del parámetro: on + Verb + Target (por ejemplo, onExpandClicked o onValueChange).
  • Expresión lambda: Cuando se llama al elemento componible, la lambda suele ser solo la implementación de ese evento.

Cómo controlar eventos ViewModel

Las acciones de la IU que se originan en ViewModel (eventos ViewModel) siempre deben dar como resultado una actualización del estado de la IU. Esto cumple con los principios del flujo de datos unidireccional. Permite que los eventos se puedan reproducir después de los cambios de configuración y garantiza que no se pierdan las acciones de IU. De forma opcional, también puedes hacer que los eventos sean reproducibles después del cierre del proceso si usas el módulo de estado guardado.

Asignar acciones de la IU al estado de la IU no siempre es un proceso simple, pero conduce a una lógica más simple. Tu proceso de pensamiento no debería terminar con la determinación de cómo hacer que la IU navegue a una pantalla en particular, por ejemplo. Debes pensar más a fondo y considerar cómo representar ese flujo de usuarios en el estado de tu IU. En otras palabras, no pienses en las acciones que debe realizar la IU, piensa en cómo esas acciones afectan el estado de la IU.

Por ejemplo, considera el caso de una pantalla de acceso. Podrías modelar el estado de la IU de esta pantalla de la siguiente manera:

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

La pantalla de acceso reacciona a los cambios en el estado de la IU.

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

Consumir eventos puede activar actualizaciones de estado

Consumir ciertos eventos ViewModel en la IU puede dar como resultado otras actualizaciones de estado de la IU. Por ejemplo, cuando se muestran mensajes transitorios en la pantalla para informar al usuario que algo ocurrió, la IU debe notificar a ViewModel para activar otra actualización de estado cuando el mensaje se haya mostrado en la pantalla. El evento que ocurre cuando el usuario consume el mensaje (ya sea que lo descarte o se agote el tiempo de espera) se puede tratar como "entrada del usuario", por lo que ViewModel debe estar al tanto. En esta situación, el estado de la IU se puede modelar de la siguiente manera:

// 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 actualizaría el estado de la IU de la siguiente manera cuando la lógica empresarial requiera mostrar un nuevo mensaje transitorio al usuario:

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 no necesita saber cómo la IU muestra el mensaje en la pantalla. Solo sabe que hay un mensaje del usuario que se debe mostrar. Una vez que se muestra el mensaje transitorio, la IU debe notificar a ViewModel al respecto, lo que hará que otra actualización del estado de la IU borre la propiedad 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()
        }
    }
}

Aunque el mensaje es transitorio, el estado de la IU es una representación fiel de lo que se muestra en la pantalla en cada momento. El mensaje del usuario se muestra o no lo hace.

En la sección Consumir eventos puede activar actualizaciones de estado se detalla cómo usas el estado de la IU para mostrar mensajes de usuarios en la pantalla. Los eventos de navegación también son un tipo común de eventos en una app para Android.

Si el evento se activa en la IU porque el usuario presionó un botón, la IU se encarga de eso. Para ello, expone el evento al elemento componible llamador.

@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 forma parte de la biblioteca de Lifecycle y te permite ejecutar la función onHelp solo cuando el ciclo de vida está en RESUMED como mínimo.

Si la entrada de datos requiere alguna validación de la lógica empresarial antes de la navegación, ViewModel tendría que exponer ese estado a la IU. La IU reaccionaría a ese cambio de estado y navegaría en consecuencia. En la sección Cómo controlar eventos ViewModel , se aborda este caso de uso. Este es un código similar:

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

En el ejemplo anterior, la app funciona como se espera porque el destino actual, el acceso, no se mantendría en la pila de actividades. Los usuarios no pueden volver a él si presionan Atrás. Sin embargo, en los casos en que eso pueda suceder, la solución requerirá una lógica adicional.

Cuando ViewModel establece algún estado que produce un evento de navegación de la pantalla A a la pantalla B y la pantalla A se mantiene en la pila de actividades de navegación, es posible que necesites lógica adicional para no avanzar automáticamente a B. Para implementar esto, necesitas un estado adicional para indicar si la IU debe navegar a la otra pantalla. Por lo general, ese estado se mantiene en la IU porque la lógica de navegación es un asunto de la IU, no de ViewModel. Para ilustrar esto, considera el siguiente caso de uso.

Supongamos que estás en el flujo de registro de tu app. En la pantalla de validación de fecha de nacimiento, cuando el usuario ingresa una fecha, ViewModel la valida cuando se presiona el botón "Continuar". ViewModel delega la lógica de validación a la capa de datos. Si la fecha es válida, el usuario pasa a la siguiente pantalla. Como función adicional, los usuarios pueden alternar entre las diferentes pantallas de registro en caso de que quieran cambiar algunos datos. Por lo tanto, todos los destinos en el flujo de registro se mantienen en la misma pila de actividades. Teniendo en cuenta estos requisitos, puedes implementar esta pantalla de la siguiente manera:

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

La validación de la fecha de nacimiento es la lógica empresarial de la que es responsable ViewModel. La mayoría de las veces, ViewModel delegaría esa lógica a la capa de datos. La lógica para llevar al usuario a la siguiente pantalla es la lógica de la IU porque estos requisitos pueden cambiar según la configuración de la IU. Por ejemplo, es posible que no desees avanzar automáticamente a otra pantalla en una tablet si muestras varios pasos de registro al mismo tiempo. La variable validationInProgress en el código anterior implementa esta funcionalidad y controla si la IU debe navegar automáticamente cuando la fecha de nacimiento es válida y el usuario desea continuar con el siguiente paso de registro.

Otros casos prácticos

Si crees que no se puede resolver el caso de uso de tu evento de la IU con actualizaciones de estado de la IU, es posible que debas volver a considerar cómo fluyen los datos en tu app. Considera los siguientes principios:

  • Cada clase debe hacer lo que le corresponde, no más. La IU se encarga de la lógica de comportamiento específica de la pantalla, como las llamadas de navegación, los eventos de clic y la obtención de permisos. ViewModel contiene lógica empresarial y convierte los resultados de capas inferiores de la jerarquía en el estado de la IU.
  • Piensa en el lugar donde se origina el evento. Sigue el árbol de decisión que se presenta al comienzo de esta guía y haz que cada clase controle la tarea por la que es responsable. Por ejemplo, si el evento se origina en la IU y genera un evento de navegación, ese evento se debe controlar en la IU. Parte de la lógica se puede delegar al ViewModel, pero el control del evento no se puede delegar por completo al ViewModel.
  • Si tienes varios consumidores y te preocupa que el evento se consuma varias veces, es posible que debas reconsiderar la arquitectura de tu app. Tener varios consumidores simultáneos hace que el contrato entregado exactamente una vez sea muy difícil de garantizar, por lo que el aumenta nivel de complejidad y comportamiento sutil. Si tienes este problema, considera enviar esas inquietudes hacia arriba en tu árbol de IU. Es posible que necesites una entidad diferente con un alcance más alto en la jerarquía.
  • Piensa en cuándo se debe consumir el estado. En ciertas situaciones, es posible que no quieras seguir consumiendo el estado cuando la app está en segundo plano, por ejemplo, se muestra un Toast. En esos casos, considera consumir el estado cuando la IU está en primer plano.

Ejemplos

En los siguientes ejemplos de Google, se demuestran los eventos de la IU en la capa de la IU. Explóralos para ver esta guía en práctica:

Recursos adicionales

Para obtener más información sobre los eventos de la IU, consulta los siguientes recursos adicionales:

Codelabs

Documentación

Ver contenido