Listas con LazyColumn
Al terminar: Mostrar listas largas con LazyColumn, claves estables y vistas previas con datos de ejemplo.
Al terminar esta lección, Marcador tendrá una lista de 18 partidos de ejemplo y una pantalla de tablas (la tabla de posiciones y los goleadores, en una sola lista que se desplaza), que verás en la vista previa de Android Studio sin ejecutar nada. En la lección siguiente, Pestañas con NavigationBar, las meterás en la app con una barra de pestañas y provocarás a propósito el cierre inesperado (la app se cae) que da usar un Jugador como clave de fila.
Importa porque casi toda pantalla real es una lista, y una lista mal hecha se nota de dos formas: va lenta, o pierde el estado de las filas cuando los datos cambian. Temas: LazyColumn y claves (key) de fila; y, en un recuadro para conocerla, la librería Paging 3.
Column compone todo; LazyColumn, solo lo que se ve
Una Column compone (ejecuta) y mide todos sus hijos, se vean o no. Con 18 partidos da igual; con 10.000, la pantalla tarda en abrirse. LazyColumn («columna perezosa») compone solo las filas que caben en pantalla más un margen.
El contenido de una LazyColumn no es un bloque de composables normal, sino uno especial que declara elementos: item { … } para uno solo, items(lista) { … } para uno por cada elemento de la lista, e itemsIndexed(lista) { indice, elemento -> … } si además necesitas la posición. Dentro de las llaves de LazyColumn { … } solo puedes llamar a item, items e itemsIndexed (no puedes poner un Text suelto); los composables van dentro de las llaves de cada uno.
Las claves (key): que el estado siga al dato
Por defecto Compose identifica cada fila por su posición. Si se inserta un partido arriba, el que estaba en la posición 0 pasa a la 1, y lo que cada fila recordaba (con remember, por ejemplo) se queda con la posición y no con el dato. Una clave (key) es un valor que identifica a cada fila por su dato: con ella, lo recordado sigue al dato aunque cambie de sitio.
¿Y por qué tiene que caber en un Bundle? Recuerda el sobre que el director guarda en Estado en Compose: el Bundle es ese sobre, donde Android guarda copias pequeñas de lo que tu pantalla quiere conservar al rotar. Compose etiqueta cada copia con la clave de la fila (así sabe de qué fila es) y por eso la clave tiene que ser un tipo que el Bundle sepa guardar. Se exige siempre, uses o no rememberSaveable en esas filas.
Una buena clave cumple tres cosas:
| Regla | Ejemplo bueno | Ejemplo malo |
|---|---|---|
| Única en la lista | El id de cada partido | El nombre del equipo local (se repite) |
| Estable: mismo dato, misma clave | "jugador-${jugador.nombre}" | El índice de la posición |
Tipo que cabe en un Bundle | Int, Long, String, enum | Una data class tuya |
val equipos = listOf("Rayo FC", "Toros", "Lobos")
LazyColumn { // compone solo las filas que se ven
// key: el estado de cada fila sigue al equipo, aunque cambie de sitio.
items(equipos, key = { nombre -> nombre }) { nombre -> // una fila por elemento; la clave es un String
Text(nombre) // lo que se dibuja en cada fila
}
}contentType es opcional: dice qué filas son «del mismo tipo» (cabeceras frente a filas de datos) para que Compose reutilice mejor su composición cuando la lista las mezcla. Su lambda recibe lo mismo que la de key: en items basta una lambda con el elemento, como { "fila" }; en itemsIndexed recibe dos cosas, la posición y el elemento, y como aquí no hace falta ninguna se escribe { _, _ -> "fila" } (cada _ es «este valor no me hace falta»).
Error típico: un Jugador como clave
Usar el propio objeto como clave parece razonable (es único y estable), y compila:
items(goleadores, key = { (jugador, _) -> jugador }) { (jugador, goles) ->
Text("${jugador.nombre}: $goles goles")
}Fíjate en { (jugador, _) -> jugador }: cada elemento de goleadores es un par (Pair: un jugador y sus goles), y los paréntesis desestructuran el par en dos nombres, jugador y _. El guion bajo significa «este valor no me hace falta». (data class e igualdad explica la desestructuración con data class; Pair funciona igual).
Falla en ejecución, al entrar en la pantalla:
E ComposeInternal: java.lang.IllegalArgumentException: Type of the key Jugador(nombre=Nico, dorsal=11) is not supported. On Android you can only use types which can be stored inside the Bundle.
E AndroidRuntime: FATAL EXCEPTION: mainitems(goleadores, key = { (jugador, _) -> "jugador-${jugador.nombre}" }) { (jugador, goles) ->
Text("${jugador.nombre}: $goles goles")
}Compose necesita poder guardar la clave en un Bundle, y un Jugador (una clase de :torneo) no es un tipo que el Bundle admita. Con el nombre, un String, sí. Esto solo vale mientras dos jugadores no compartan nombre: si pasara, la clave dejaría de ser única y habría que usar un identificador propio.
Una lista con cabecera y posiciones: el ejemplo, en una biblioteca inventada
Antes del paso en Marcador, las piezas que usará en un ejemplo con otros datos (una biblioteca; los nombres no son los de Marcador). item añade un elemento suelto, itemsIndexed también te da la posición de cada uno, y items con un par desestructurado ya lo viste arriba:
@Composable
fun ListaDeLibros(libros: List<String>, prestamos: List<Pair<String, Int>>, modifier: Modifier = Modifier) {
LazyColumn(
modifier,
contentPadding = PaddingValues(16.dp), // margen dentro de la zona que se desplaza
verticalArrangement = Arrangement.spacedBy(4.dp), // 4 dp entre filas
) {
item(key = "cabecera-libros", contentType = "cabecera") { // UN elemento suelto, con su clave
Text("Libros", style = MaterialTheme.typography.titleLarge)
}
itemsIndexed(libros, key = { _, titulo -> "libro-$titulo" }, contentType = { _, _ -> "libro" }) { indice, titulo -> // uno por libro, con su posición y su tipo
Row(Modifier.fillMaxWidth()) {
Text("${indice + 1}.", Modifier.weight(1f)) // weight(1f): se queda el espacio sobrante
Text(titulo, textAlign = TextAlign.End) // texto alineado a la derecha
}
}
items(prestamos, key = { (lector, _) -> "lector-$lector" }, contentType = { "lector" }) { (lector, veces) -> // desestructura el Pair
Text("$lector: $veces préstamos")
}
}
}Lo que se ve en pantalla (llamándola con libros = listOf("Rayuela", "Ficciones") y prestamos = listOf("Ana" to 3)): arriba, el título «Libros» en grande; debajo, dos filas, 1. a la izquierda y Rayuela pegado a la derecha, y 2. con Ficciones; y al final, «Ana: 3 préstamos». El número se queda a la izquierda y el título a la derecha porque weight(1f) le da al número todo el sitio que sobra.
Reto · Ejercicio: la lista de la compra
En tu proyecto, en la carpeta ejercicios (la que creaste en La rotación y la muerte del proceso): clic derecho sobre com.sebastiannarvaez.marcador → New → Package → escribe .ejercicios.lista_de_la_compra. Luego clic derecho sobre esa carpeta nueva → New → Kotlin Class/File → File → ListaDeLaCompra. Los ejercicios no se borran ni tocan la app.
Escribe en ese fichero:
- un dato
Productocon un identificador numérico, un nombre y unas unidades; - un composable
ListaDeLaCompraque recibe dos listas de productos, los que faltan por comprar y los que ya están en la cesta, y un modificador opcional. Dibuja una sola lista perezosa, con 16 dp de margen dentro de la zona que se desplaza y 4 dp entre filas. Primero la cabecera «Por comprar», en grande. Después una fila por producto con su posición y su nombre a la izquierda («1. Pan») y sus unidades a la derecha («x2»). Luego la cabecera «En la cesta» y una línea por producto con su nombre y «(hecho)».
Cada fila de producto se identifica por su número, y cada cabecera por un texto propio. Marca las cabeceras y las filas con tipos de contenido distintos. Añade una vista previa con Pan (2), Leche (6) y Huevos (12) por comprar, y Tomates (4) y Arroz (1) en la cesta, con los números del 1 al 5.
Debes ver, en la vista previa (modo Split): «Por comprar» en grande; «1. Pan» con «x2» pegado a la derecha, «2. Leche … x6», «3. Huevos … x12»; «En la cesta» en grande; y «Tomates (hecho)», «Arroz (hecho)». Si te atascas, el ejemplo de la biblioteca de arriba usa casi todas las piezas.
Ver solución
package com.sebastiannarvaez.marcador.ejercicios.lista_de_la_compra
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
data class Producto(val id: Int, val nombre: String, val unidades: Int)
@Composable
fun ListaDeLaCompra(
porComprar: List<Producto>,
enLaCesta: List<Producto>,
modifier: Modifier = Modifier,
) {
LazyColumn(
modifier,
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
item(key = "cabecera-por-comprar", contentType = "cabecera") {
Text("Por comprar", style = MaterialTheme.typography.titleLarge)
}
itemsIndexed(porComprar, key = { _, producto -> producto.id }, contentType = { _, _ -> "fila" }) { indice, producto ->
Row(Modifier.fillMaxWidth()) {
Text("${indice + 1}. ${producto.nombre}", Modifier.weight(1f))
Text("x${producto.unidades}", textAlign = TextAlign.End)
}
}
item(key = "cabecera-en-la-cesta", contentType = "cabecera") {
Text("En la cesta", style = MaterialTheme.typography.titleLarge)
}
items(enLaCesta, key = { producto -> producto.id }, contentType = { "fila" }) { producto ->
Text("${producto.nombre} (hecho)")
}
}
}
@Preview(showBackground = true)
@Composable
private fun ListaDeLaCompraPreview() {
MaterialTheme {
ListaDeLaCompra(
porComprar = listOf(Producto(1, "Pan", 2), Producto(2, "Leche", 6), Producto(3, "Huevos", 12)),
enLaCesta = listOf(Producto(4, "Tomates", 4), Producto(5, "Arroz", 1)),
)
}
}La clave de cada producto es su id, un Int, y no su nombre: dos productos pueden llamarse igual y el nombre dejaría de ser único, mientras que el id no se repite. Además, un Int cabe en un Bundle y un Producto no, como viste con Jugador.
Paso · los datos de ejemplo y la pantalla de listas
Objetivo. Crear los datos de ejemplo de Marcador (18 partidos) y Listas.kt, con la lista de partidos y la pantalla de tablas. Al terminar ves, en la vista previa de Listas.kt, la tabla de posiciones con Rayo FC primero y, debajo, los goleadores.
Crear los ficheros. En la vista Android, despliega app → kotlin+java → com.sebastiannarvaez.marcador, y crea los dos paquetes de la nueva parte de la app, la lista de partidos: clic derecho sobre com.sebastiannarvaez.marcador → New → Package; el campo ya trae com.sebastiannarvaez.marcador, así que escribe .feature.partidos.data al final (con el punto delante); otra vez New → Package → .feature.partidos.ui. Android Studio puede juntar en una sola línea las carpetas que solo tienen una hija: si ves feature.marcador.ui o partidos.data en una línea, es la misma carpeta, y cuando una carpeta gana una segunda hija se separa (ahora feature tiene dos, marcador y partidos, y las verás como filas distintas). Después, clic derecho sobre data (dentro de partidos) → New → Kotlin Class/File → File → DatosDeEjemplo, y sobre ui (el de partidos, no el de marcador) el mismo camino con Listas. Cada fichero se abre con solo la línea package.
DatosDeEjemplo.kt se copia, no se escribe. Construye un Torneo de :torneo con las cuatro plantillas de ejemplo y tres vueltas de todos contra todos (18 partidos), y no enseña nada de Compose: por eso es el botón y no tus manos. Con DatosDeEjemplo.kt abierto: pulsa Copiar fichero, haz clic dentro del editor, selecciona todo con Cmd+A y pega con Cmd+V. El contenido pegado trae su propia línea package.
Te deja tres cosas:
| Nombre | Qué es |
|---|---|
DatosDeEjemplo.torneo | El Torneo con los 18 partidos ya registrados |
DatosDeEjemplo.partidos | La lista de PartidoDeLista(id, partido) |
PartidoDeLista | Un envoltorio con id (1 a 18): Partido no tiene identificador, y la clave necesita uno |
Piezas nuevas de este paso:
LazyColumn(modifier, contentPadding = PaddingValues(16.dp), verticalArrangement = …):contentPaddinges el margen dentro de la zona que se desplaza (las filas nunca quedan pegadas al borde).items(lista, key = { … }) { elemento -> … },item(key = …, contentType = …) { … }eitemsIndexed(lista, key = { indice, e -> … }) { indice, e -> … }, como en el ejemplo de la biblioteca.{ (jugador, goles) -> … }: desestructura unPairen dos nombres, como se explicó antes junto al primer uso.Modifier.weight(1f): dentro de unaRow, reparte el espacio sobrante entre los hijos con peso.textAlign = TextAlign.Endalinea el texto a la derecha.item.partido.marcador().toString()es el texto del resultado:Marcador(de:torneo) tiene su propiotoString, que escribe2-1(goles del local, guion, goles del visitante).torneo.goleadores()devuelve unRanking<Jugador>(Comparadores, Iterable y el ranking):ranking.ordenadosson los jugadores de más a menos goles yranking.puntosDe(jugador)sus goles.torneo.tablaDePosiciones()devuelve la lista deFilaDePosicion. La pantalla necesita pares (jugador, goles): el intento los construye conranking.ordenados.map { it to ranking.puntosDe(it) }.- Ya las conoces:
Pair(Colecciones;jugador to goles, aquí un jugador y sus goles) yJugador.dorsalOGuion(), una función de:torneoque escribiste en Clases y objetos y que devuelve el dorsal con almohadilla,#11, o-si el jugador no tiene dorsal.
Intenta escribirlo tú (sin el fichero de datos, que ya tienes), en este orden:
FilaDePartido, una tarjeta con el nombre del local a la izquierda, el resultado en negrita en el centro y el visitante a la derecha; yPantallaPartidos, una lista perezosa con una de esas filas por partido, identificada por el id del partido.PantallaTablas, una sola lista perezosa con cuatro partes: la cabecera «Tabla de posiciones», una fila por equipo con su puesto, su nombre, y a la derecha partidos jugados, diferencia de goles y puntos («9 PJ 4 DG 15 pts»); la cabecera «Goleadores», y una fila por jugador con su nombre y dorsal y sus goles («2 goles»). Cada fila se identifica por el nombre del equipo o del jugador. Los pares de jugador y goles los dagoleadoresConGoles, una función de extensión sobreTorneo(una función que se escribe fuera de la clase pero se llama como si fuera suya, como la de Extensiones y alcance) que escribes tú al final deListas.kt, a partir de su ranking de goleadores.- Una vista previa de
PantallaTablascon los datos de ejemplo, envuelta enMaterialTheme.
Debes ver, en la vista previa, la clasificación con Rayo FC primero y los goleadores debajo.
Intenta primero. Cuando termines, compara con la solución.Mostrar el código
package com.sebastiannarvaez.marcador.feature.partidos.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material3.Card
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.sebastiannarvaez.marcador.feature.partidos.data.DatosDeEjemplo
import com.sebastiannarvaez.marcador.feature.partidos.data.PartidoDeLista
import com.sebastiannarvaez.marcador.torneo.FilaDePosicion
import com.sebastiannarvaez.marcador.torneo.Jugador
import com.sebastiannarvaez.marcador.torneo.Torneo
import com.sebastiannarvaez.marcador.torneo.marcador
@Composable
fun PantallaPartidos(partidos: List<PartidoDeLista>, modifier: Modifier = Modifier) {
LazyColumn(
modifier,
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
items(partidos, key = { it.id }) { item ->
FilaDePartido(item.partido.local.nombre, item.partido.visitante.nombre, item.partido.marcador().toString())
}
}
}
@Composable
fun FilaDePartido(local: String, visitante: String, resultado: String, modifier: Modifier = Modifier) {
Card(modifier.fillMaxWidth()) {
Row(Modifier.padding(16.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
Text(local, Modifier.weight(1f))
Text(resultado, fontWeight = FontWeight.Bold)
Text(visitante, Modifier.weight(1f), textAlign = TextAlign.End)
}
}
}
// Dos listas en una sola LazyColumn: cabecera (`item`), filas (`items`), otra cabecera
// y otras filas. Una sola lista que se desplaza, en vez de dos LazyColumn anidadas
// (una LazyColumn dentro de otra que se desplaza en la misma direccion no es un patron valido).
@Composable
fun PantallaTablas(
tabla: List<FilaDePosicion>,
goleadores: List<Pair<Jugador, Int>>,
modifier: Modifier = Modifier,
) {
LazyColumn(
modifier,
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
item(key = "cabecera-tabla", contentType = "cabecera") {
Text("Tabla de posiciones", style = MaterialTheme.typography.titleLarge)
}
// itemsIndexed da tambien la posicion: el puesto en la tabla.
itemsIndexed(tabla, key = { _, fila -> "equipo-${fila.equipo.nombre}" }, contentType = { _, _ -> "fila" }) { indice, fila ->
Row(Modifier.fillMaxWidth().padding(vertical = 4.dp), horizontalArrangement = Arrangement.SpaceBetween) {
Text("${indice + 1}. ${fila.equipo.nombre}", Modifier.weight(1f))
Text("${fila.jugados} PJ ${fila.diferencia} DG ${fila.puntos} pts")
}
}
item(key = "cabecera-goleadores", contentType = "cabecera") {
Text("Goleadores", style = MaterialTheme.typography.titleLarge, modifier = Modifier.padding(top = 16.dp))
}
// La key es el NOMBRE (String), no el Jugador: un String siempre cabe en un Bundle.
items(goleadores, key = { (jugador, _) -> "jugador-${jugador.nombre}" }, contentType = { "fila" }) { (jugador, goles) ->
Row(Modifier.fillMaxWidth().padding(vertical = 4.dp), horizontalArrangement = Arrangement.SpaceBetween) {
Text("${jugador.nombre} ${jugador.dorsalOGuion()}", Modifier.weight(1f))
Text("$goles goles")
}
}
}
}
@Preview(showBackground = true)
@Composable
private fun PantallaTablasPreview() {
val torneo = DatosDeEjemplo.torneo
MaterialTheme { PantallaTablas(torneo.tablaDePosiciones(), torneo.goleadoresConGoles()) }
}
// El Ranking de :torneo sabe cuantos goles lleva cada uno (`puntosDe`); la pantalla
// necesita el par (jugador, goles) ya calculado.
fun Torneo.goleadoresConGoles(): List<Pair<Jugador, Int>> =
goleadores().let { ranking -> ranking.ordenados.map { it to ranking.puntosDe(it) } }Fíjate en tres decisiones del código:
| Decisión | Por qué |
|---|---|
Cabeceras y filas de las dos listas en una LazyColumn | Una lista que se desplaza dentro de otra que se desplaza en la misma dirección no es un patrón válido; una sola con varios bloques sí |
Las claves de PantallaTablas llevan un prefijo ("equipo-", "jugador-") | Así un equipo y un jugador que se llamaran igual no chocarían: las claves son únicas en toda la lista |
goleadoresConGoles() calcula el par jugador-goles fuera de la pantalla | La pantalla solo recibe datos ya calculados |
Mira la vista previa. Abre Listas.kt y pulsa Split (arriba a la derecha del editor, junto a Code y Design). Si la vista previa pide compilar, pulsa Build & Refresh y espera unos segundos: debes ver PantallaTablasPreview con la clasificación y los goleadores.
Punto de control
- El proyecto compila sin nada en rojo (Build → Make Project, o el martillo de la barra superior).
- En modo Split, la vista previa de
Listas.ktempieza por1. Rayo FCcon15 ptsy9 PJ, tiene 4 equipos, y termina con los goleadores, conNico #11y11 golesarriba. - La app todavía no muestra estas pantallas: las enchufarás a una barra de pestañas en la lección siguiente.
La siguiente lección, Pestañas con NavigationBar, junta estas listas con la pantalla del marcador en una app de tres pestañas.
Comprueba lo que entendiste
6 preguntas · necesitas 5 para completar la lección
1.¿Qué diferencia hay entre una
Columny unaLazyColumncon muchas filas?2.Una lista no lleva
key. Se inserta un partido arriba. ¿Qué pasa con lo que cada fila recordaba?3.Usas
key = { (jugador, _) -> jugador }con unJugadorde:torneo. ¿Qué ocurre al entrar en la pantalla?4.¿Cuál de estas es una buena clave para las filas de la tabla de goleadores?
5.¿Para qué usarías Paging 3 en una app?
6.Dentro de las llaves de
LazyColumn { … }, ¿qué puedes escribir directamente?