Saltar al contenido
K
La lista de PokémonPaso a paso37 min de lectura · 5 min de máquina Intermedio

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.

ejemplo mínimo: un escaparate con rejilla adaptable e imágenes (con comentarios)
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:

  • dp es 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.
  • AsyncImage es el composable de Coil. Recibe la URL en model y pinta la imagen cuando llega; mientras tanto deja ver el fondo.
  • contentDescription = null dice «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, items no recibe una lista como aquí sino un número (pokemon.itemCount) y se pide cada Pokémon por posición: así es como LazyPagingItems se 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.

Pokedex-KMP
gradle
libs.versions.tomlse modifica: coil y paging-compose
androidApp
build.gradle.ktsse modifica: 3 dependencias
src/main/kotlin/com/sebastiannarvaez/pokedex/android
ui/PokedexTheme.ktse crea: color dinámico
ui/TypeColors.ktse crea: el color de cada tipo
list/PokemonCard.ktse crea
list/PokemonListScreen.ktse crea
MainActivity.ktse modifica
home/HomeScreen.ktse borra: ya no lo usa nadie
Cuatro ficheros nuevos en androidApp, tres que se modifican y uno que se borra. Nada de esto se comparte. Vista 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 composable AsyncImage. 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:

gradle/libs.versions.toml
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:

gradle/libs.versions.toml
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:

androidApp/build.gradle.kts
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.kt

Escribe el fichero entero, empezando por la línea package (dice en qué carpeta vive el fichero) y los imports. Cada línea va comentada:

androidApp/src/main/kotlin/com/sebastiannarvaez/pokedex/android/ui/PokedexTheme.kt
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.

TypeColors.kt

Su cabecera dice la razón de que esté en androidApp y no en shared, y es una decisión de diseño:

TypeColors.kt: la idea, resumida
// 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.

PokemonCard.kt

El degradado sale del tipo principal, y la ilustración se pide con Coil, igual que en el ejemplo del escaparate:

androidApp/src/main/kotlin/com/sebastiannarvaez/pokedex/android/list/PokemonCard.kt
                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).

PokemonListScreen.kt

Ahora, las piezas. La puerta de entrada son cinco líneas:

androidApp/src/main/kotlin/com/sebastiannarvaez/pokedex/android/list/PokemonListScreen.kt
@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.

androidApp/src/main/kotlin/com/sebastiannarvaez/pokedex/android/list/PokemonListScreen.kt
    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):

androidApp/src/main/kotlin/com/sebastiannarvaez/pokedex/android/MainActivity.kt
        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:assembleDebug termina en BUILD SUCCESSFUL; al ejecutar androidApp en 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.kt ya 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. 1.Añades coil-compose, pintas AsyncImage con una URL correcta y no se ve nada, sin ningún error en el log. ¿Qué falta?

  2. 2.¿Por qué TypeColors.kt vive en androidApp y no en el módulo compartido?

  3. 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. 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. 5.En la rejilla, pokemon[indice] devuelve el Pokémon de esa posición. ¿Qué hace además?

Faltan 5 de 5