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 interface | data class única | |
|---|---|---|
| Las situaciones son… | excluyentes: una u otra | independientes: varias a la vez |
| Ejemplo | cargando, o con datos, o con error | «recargando con los datos viejos a la vista» |
| El compilador | obliga a cubrir cada variante | no 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.
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 compilarError típico: un UiState con banderas
data class MarcadorUiState(
val cargando: Boolean = true,
val error: String? = null,
val partido: Partido? = null,
)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 deTextque pinta el texto con el color de error del tema (rojo). Se usa así:Text("Sin conexión", color = MaterialTheme.colorScheme.error).- Un
Buttonen 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:
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
colorde 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
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.
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: contieneMarcadorUiStateyMarcadorEvento.PantallaMarcador.kt: añadePantallaMarcadorContenidojusto debajo dePantallaMarcador, sin tocar todavía esa. Cambia el cuerpo dePantallaMarcadorPreview(la función con@Previewdel final): ya no debe arrancar un ViewModel con su reloj solo para dibujar una vista previa, dibujaPantallaMarcadorContenidocon datos de ejemplo. Solo faltan dosimport(los demás que usa la función, comoBox,Column,fillMaxSize,Alignment,MaterialThemeyMarcador, ya los tiene el fichero):androidx.compose.material3.CircularProgressIndicatorycom.sebastiannarvaez.marcador.torneo.Ejemplo. Si algún nombre sigue en rojo, Option+Enter sobre él propone suimport. 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ú.
- 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. YMarcadorEvento, con lo que la pantalla puede avisar: empezar, un gol de un lado y elegir un partido por su id. - 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.
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:
@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:
import androidx.compose.material3.CircularProgressIndicator
import com.sebastiannarvaez.marcador.torneo.EjemploEl cuerpo de PantallaMarcadorPreview:
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:
PantallaMarcadorsigue funcionando con el ViewModel de la lección anterior. MarcadorUiState.ktexiste enfeature.marcador.uiconMarcadorUiStateyMarcadorEvento.- En Split, la vista previa de
PantallaMarcador.ktya no da «Render Problem» y pinta el partido en2 - 1, minuto 45, con «minuto 30 con 1-1». PantallaMarcador.kttiene dos funciones:PantallaMarcadoryPantallaMarcadorContenido.
Comprueba lo que entendiste
4 preguntas · necesitas 3 para completar la lección
1.Una pantalla es siempre una de estas tres cosas: cargando, con datos o con error. ¿Cómo la modelas?
2.¿Qué ventaja da el
whenexhaustivo sobre unUiStatesellado?3.¿Cuándo es mejor una
data classúnica que un tipo sellado para elUiState?4.En Marcador, ¿por qué
PantallaMarcadorContenidose puede previsualizar con@Previewsin ViewModel?