Eventos de la IU (Views)

Conceptos y la implementación de Jetpack Compose

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.

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

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

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:

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

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

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

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:

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

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.

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

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:

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

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:

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

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.