Saltar al contenido
K
Jetpack ComposePaso a paso35 min de lectura · 6 min de máquina Básico

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:

ReglaEjemplo buenoEjemplo malo
Única en la listaEl id de cada partidoEl 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 BundleInt, Long, String, enumUna data class tuya
ejemplo mínimo: una lista con claves (con comentarios)
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:

con el bug: una data class como key
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: main
corregido: un String como key
items(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:

ejemplo mínimo: LazyColumn con cabecera, posiciones y claves (con comentarios)
@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 Producto con un identificador numérico, un nombre y unas unidades;
  • un composable ListaDeLaCompra que 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
app/src/main/java/com/sebastiannarvaez/marcador/ejercicios/lista_de_la_compra/ListaDeLaCompra.kt
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.

app
kotlin+java → com.sebastiannarvaez.marcador
feature
partidos
data
DatosDeEjemplo.ktse crea
ui
Listas.ktse crea
Dos ficheros nuevos, en carpetas nuevas de la vista Android.

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.

DatosDeEjemplo.kt

Te deja tres cosas:

NombreQué es
DatosDeEjemplo.torneoEl Torneo con los 18 partidos ya registrados
DatosDeEjemplo.partidosLa lista de PartidoDeLista(id, partido)
PartidoDeListaUn 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 = …): contentPadding es el margen dentro de la zona que se desplaza (las filas nunca quedan pegadas al borde).
  • items(lista, key = { … }) { elemento -> … }, item(key = …, contentType = …) { … } e itemsIndexed(lista, key = { indice, e -> … }) { indice, e -> … }, como en el ejemplo de la biblioteca.
  • { (jugador, goles) -> … }: desestructura un Pair en dos nombres, como se explicó antes junto al primer uso.
  • Modifier.weight(1f): dentro de una Row, reparte el espacio sobrante entre los hijos con peso. textAlign = TextAlign.End alinea el texto a la derecha.
  • item.partido.marcador().toString() es el texto del resultado: Marcador (de :torneo) tiene su propio toString, que escribe 2-1 (goles del local, guion, goles del visitante).
  • torneo.goleadores() devuelve un Ranking<Jugador> (Comparadores, Iterable y el ranking): ranking.ordenados son los jugadores de más a menos goles y ranking.puntosDe(jugador) sus goles. torneo.tablaDePosiciones() devuelve la lista de FilaDePosicion. La pantalla necesita pares (jugador, goles): el intento los construye con ranking.ordenados.map { it to ranking.puntosDe(it) }.
  • Ya las conoces: Pair (Colecciones; jugador to goles, aquí un jugador y sus goles) y Jugador.dorsalOGuion(), una función de :torneo que 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:

  1. FilaDePartido, una tarjeta con el nombre del local a la izquierda, el resultado en negrita en el centro y el visitante a la derecha; y PantallaPartidos, una lista perezosa con una de esas filas por partido, identificada por el id del partido.
  2. 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 da goleadoresConGoles, una función de extensión sobre Torneo (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 de Listas.kt, a partir de su ranking de goleadores.
  3. Una vista previa de PantallaTablas con los datos de ejemplo, envuelta en MaterialTheme.

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
app/src/main/java/com/sebastiannarvaez/marcador/feature/partidos/ui/Listas.kt
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ónPor qué
Cabeceras y filas de las dos listas en una LazyColumnUna 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 pantallaLa 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.kt empieza por 1. Rayo FC con 15 pts y 9 PJ, tiene 4 equipos, y termina con los goleadores, con Nico #11 y 11 goles arriba.
  • 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. 1.¿Qué diferencia hay entre una Column y una LazyColumn con muchas filas?

  2. 2.Una lista no lleva key. Se inserta un partido arriba. ¿Qué pasa con lo que cada fila recordaba?

  3. 3.Usas key = { (jugador, _) -> jugador } con un Jugador de :torneo. ¿Qué ocurre al entrar en la pantalla?

  4. 4.¿Cuál de estas es una buena clave para las filas de la tabla de goleadores?

  5. 5.¿Para qué usarías Paging 3 en una app?

  6. 6.Dentro de las llaves de LazyColumn { … }, ¿qué puedes escribir directamente?

Faltan 6 de 6