La lista en Android
Al terminar: Pintar en Compose la lista paginada con imágenes, tarjetas y estados.
Todo lo de las lecciones anteriores estaba en commonMain y no se veía. Al terminar esta ya hay app: una rejilla de tarjetas con la ilustración de cada Pokémon, con sus tipos de colores, que va pidiendo más Pokémon a medida que bajas. Lo que queda (tirar para refrescar, la barra que se encoge, los avisos de error y la voz para quien no ve) se lee en la lección siguiente, Refresco, barra y accesibilidad en Android, sobre la misma pantalla.
Compose y las listas perezosas ya los conoces de Marcador (Listas con LazyColumn), y la paginación, de Paginación con Paging 3: aquí no se vuelven a explicar. Lo importante no es que quede bonita, sino qué parte de todo esto es de Android y no tiene equivalente en iOS: el color dinámico, la barra, el componente de refresco, las columnas adaptativas y la carga de imágenes. Cinco cosas, cinco decisiones que la app de iOS tomará distinto dos lecciones más adelante. Aquí verás las columnas adaptativas, la carga de imágenes y el color; la barra y el refresco se leen en la lección siguiente.
Tres piezas nuevas, antes de escribir nada
1 · Una rejilla que decide sola cuántas columnas pone. Una LazyColumn pinta una columna. Una LazyVerticalGrid pinta una rejilla, y sigue siendo perezosa: crea solo las celdas que se ven. El número de columnas se le dice con GridCells.Adaptive(minSize = …): «pon tantas columnas como quepan, con al menos este ancho cada una». No hay un if por tamaño de pantalla: el mismo código da una columna en un móvil pequeño y más en uno grande.
2 · Una librería que baja las imágenes: Coil. Cada Pokémon trae la dirección (URL) de su ilustración, y alguien tiene que descargarla y pintarla. Coil lo hace, y además guarda en memoria lo que ya bajó para no repetirlo. Piensa en una tienda de revelado de fotos: Coil es la tienda, y necesita un mensajero (en inglés, fetcher, «traedor») que sepa ir a buscar las fotos a un sitio concreto. Coil 3 no trae mensajero para internet, y hay que elegir uno: lo verás en el paso 1.
3 · Una lista que avisa a Paging. Hasta ahora el ViewModel entregaba un Flow<PagingData<Pokemon>>, que Compose no sabe recorrer. collectAsLazyPagingItems() lo convierte en LazyPagingItems: una lista con itemCount (cuántos Pokémon hay cargados) a la que se pide cada uno por posición, pokemon[indice]. Y al pedir uno, avisa a Paging de por dónde vas mirando, para que descargue la página siguiente a tiempo.
Las tres juntas caben en un ejemplo de treinta líneas con datos ajenos: un escaparate de tienda. No se escribe en tu proyecto; está para que veas las piezas sin el ruido de la Pokédex.
data class Producto(val nombre: String, val foto: String) // un producto: su nombre y la URL de su foto
@Composable
fun Escaparate(productos: List<Producto>) {
LazyVerticalGrid( // rejilla perezosa: solo crea las celdas que se ven
columns = GridCells.Adaptive(minSize = 150.dp), // tantas columnas como quepan con 150 dp o más cada una
contentPadding = PaddingValues(16.dp), // margen alrededor de toda la rejilla
horizontalArrangement = Arrangement.spacedBy(12.dp), // hueco entre columnas
verticalArrangement = Arrangement.spacedBy(12.dp), // hueco entre filas
) {
items(productos) { producto -> // una celda por producto
Card { // la tarjeta
AsyncImage( // Coil: baja la foto de la URL y la pinta
model = producto.foto, // de dónde baja la foto
contentDescription = null, // null: la imagen es solo decoración
modifier = Modifier
.fillMaxWidth() // tan ancha como la tarjeta
.aspectRatio(1f), // y cuadrada
)
Text(producto.nombre, modifier = Modifier.padding(8.dp))
}
}
}
}Frase por pieza:
dpes la unidad de tamaño de Android (píxeles independientes de la densidad de la pantalla): 150 dp miden lo mismo a la vista en un móvil modesto y en uno de pantalla muy nítida.AsyncImagees el composable de Coil. Recibe la URL enmodely pinta la imagen cuando llega; mientras tanto deja ver el fondo.contentDescription = nulldice «esta imagen es solo decoración»: un lector de pantalla no la lee. En la tarjeta de la Pokédex hay una razón concreta, y se cuenta en la lección siguiente.- En la Pokédex,
itemsno recibe una lista como aquí sino un número (pokemon.itemCount) y se pide cada Pokémon por posición: así es comoLazyPagingItemsse entera de por dónde vas.
Paso 1 · Lo que Android pone y la librería de imágenes que no
Todas las rutas de esta lección (androidApp/src/main/kotlin/…, gradle/libs.versions.toml) son las de la vista Project de Android Studio, que enseña las carpetas tal como están en el disco. Si el panel de la izquierda todavía enseña la vista Android de los fundamentos, cámbiala con el selector de arriba del panel (donde pone Android) → Project.
1 · Qué instalas y por qué ahora. Tres librerías, todas para androidApp:
paging-compose: el puente entre Paging y Compose (collectAsLazyPagingItems). Hasta ahora solo tenías la parte de Paging que no sabe nada de pantallas (paging-common, de Paginación con Paging 3); esta es la que habla con Compose, así que entra con la pantalla que la usa.coil-compose: Coil, con su composableAsyncImage. Hace falta ya porque cada tarjeta lleva la ilustración del Pokémon, que PokeAPI sirve como URL (en Marcador no hubo imágenes de red).coil-network-ktor3: el mensajero de red de Coil. Coil 3 no lo trae de serie (la caja de abajo lo cuenta).
2 · La versión, en [versions]. Abre gradle/libs.versions.toml y añade una línea al final de la sección [versions]. paging-compose reutiliza la versión paging que ya tienes, así que solo hace falta la de Coil, una sola para sus dos piezas porque se publican juntas:
coil = "3.6.2"3 · Las librerías, en [libraries]. Al final de esa sección (la cabecera [libraries] ya existe; no la repitas), tres líneas:
androidx-paging-compose = { module = "androidx.paging:paging-compose", version.ref = "paging" }
coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" }
coil-network-ktor3 = { module = "io.coil-kt.coil3:coil-network-ktor3", version.ref = "coil" }4 · No es un plugin, así que [plugins] y el build.gradle.kts de la raíz no se tocan.
5 · La dependencia, en el módulo que la usa. Abre androidApp/build.gradle.kts. Es un módulo de Android normal, con un dependencies { } suelto (el commonMain.dependencies es de shared). Añade las tres con implementation, porque solo las usa esta app:
dependencies {
// … lo que ya había se queda igual
implementation(libs.androidx.paging.compose)
implementation(libs.coil.compose)
implementation(libs.coil.network.ktor3)
}La primera trae collectAsLazyPagingItems; la segunda, AsyncImage; la tercera, el mensajero de red (la caja de abajo).
6 · Sync Now, y comprueba. Pulsa Sync Now. Si sale en rojo, repasa la caja «Si Sync sale en rojo» de Los ficheros de Gradle de tu proyecto. Dos errores típicos aquí. Uno: un guion en vez de un punto o al revés (libs.coil-compose no existe; se escribe libs.coil.compose). Dos: olvidar paging-compose. Sync no se queja, pero al escribir la pantalla verás, en los import androidx.paging.compose…:
Unresolved reference 'compose'.y en la línea de collectAsLazyPagingItems:
Unresolved reference 'collectAsLazyPagingItems' on receiver of type 'Flow<PagingData<Pokemon>>'.Para comprobar que llegó todo, en la Terminal (la pestaña de abajo de Android Studio, abierta en la carpeta del proyecto). grep enseña solo las líneas de la salida que contienen lo que buscas: la barra vertical | pasa lo que escribe Gradle a grep, y dentro de las comillas paging-compose|coil significa «paging-compose o coil»:
./gradlew :androidApp:dependencies --configuration debugRuntimeClasspath | grep -E "paging-compose|coil"Deben aparecer androidx.paging:paging-compose:3.5.1, io.coil-kt.coil3:coil-compose:3.6.2 y io.coil-kt.coil3:coil-network-ktor3:3.6.2.
Se elige el mensajero de Ktor 3 porque la app ya tiene Ktor 3 (de HTTP y Ktor: el cliente de PokeAPI). Añadir el de OkHttp metería un segundo cliente HTTP en la app solo para bajar imágenes.
Paso 2 · El tema, que es lo más de Android que hay
El archivo. PokedexTheme.kt envuelve toda la app con sus colores. Es lo más de Android que hay: el color dinámico de Material You (el sistema de colores de Android que toma la paleta del fondo de pantalla del usuario) solo existe aquí.
Créalo. Clic derecho sobre el nodo androidApp → New → File y escribe la ruta desde la carpeta del módulo (el método de El módulo compartido y la prueba de humo); si ese menú no te lo ofrece, usa touch en la Terminal con la misma ruta:
src/main/kotlin/com/sebastiannarvaez/pokedex/android/ui/PokedexTheme.ktEscribe el fichero entero, empezando por la línea package (dice en qué carpeta vive el fichero) y los imports. Cada línea va comentada:
package com.sebastiannarvaez.pokedex.android.ui
import android.os.Build // para saber la versión de Android
import androidx.compose.foundation.isSystemInDarkTheme // ¿el sistema está en modo oscuro?
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme // paleta oscura propia
import androidx.compose.material3.dynamicDarkColorScheme // paleta oscura del fondo de pantalla
import androidx.compose.material3.dynamicLightColorScheme // paleta clara del fondo de pantalla
import androidx.compose.material3.lightColorScheme // paleta clara propia
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext // el Context, sin pasarlo como parámetro
private val Rojo = Color(0xFFD2413C) // un color: 0x + FF (opaco) + D2 (rojo) 41 (verde) 3C (azul)
private val ClaroPropio = lightColorScheme( // la paleta de catálogo, para el modo claro
primary = Rojo,
secondary = Color(0xFF4D90D5),
tertiary = Color(0xFFF4D23C),
)
private val OscuroPropio = darkColorScheme( // y la del modo oscuro
primary = Color(0xFFFFB4AA),
secondary = Color(0xFF9FCAFF),
tertiary = Color(0xFFE9C84A),
)
/**
* Color dinamico donde lo haya, y una paleta propia donde no.
*
* El color dinamico es de Android: toma los tonos del fondo de pantalla del
* usuario. No existe en iOS, y ese es justo el punto de tener dos interfaces
* nativas en vez de una compartida.
*/
@Composable
fun PokedexTheme(
oscuro: Boolean = isSystemInDarkTheme(),
contenido: @Composable () -> Unit,
) {
val contexto = LocalContext.current // lo necesita el color dinámico
val esquema = when {
// Android 12 o más (nivel de API 31): colores sacados del fondo de pantalla.
Build.VERSION.SDK_INT >= Build.VERSION_CODES.S ->
if (oscuro) dynamicDarkColorScheme(contexto) else dynamicLightColorScheme(contexto)
oscuro -> OscuroPropio // más antiguo: la paleta propia
else -> ClaroPropio
}
MaterialTheme(colorScheme = esquema, content = contenido) // todo lo de dentro hereda esos colores
}Antes, una aclaración sobre el color de arriba: 0xFFD2413C son cuatro pares de cifras hexadecimales (base 16, de 00 a FF). El primero, FF, es la opacidad (FF es totalmente opaco), y los otros tres son la cantidad de rojo, de verde y de azul. Esas siglas, en inglés, son lo que da nombre al formato, ARGB.
Después, TypeColors.kt: el color y el nombre en castellano de cada uno de los dieciocho tipos (la propiedad etiqueta de cada PokemonType, que es el texto que ponen las tarjetas: «Planta», «Veneno»…). Es una tabla larga y repetitiva, así que se copia; lo que importa es dónde vive y por qué.
Créalo. Clic derecho sobre androidApp → New → File → src/main/kotlin/com/sebastiannarvaez/pokedex/android/ui/TypeColors.kt. Pulsa Copiar fichero, selecciona todo en el editor y pega (Cmd+A / Cmd+V). El texto trae su package.
Su cabecera dice la razón de que esté en androidApp y no en shared, y es una decisión de diseño:
// El aspecto no se comparte: compartir el color obligaría a elegir un espacio de
// color común y a renunciar a que cada plataforma use el suyo. Lo que se comparte
// es el tipo (PokemonType); cómo se pinta lo decide cada app.
val PokemonType.color: Color // propiedad de extensión: añade `.color` a un tipo de shared
get() = when (this) { PokemonType.NORMAL -> Color(0xFF9099A1) /* … y así hasta los 18 tipos */ }Sí, la app de iOS tendrá su propia tabla con colores parecidos pero no iguales. No es duplicación por descuido: es la decisión de no compartir el aspecto, escrita.
Paso 3 · La tarjeta y la ilustración
El archivo. PokemonCard.kt es la tarjeta de cada Pokémon: degradado del color de su tipo, ilustración, nombre y etiquetas. Créalo y cópialo igual que el anterior (src/main/kotlin/com/sebastiannarvaez/pokedex/android/list/PokemonCard.kt), y mira la pieza que importa.
El degradado sale del tipo principal, y la ilustración se pide con Coil, igual que en el ejemplo del escaparate:
AsyncImage(
model = pokemon.artworkUrl,
// null: la tarjeta entera ya tiene descripcion, y repetirla
// haria que el lector de pantalla la dijera dos veces.
contentDescription = null,
contentScale = ContentScale.Fit,
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 18.dp, vertical = 14.dp),
)contentScale = ContentScale.Fit pide que la imagen entre entera en su hueco, sin recortarse. La tarjeta lleva además una descripción para el lector de pantalla, que se lee en la lección siguiente.
Paso 4 · La pantalla
El archivo. PokemonListScreen.kt es la pantalla: la barra, la rejilla, el refresco y los estados de carga y error. Son unas 170 líneas, y lo propio de KMP cabe en unas pocas. Así que el fichero entero se copia y después se leen las piezas que importan. Verás que, además de PokemonListScreen, trae otras cuatro funciones marcadas private (PokemonListContent, Rejilla, Centrado y EstadoDeError): solo existen dentro de este fichero, y en la lección siguiente se leen. La barra, el refresco y los estados se dejan para la lección siguiente: ya funcionan, y esta se centra en la rejilla.
Créalo y cópialo. Clic derecho sobre androidApp → New → File → src/main/kotlin/com/sebastiannarvaez/pokedex/android/list/PokemonListScreen.kt. Pulsa Copiar fichero, selecciona todo y pega (Cmd+A / Cmd+V).
Ahora, las piezas. La puerta de entrada son cinco líneas:
@Composable
fun PokemonListScreen(
modifier: Modifier = Modifier,
viewModel: PokemonListViewModel = koinViewModel(),
) {
// collectAsLazyPagingItems es el puente que Android ya trae hecho. En iOS
// hubo que escribirlo a mano: es la misma pieza, aqui regalada.
val pokemon = viewModel.pokemon.collectAsLazyPagingItems()
PokemonListContent(pokemon = pokemon, modifier = modifier)
}Compara eso con las noventa líneas del presentador de El puente de paginación para iOS. Es el mismo trabajo; en Android ya está hecho.
Dos piezas de esa cabecera. koinViewModel() pide a Koin el PokemonListViewModel y lo ata a la pantalla (es la versión de Compose de lo que registraste con viewModelOf en Koin: un solo grafo en commonMain; la función se presentó en Arrancar Koin desde Android y desde iOS). Y collectAsLazyPagingItems() convierte el Flow<PagingData<Pokemon>> en LazyPagingItems, la lista de la que hablaba el principio.
Y la rejilla, que es el ejemplo del escaparate con datos de verdad. pokemon.itemCount es cuántos Pokémon hay cargados, y pokemon[indice] da uno (y, al pedirlo, avisa a Paging de por dónde vas: es lo que en iOS hace loadAround). key y contentType ayudan a Compose a reutilizar celdas: itemKey { it.id } da a cada celda como clave el id del Pokémon (la lección siguiente mira de cerca la clave), y itemContentType { "pokemon" } etiqueta todas las celdas con el mismo tipo, "pokemon", para que Compose pueda reciclar una celda que sale de pantalla en otra que entra. Por último, pokemon[indice] devuelve un Pokémon que podría ser null (para Paging hay listas con huecos por rellenar), y ?.let { … } pinta la tarjeta solo si hay Pokémon. En esta app nunca hay huecos (se desactivan en PokemonListViewModel), así que siempre hay uno; el ?.let es lo que el compilador pide por el tipo.
LazyVerticalGrid(
columns = GridCells.Adaptive(minSize = 164.dp),
contentPadding = PaddingValues(16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.fillMaxSize(),
) {
items(
count = pokemon.itemCount,
key = pokemon.itemKey { it.id },
contentType = pokemon.itemContentType { "pokemon" },
) { indice ->
pokemon[indice]?.let { PokemonCard(it) }
}Cuántas columnas salen. minSize es una afirmación sobre el contenido, no sobre la pantalla: 164 dp es el ancho mínimo con el que una tarjeta de esta app sigue siendo legible. A partir de ahí, cuántas caben lo decide el ancho disponible: con 164 salen una columna en un móvil de 360 dp de ancho y dos en uno de 411 dp (en 360 dp, descontados los márgenes, quedan 328 dp, que no dan para dos tarjetas de 164 más el hueco de 12). En una tableta o con el móvil girado salen más, sin tocar nada.
Paso 5 · Arrancar en la lista, y borrar lo que sobra
Por último, MainActivity.kt. Ahora arranca en la lista: dentro de setContent, que es el punto donde la Activity entrega su pantalla a Compose (La Activity), sustituye el contenido por esto y añade los import de PokedexTheme y PokemonListScreen (Option+Enter sobre el nombre en rojo, que es el atajo de Android Studio para ofrecer «arreglar», y aquí lo que ofrece es importar; el menú Code → Optimize Imports borra los que queden en gris):
setContent {
PokedexTheme {
PokemonListScreen()
}
}El fichero androidApp/src/main/kotlin/com/sebastiannarvaez/pokedex/android/home/HomeScreen.kt, de El ViewModel compartido en Android, ya no lo usa nadie: bórralo (clic derecho sobre HomeScreen.kt → Delete → OK). Nada deja de compilar. La parte compartida (HomeViewModel, HomeUiState) se queda donde estaba.
Punto de control
./gradlew :androidApp:assembleDebugtermina enBUILD SUCCESSFUL; al ejecutarandroidAppen el emulador, la app arranca en la rejilla: de una columna en un móvil pequeño y de dos en uno normal.- Las ilustraciones se ven: si no, falta
coil-network-ktor3. - Al llegar al final de lo cargado entran más Pokémon, sin huecos ni repetidos.
HomeScreen.ktya no existe.- Esta lección no añade tests: siguen los de antes, 34 en la JVM, 33 en Android host y 35 en iOS con tus ejercicios (27 / 26 / 28 sin ellos). Tus ejercicios suman tests (los de los que hayas hecho), y la JVM tiene uno más que Android por
ModulesTest, que solo corre en la JVM.
Siguiente: Refresco, barra y accesibilidad en Android. No cambia ningún fichero: lee con calma lo que esta pantalla ya hace.
Comprueba lo que entendiste
5 preguntas · necesitas 4 para completar la lección
1.Añades
coil-compose, pintasAsyncImagecon una URL correcta y no se ve nada, sin ningún error en el log. ¿Qué falta?2.¿Por qué
TypeColors.ktvive enandroidAppy no en el módulo compartido?3.Una sola línea,
viewModel.pokemon.collectAsLazyPagingItems(), sustituye a las noventa líneas del presentador de iOS. ¿Qué conclusión saca la guía de ahí?4.La rejilla usa
GridCells.Adaptive(minSize = 164.dp): sale una columna en un móvil de 360 dp de ancho y dos en uno de 411 dp. ¿Qué dice ese 164?5.En la rejilla,
pokemon[indice]devuelve el Pokémon de esa posición. ¿Qué hace además?