Saltar al contenido
K
Jetpack y arquitecturaPaso a paso30 min de lectura · 8 min de máquina Intermedio

Modelar el UiState

Al terminar: Modelar la pantalla con un UiState de carga, éxito y error.

Al terminar esta lección, la pantalla del marcador sabrá dibujarse a partir de un solo objeto que dice en qué situación está: cargando, con datos o con error. Dibujarás las tres variantes en un ejercicio; en el proyecto, la vista previa de la pantalla del partido ya funcionará, y tendrás los dos tipos nuevos, MarcadorUiState y MarcadorEvento. En la lección siguiente, el ViewModel los produce.

Importa porque una pantalla armada a partir de seis flujos sueltos admite combinaciones que no existen («cargando» y «error» a la vez) y puede mostrar un minuto nuevo con un marcador viejo. Modelar el estado con un tipo cerrado hace que el compilador te obligue a cubrir cada caso. Temas: modelar el UiState con sealed frente a data class, y estado hacia abajo y eventos hacia arriba.

Un objeto que dice cuál es la situación

Hasta ahora la pantalla recogía seis StateFlow del ViewModel y montaba el estado ella misma. Eso tiene dos problemas: pueden llegar desincronizados, y no hay sitio donde decir «todavía no hay datos» o «algo falló». La solución es un UiState: un objeto con todo lo que la pantalla necesita para dibujarse, y que solo pueda ser lo que puede ser.

Hay dos formas de modelarlo:

sealed interfacedata class única
Las situaciones son…excluyentes: una u otraindependientes: varias a la vez
Ejemplocargando, o con datos, o con error«recargando con los datos viejos a la vista»
El compiladorobliga a cubrir cada varianteno obliga a nada

Marcador cambia la pantalla entera según el caso, así que usa sealed. La data class se verá en acción más adelante, en la pantalla de árbitros de Dos fuentes: la pantalla y las pruebas: ahí la lista guardada, el «refrescando» y el error conviven.

ejemplo mínimo: un UiState sellado (con comentarios)
sealed interface DetalleUiState {                     // sealed: estas tres variantes y ninguna más
    data object Cargando : DetalleUiState             // object: no lleva datos, basta una instancia
    data class Exito(val titulo: String) : DetalleUiState   // solo Exito tiene título
    data class Error(val mensaje: String) : DetalleUiState  // solo Error tiene mensaje
}
 
fun pintar(estado: DetalleUiState): String = when (estado) {
    DetalleUiState.Cargando -> "spinner"              // un object se compara sin is
    is DetalleUiState.Exito -> estado.titulo          // is: tras comprobarlo, Kotlin deja leer .titulo
    is DetalleUiState.Error -> estado.mensaje
}   // sin `else`: si añades una variante, esto deja de compilar

Error típico: un UiState con banderas

con el bug: combinaciones imposibles
data class MarcadorUiState(
    val cargando: Boolean = true,
    val error: String? = null,
    val partido: Partido? = null,
)
corregido: solo las combinaciones que existen
sealed interface MarcadorUiState {
    data object Cargando : MarcadorUiState
    data class Exito(val partido: Partido) : MarcadorUiState
    data class Error(val mensaje: String) : MarcadorUiState
}

La primera admite cargando = true con un error a la vez, o cargando = false sin partido ni error, y obliga a comprobar el null en cada uso. Nada de eso falla al compilar: falla en pantalla, con un spinner y un error superpuestos. En la segunda, Exito siempre tiene partido.

Dibujar cada variante

Un composable que recibe un UiState hace un when sobre él y dibuja una cosa distinta en cada variante. Dos piezas de Material 3 que usarás:

  • CircularProgressIndicator(): el indicador de carga circular, un círculo que gira. Se dibuja en la variante «cargando».
  • color = MaterialTheme.colorScheme.error: un parámetro de Text que pinta el texto con el color de error del tema (rojo). Se usa así: Text("Sin conexión", color = MaterialTheme.colorScheme.error).
  • Un Button en la variante de error: recibe una lambda (onReintentar) que el composable no ejecuta por su cuenta, solo avisa. Así sigue sin saber quién lo arregla.

Hay una pieza más: los eventos, lo que la pantalla avisa hacia arriba, también se modelan con un tipo cerrado, una variante por cosa que el usuario puede hacer:

ejemplo mínimo: eventos como tipo sellado (con comentarios)
sealed interface DetalleEvento {                      // lo que la pantalla puede pedir
    data object Reintentar : DetalleEvento            // sin datos: basta saber que ocurrió
    data class Abrir(val id: Int) : DetalleEvento     // con datos: qué elemento abrió
}
 
// El composable no decide nada: recibe una función y la llama con el evento.
// onEvento(DetalleEvento.Abrir(3))   // «el usuario tocó el elemento 3»

Así hay un solo tipo de aviso y un solo sitio donde se tratan, en lugar de una lambda suelta por cada botón. En el paso de abajo lo usarás con MarcadorEvento.

Y una ventaja: como el composable solo recibe datos, puedes escribir una vista previa por variante, cada una con unos datos inventados, sin ViewModel y sin esperar a que nada cargue. Las vistas previas se abren y se refrescan como en Compose: composables y modifiers.

Reto · Ejercicio: el tiempo de una ciudad

En la carpeta ejercicios de tu proyecto (la que creaste en La rotación y la muerte del proceso), crea la subcarpeta el_tiempo: clic derecho sobre com.sebastiannarvaez.marcador → New → Package → escribe .ejercicios.el_tiempo. Después, clic derecho sobre esa carpeta → New → Kotlin Class/File → File → ElTiempo. La primera línea debe ser package com.sebastiannarvaez.marcador.ejercicios.el_tiempo.

Modela la pantalla del tiempo con un tipo cerrado de tres variantes:

  • cargando;
  • con datos, que lleva la ciudad y los grados;
  • error, que lleva un mensaje.

Escribe un composable sin estado que reciba ese estado y una función para reintentar. Según la variante, dibuja:

  • el indicador de carga circular;
  • el texto «Cali: 27 °C» (la ciudad y los grados que lleve);
  • o el mensaje, en el color de error del tema (el color de arriba), y debajo un botón «Reintentar» que llama a la función.

Añade tres vistas previas, una por variante: cargando; Cali con 27 grados; y el error «Sin conexión».

Lo que debes ver en Split: tres recuadros: un círculo de carga que gira; «Cali: 27 °C»; y «Sin conexión» en rojo, con «Reintentar» debajo.

Para pensar: añade una cuarta variante, «sin permiso de ubicación». ¿Qué marca Android Studio, y dónde?

Ver solución
app/src/main/java/com/sebastiannarvaez/marcador/ejercicios/el_tiempo/ElTiempo.kt
package com.sebastiannarvaez.marcador.ejercicios.el_tiempo
 
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
 
// Lo que la pantalla puede mostrar: una de tres cosas, nunca dos a la vez.
sealed interface TiempoUiState {
    data object Cargando : TiempoUiState
    data class Exito(val ciudad: String, val grados: Int) : TiempoUiState
    data class Error(val mensaje: String) : TiempoUiState
}
 
// Sin estado: recibe el estado y avisa con una lambda.
@Composable
fun PantallaTiempo(estado: TiempoUiState, onReintentar: () -> Unit, modifier: Modifier = Modifier) {
    Column(modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
        when (estado) {                                       // sin else: si añades una variante, no compila
            TiempoUiState.Cargando -> CircularProgressIndicator()
            is TiempoUiState.Exito -> Text("${estado.ciudad}: ${estado.grados} °C")
            is TiempoUiState.Error -> {
                Text(estado.mensaje, color = MaterialTheme.colorScheme.error)
                Button(onClick = onReintentar) { Text("Reintentar") }
            }
        }
    }
}
 
@Preview(showBackground = true)
@Composable
private fun TiempoCargandoPreview() {
    MaterialTheme { PantallaTiempo(TiempoUiState.Cargando, onReintentar = {}) }
}
 
@Preview(showBackground = true)
@Composable
private fun TiempoExitoPreview() {
    MaterialTheme { PantallaTiempo(TiempoUiState.Exito("Cali", 27), onReintentar = {}) }
}
 
@Preview(showBackground = true)
@Composable
private fun TiempoErrorPreview() {
    MaterialTheme { PantallaTiempo(TiempoUiState.Error("Sin conexión"), onReintentar = {}) }
}

Con una cuarta variante, el when del composable deja de compilar y Android Studio lo marca en rojo en esa línea, porque no hay else y falta un caso. Hasta que lo trates, el proyecto no compila: eso es lo que te da el tipo cerrado.

Con una sola subcarpeta dentro de ejercicios, la vista Android la muestra en una fila, ejercicios.el_tiempo; con dos o más, se separan en ejercicios con sus hijas.

Paso · MarcadorUiState y la pantalla que lo pinta

Objetivo. Crear MarcadorUiState y MarcadorEvento, y partir la pantalla en dos: PantallaMarcador, que se queda como está, y una función nueva, PantallaMarcadorContenido, que dibuja un MarcadorUiState sin saber de ViewModels. Al terminar la app se comporta como antes, y la vista previa de PantallaMarcador.kt ya dibuja el partido sin errores.

app → kotlin+java → com.sebastiannarvaez.marcador → feature → marcador → ui
MarcadorUiState.ktse crea
PantallaMarcador.ktse modifica
Un fichero nuevo y otro que ya tienes, en la vista Android.

Nada que instalar. Los tipos sellados son de Kotlin y CircularProgressIndicator es de Material 3 (material3, que viene en el bundle de Compose: un grupo de librerías que el catálogo declara junto y que se añade con una sola línea, como viste en Gradle: build types y BuildConfig). Tampoco hay Sync Now.

Cómo crear MarcadorUiState.kt. Clic derecho sobre ui, dentro de feature → marcador (en app → kotlin+java → com.sebastiannarvaez.marcador) → New → Kotlin Class/File → File → MarcadorUiState (aquí no hay una sola clase, sino dos interfaces). Va junto a la pantalla porque describe lo que esa pantalla muestra.

Rutas de los bloques. El título de cada bloque de abajo es la ruta del fichero en tu proyecto (app/src/main/java/…/feature/marcador/ui/…); en la vista Android lo encuentras en app → kotlin+java, en el paquete com.sebastiannarvaez.marcador.feature.marcador.ui.

Dónde va cada trozo (obligatorio; el código está debajo):

  • MarcadorUiState.kt: contiene MarcadorUiState y MarcadorEvento.
  • PantallaMarcador.kt: añade PantallaMarcadorContenido justo debajo de PantallaMarcador, sin tocar todavía esa. Cambia el cuerpo de PantallaMarcadorPreview (la función con @Preview del final): ya no debe arrancar un ViewModel con su reloj solo para dibujar una vista previa, dibuja PantallaMarcadorContenido con datos de ejemplo. Solo faltan dos import (los demás que usa la función, como Box, Column, fillMaxSize, Alignment, MaterialTheme y Marcador, ya los tiene el fichero): androidx.compose.material3.CircularProgressIndicator y com.sebastiannarvaez.marcador.torneo.Ejemplo. Si algún nombre sigue en rojo, Option+Enter sobre él propone su import. El bloque trae también el botón «Provocar error (partido 99)»: por ahora solo se ve en la vista previa y no hace nada, porque la pantalla que usa la app todavía no es esta; en la lección siguiente te llevará a la pantalla de error. Escribe el texto de la pantalla tal cual aparece en el bloque, para que la lección siguiente y la tuya coincidan.

Intenta escribirlo tú.

  1. En su fichero, MarcadorUiState, con tres estados posibles: cargando; éxito, con el id del partido, el partido, el marcador, el minuto, si corre y el último aviso; y error, con un mensaje. Y MarcadorEvento, con lo que la pantalla puede avisar: empezar, un gol de un lado y elegir un partido por su id.
  2. Una pantalla sin estado, PantallaMarcadorContenido, que recibe el estado y una función para los eventos y, según el estado, dibuja un indicador de carga, la pantalla del partido que ya tienes o el mensaje de error con un botón para volver al partido 1.
Intenta primero. Cuando termines, compara con la solución.Mostrar el código

El fichero nuevo (estado y eventos). El estado baja (MarcadorUiState) y los eventos suben (MarcadorEvento): la pantalla avisa, el ViewModel decide.

app/src/main/java/com/sebastiannarvaez/marcador/feature/marcador/ui/MarcadorUiState.kt
package com.sebastiannarvaez.marcador.feature.marcador.ui
 
import com.sebastiannarvaez.marcador.torneo.Marcador
import com.sebastiannarvaez.marcador.torneo.Partido
 
sealed interface MarcadorUiState {
    data object Cargando : MarcadorUiState
 
    data class Exito(
        val partidoId: Int,
        val partido: Partido,
        val marcador: Marcador,
        val minuto: Int,
        val corriendo: Boolean,
        val ultimoAviso: String,
    ) : MarcadorUiState
 
    data class Error(val mensaje: String) : MarcadorUiState
}
 
sealed interface MarcadorEvento {
    data object Empezar : MarcadorEvento
    data class Gol(val lado: Lado) : MarcadorEvento
    data class Elegir(val partidoId: Int) : MarcadorEvento
}

PantallaMarcador.kt. La pantalla sin estado, debajo de PantallaMarcador:

app/src/main/java/com/sebastiannarvaez/marcador/feature/marcador/ui/PantallaMarcador.kt
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PantallaMarcadorContenido(
    estado: MarcadorUiState,
    pausas: Int,
    onEvento: (MarcadorEvento) -> Unit,
    onVerDemos: () -> Unit,
    modifier: Modifier = Modifier,
) {
    Scaffold(
        modifier = modifier,
        contentWindowInsets = WindowInsets(0),
        topBar = { CenterAlignedTopAppBar(title = { Text("Marcador") }) },
        bottomBar = {
            Row(Modifier.fillMaxWidth().padding(16.dp), horizontalArrangement = Arrangement.Center) {
                Button(onClick = onVerDemos) { Text("Pruebas de Android") }
            }
        },
    ) { paddingValues ->
        when (estado) {
            MarcadorUiState.Cargando -> Box(Modifier.padding(paddingValues).fillMaxSize(), Alignment.Center) {
                CircularProgressIndicator()
            }
            is MarcadorUiState.Error -> Column(
                Modifier.padding(paddingValues).fillMaxSize().padding(24.dp),
                verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterVertically),
                horizontalAlignment = Alignment.CenterHorizontally,
            ) {
                Text(estado.mensaje, color = MaterialTheme.colorScheme.error)
                Button(onClick = { onEvento(MarcadorEvento.Elegir(1)) }) { Text("Volver al partido 1") }
            }
            is MarcadorUiState.Exito -> {
                TituloDeLaActividad(estado.marcador)
                MarcadorContent(
                    estado = EstadoMarcador(estado.partido, estado.marcador),
                    onGol = { lado -> onEvento(MarcadorEvento.Gol(lado)) },
                    modifier = Modifier.padding(paddingValues),
                    extras = {
                        Text("Partido n.º ${estado.partidoId} (elegido en la pestana Partidos)")
                        PanelDelCronometro(
                            estado.minuto, estado.corriendo, estado.ultimoAviso, pausas,
                            onEmpezar = { onEvento(MarcadorEvento.Empezar) },
                        )
                        Button(onClick = { onEvento(MarcadorEvento.Elegir(99)) }) { Text("Provocar error (partido 99)") }
                        ComparacionDeEstado()
                        OrdenDeLosModifiers()
                        InsigniaSobreEscudo()
                    },
                )
            }
        }
    }
}

Los dos import que faltan:

app/src/main/java/com/sebastiannarvaez/marcador/feature/marcador/ui/PantallaMarcador.kt
import androidx.compose.material3.CircularProgressIndicator
import com.sebastiannarvaez.marcador.torneo.Ejemplo

El cuerpo de PantallaMarcadorPreview:

app/src/main/java/com/sebastiannarvaez/marcador/feature/marcador/ui/PantallaMarcador.kt
    MaterialTheme {
        PantallaMarcadorContenido(
            MarcadorUiState.Exito(1, Ejemplo.rayoContraToros, Marcador(2, 1), 45, true, "minuto 30 con 1-1"),
            pausas = 0, onEvento = {}, onVerDemos = {},
        )
    }

Mira las tres variantes en la vista previa

Abre PantallaMarcador.kt en modo Split y refresca la vista previa. Ves la pantalla del partido dibujada desde un MarcadorUiState.Exito inventado: el partido 2 - 1, el minuto 45 y el aviso «minuto 30 con 1-1». Para ver las otras dos variantes, cambia en la vista previa el Exito(…) por MarcadorUiState.Cargando (aparece el círculo de carga) y por MarcadorUiState.Error("No existe el partido") (el mensaje en rojo con el botón), y devuélvela a Exito al terminar.

Punto de control

  • La app compila (Build → Make Project) y se comporta como antes: PantallaMarcador sigue funcionando con el ViewModel de la lección anterior.
  • MarcadorUiState.kt existe en feature.marcador.ui con MarcadorUiState y MarcadorEvento.
  • En Split, la vista previa de PantallaMarcador.kt ya no da «Render Problem» y pinta el partido en 2 - 1, minuto 45, con «minuto 30 con 1-1».
  • PantallaMarcador.kt tiene dos funciones: PantallaMarcador y PantallaMarcadorContenido.

Comprueba lo que entendiste

4 preguntas · necesitas 3 para completar la lección

  1. 1.Una pantalla es siempre una de estas tres cosas: cargando, con datos o con error. ¿Cómo la modelas?

  2. 2.¿Qué ventaja da el when exhaustivo sobre un UiState sellado?

  3. 3.¿Cuándo es mejor una data class única que un tipo sellado para el UiState?

  4. 4.En Marcador, ¿por qué PantallaMarcadorContenido se puede previsualizar con @Preview sin ViewModel?

Faltan 4 de 4