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 objetos de escucha onClick().

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

Vistas

class LatestNewsActivity : AppCompatActivity() {

    private lateinit var binding: ActivityLatestNewsBinding
    private val viewModel: LatestNewsViewModel by viewModels()

    override fun onCreate(savedInstanceState: Bundle?) {
        /* ... */

        // The expand details event is processed by the UI that
        // modifies a View's internal state.
        binding.expandButton.setOnClickListener {
            binding.expandedSection.visibility = View.VISIBLE
        }

        // The refresh event is processed by the ViewModel that is in charge
        // of the business logic.
        binding.refreshButton.setOnClickListener {
            viewModel.refreshNews()
        }
    }
}

Compose

@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 RecyclerViews

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

Por ejemplo, supongamos que todos los elementos de noticias de NewsActivity contienen un botón de favoritos. ViewModel necesita conocer el ID del artículo destacado de noticias. Cuando un usuario agrega un elemento de noticias a favoritos, el adaptador RecyclerView no llama a la función addBookmark(newsId) expuesta desde ViewModel, que requiere una dependencia de ViewModel. En cambio, ViewModel expone un objeto de estado llamado NewsItemUiState que contiene la implementación para controlar el evento:

data class NewsItemUiState(
    val title: String,
    val body: String,
    val bookmarked: Boolean = false,
    val publicationDate: String,
    val onBookmark: () -> Unit
)

class LatestNewsViewModel(
    private val formatDateUseCase: FormatDateUseCase,
    private val repository: NewsRepository
)
    val newsListUiItems = repository.latestNews.map { news ->
        NewsItemUiState(
            title = news.title,
            body = news.body,
            bookmarked = news.bookmarked,
            publicationDate = formatDateUseCase(news.publicationDate),
            // Business logic is passed as a lambda function that the
            // UI calls on click events.
            onBookmark = {
                repository.addBookmark(news.id)
            }
        )
    }
}

De esta manera, el adaptador RecyclerView solo funciona con los datos que necesita: la lista de objetos NewsItemUiState. El adaptador no tiene acceso a todo ViewModel, por lo que es menos probable que abuse de la funcionalidad que expone ViewModel. Cuando permites que solo la clase de actividad funcione con ViewModel, separas las responsabilidades. Esto garantiza que los objetos específicos de la IU, como las vistas o los adaptadores RecyclerView, no interactúen directamente con ViewModel.

Convenciones de asignación de nombres para las funciones de eventos de los usuarios

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 addBookmark(id) o logIn(username, password).

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 navegar a la pantalla principal cuando el usuario se conecta a la pantalla de acceso. Puedes generar un modelo de esto en el estado de la IU de la siguiente manera:

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

Esta IU reacciona a los cambios en el estado isUserLoggedIn y navega al destino correcto según sea necesario:

Objetos View

class LoginViewModel : ViewModel() {
    private val _uiState = MutableStateFlow(LoginUiState())
    val uiState: StateFlow<LoginUiState> = _uiState.asStateFlow()
    /* ... */
}

class LoginActivity : AppCompatActivity() {
    private val viewModel: LoginViewModel by viewModels()

    override fun onCreate(savedInstanceState: Bundle?) {
        /* ... */

        lifecycleScope.launch {
            repeatOnLifecycle(Lifecycle.State.STARTED) {
                viewModel.uiState.collect { uiState ->
                    if (uiState.isUserLoggedIn) {
                        // Navigate to the Home screen.
                    }
                    ...
                }
            }
        }
    }
}

Redactar

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

@Composable
fun LoginScreen(
    viewModel: LoginViewModel = viewModel(),
    onUserLogIn: () -> Unit
) {
    val currentOnUserLogIn by rememberUpdatedState(onUserLogIn)

    // Whenever the uiState changes, check if the user is logged in.
    LaunchedEffect(viewModel.uiState)  {
        if (viewModel.uiState.isUserLoggedIn) {
            currentOnUserLogIn()
        }
    }

    // Rest of the UI for the login screen.
}

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:

Vistas

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

    private val _uiState = MutableStateFlow(LatestNewsUiState(isLoading = true))
    val uiState: StateFlow<LatestNewsUiState> = _uiState

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

            // Do something else.
        }
    }

    fun userMessageShown() {
        _uiState.update { currentUiState ->
            currentUiState.copy(userMessage = null)
        }
    }
}

Redactar

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:

Vistas

class LatestNewsActivity : AppCompatActivity() {
    private val viewModel: LatestNewsViewModel by viewModels()

    override fun onCreate(savedInstanceState: Bundle?) {
        /* ... */

        lifecycleScope.launch {
            repeatOnLifecycle(Lifecycle.State.STARTED) {
                viewModel.uiState.collect { uiState ->
                    uiState.userMessage?.let {
                        // TODO: Show Snackbar with userMessage.

                        // Once the message is displayed and
                        // dismissed, notify the ViewModel.
                        viewModel.userMessageShown()
                    }
                    ...
                }
            }
        }
    }
}

Redactar

@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, llama al controlador de navegación o expone el evento al elemento componible llamador , según corresponda.

Vistas

class LoginActivity : AppCompatActivity() {

    private lateinit var binding: ActivityLoginBinding
    private val viewModel: LoginViewModel by viewModels()

    override fun onCreate(savedInstanceState: Bundle?) {
        /* ... */

        binding.helpButton.setOnClickListener {
            navController.navigate(...) // Open help screen
        }
    }
}

Redactar

@Composable
fun LoginScreen(
    onHelp: () -> Unit, // Caller navigates to the right screen
    viewModel: LoginViewModel = viewModel()
) {
    // Rest of the UI

    Button(onClick = onHelp) {
        Text("Get help")
    }
}

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. A continuación, se muestra un código similar:

Objetos View

class LoginActivity : AppCompatActivity() {
    private val viewModel: LoginViewModel by viewModels()

    override fun onCreate(savedInstanceState: Bundle?) {
        /* ... */

        lifecycleScope.launch {
            repeatOnLifecycle(Lifecycle.State.STARTED) {
                viewModel.uiState.collect { uiState ->
                    if (uiState.isUserLoggedIn) {
                        // Navigate to the Home screen.
                    }
                    ...
                }
            }
        }
    }
}

Compose

@Composable
fun LoginScreen(
    onUserLogIn: () -> Unit, // Caller navigates to the right screen
    viewModel: LoginViewModel = viewModel()
) {
    Button(
        onClick = {
            // ViewModel validation is triggered
            viewModel.login()
        }
    ) {
        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. A fin de implementar esto, es necesario tener un estado adicional que indique si la IU debe considerar o no 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, consideremos 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:

Vistas

// Key that identifies the `validationInProgress` state in the Bundle
private const val DOB_VALIDATION_KEY = "dobValidationKey"

class DobValidationFragment : Fragment() {

    private var validationInProgress: Boolean = false
    private val viewModel: DobValidationViewModel by viewModels()

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        val binding = // ...
        validationInProgress = savedInstanceState?.getBoolean(DOB_VALIDATION_KEY) ?: false

        binding.continueButton.setOnClickListener {
            viewModel.validateDob()
            validationInProgress = true
        }

        viewLifecycleOwner.lifecycleScope.launch {
            viewModel.uiState
                .flowWithLifecycle(viewLifecycleOwner.lifecycle)
                .collect { uiState ->
                    // Update other parts of the UI ...

                    // If the input is valid and the user wants
                    // to navigate, navigate to the next screen
                    // and reset `validationInProgress` flag
                    if (uiState.isDobValid && validationInProgress) {
                        validationInProgress = false
                        navController.navigate(...) // Navigate to next screen
                    }
                }
        }

        return binding
    }

    override fun onSaveInstanceState(outState: Bundle) {
        super.onSaveInstanceState(outState)
        outState.putBoolean(DOB_VALIDATION_KEY, validationInProgress)
    }
}

Compose

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 del código anterior implementa esta funcionalidad y controla si la IU debe navegar automáticamente o no cuando la fecha de nacimiento sea válida y el usuario desee continuar con el siguiente paso de registro.

Otros casos de uso

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 la tarea por la que es responsable, y 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: