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

Refresco, barra y accesibilidad en Android

Al terminar: Añadir a la lista el gesto de refrescar, la barra que se encoge y la descripción para TalkBack.

La lista ya funciona. Esta lección no crea ni cambia ningún fichero (salvo una línea que cambias y devuelves en el paso 4, para provocar un fallo): lee con calma lo que PokemonListScreen.kt y PokemonCard.kt (los que copiaste en La lista en Android) hacen además de pintar tarjetas. Son los gestos y los detalles que hacen que una lista se sienta de Android: la barra que se encoge al bajar, tirar hacia abajo para refrescar, qué se ve cuando algo falla y qué oye quien no ve la pantalla.

Las rutas son las de la vista Project, que ya usas desde Crear el proyecto: el panel muestra las carpetas reales del disco.

androidApp/src/main/kotlin/com/sebastiannarvaez/pokedex/android/list
PokemonListScreen.ktla barra, el refresco, los estados y la clave
PokemonCard.ktla descripción para TalkBack
Ningún fichero cambia: se leen estos dos. Vista Project.

Cuatro piezas, con un ejemplo ajeno

  1. La barra grande que se encoge. LargeTopAppBar es una barra de título grande. Para que se encoja al bajar por la rejilla, la rejilla tiene que avisarle de que se está desplazando. Ese aviso es nestedScroll («desplazamiento anidado»): una conexión por la que el scroll de dentro le cuenta a la barra de fuera cuánto se ha movido. La barra decide qué hacer con ese dato; el comportamiento exitUntilCollapsedScrollBehavior dice «encógete hasta quedarte pequeña».
  2. Tirar para refrescar. PullToRefreshBox es un contenedor: envuelve la lista y añade el gesto de tirar hacia abajo desde arriba. Tú le dices si se está refrescando (isRefreshing) y qué hacer cuando el usuario tira (onRefresh).
  3. La clave de cada celda. Cuando la lista cambia, Compose necesita saber qué celda es cuál. Se lo dice la key: un valor propio de cada elemento, que no se repite.
  4. Una descripción única por tarjeta. TalkBack es el lector de pantalla de Android: lee en voz alta lo que hay en pantalla a quien no la ve. Cada elemento le da su texto con semantics { contentDescription = … }.

Las cuatro, juntas, sobre un escaparate de tienda (no se escribe en tu proyecto):

ejemplo mínimo: un escaparate con barra, refresco, clave y descripción (con comentarios)
data class Producto(val nombre: String, val foto: String)
 
@OptIn(ExperimentalMaterial3Api::class)                      // la barra grande y el refresco aún son «experimentales»
@Composable
fun Escaparate(productos: List<Producto>) {
    var refrescando by remember { mutableStateOf(false) }          // ¿se está refrescando?
    val barra = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState())
 
    Scaffold(
        modifier = Modifier.nestedScroll(barra.nestedScrollConnection), // el scroll avisa a la barra
        topBar = { LargeTopAppBar(title = { Text("Tienda") }, scrollBehavior = barra) },
    ) { relleno ->
        PullToRefreshBox(                                         // envuelve la rejilla
            isRefreshing = refrescando,
            onRefresh = { refrescando = true },                   // al tirar hacia abajo; aquí pedirías los datos otra vez
            modifier = Modifier.padding(relleno),
        ) {
            LazyVerticalGrid(
                columns = GridCells.Adaptive(minSize = 150.dp),   // tantas columnas de 150 dp como quepan
                contentPadding = PaddingValues(16.dp),
                modifier = Modifier.fillMaxSize(),
            ) {
                items(productos, key = { it.nombre }) { producto ->
                    Card(
                        modifier = Modifier
                            .padding(6.dp)
                            .semantics { contentDescription = producto.nombre }, // lo que lee TalkBack
                    ) {
                        AsyncImage(                               // Coil: baja la foto de la URL y la pinta
                            model = producto.foto,
                            contentDescription = null,            // la tarjeta ya se describe entera
                            modifier = Modifier.aspectRatio(1f),
                        )
                        Text(producto.nombre, modifier = Modifier.padding(8.dp))
                    }
                }
            }
        }
    }
}

Frase por pieza:

  • @OptIn(ExperimentalMaterial3Api::class) es un permiso explícito: Material marca así lo que todavía puede cambiar de forma en una versión futura, y obliga a decir «lo sé» una vez. Sin él, el compilador se niega. El fichero de la Pokédex lo lleva.
  • rememberTopAppBarState() guarda cuánto está encogida la barra entre redibujados (es el mismo remember de Estado en Compose ya empaquetado).
  • Scaffold te entrega relleno: el espacio que ocupa la barra. Si el contenido no se aparta ese tanto, queda debajo de ella.
  • refrescando es un estado: cuando vale true, PullToRefreshBox enseña su círculo de carga. Aquí vuelve a false enseguida; en la Pokédex lo gobierna Paging.

Paso 1 · La barra grande

Esta es la cabecera de la pantalla. Fíjate en el @OptIn (el permiso de arriba) y en que el contenido de la pantalla, PokemonListContent, recibe la lista de Paging. Esta función y las que verás en los pasos siguientes (Rejilla, Centrado, EstadoDeError) están en el mismo fichero, marcadas private: solo se pueden usar dentro de él, y las copiaste enteras en la lección anterior.

androidApp/src/main/kotlin/com/sebastiannarvaez/pokedex/android/list/PokemonListScreen.kt
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun PokemonListContent(
    pokemon: LazyPagingItems<Pokemon>,
    modifier: Modifier = Modifier,
) {
    // La barra grande se encoge al hacer scroll: es un gesto de Android, no
    // una imitacion de iOS.
    val comportamiento = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState())
    val refrescando = pokemon.loadState.refresh is LoadState.Loading && pokemon.itemCount > 0
 
    Scaffold(
        modifier = modifier.fillMaxSize().nestedScroll(comportamiento.nestedScrollConnection),
        topBar = {
            LargeTopAppBar(
                title = { Text("Pokédex", fontWeight = FontWeight.Bold) },
                scrollBehavior = comportamiento,
            )
        },
    ) { relleno ->

Es el escaparate, con una diferencia: refrescando no es una variable propia (en el escaparate, onRefresh lo pone en true al tirar hacia abajo y el círculo de carga gira; al terminar de pedir los datos, lo devolverías a false). Sale del estado de Paging: pokemon.loadState.refresh es Loading mientras se pide la primera página otra vez, y itemCount > 0 quiere decir «ya había algo en pantalla». Con la lista vacía, la primera carga se pinta con un círculo en el centro (paso 3), no con el gesto de refrescar.

Paso 2 · Tirar para refrescar

androidApp/src/main/kotlin/com/sebastiannarvaez/pokedex/android/list/PokemonListScreen.kt
        PullToRefreshBox(
            isRefreshing = refrescando,
            onRefresh = { pokemon.refresh() },
            modifier = Modifier.fillMaxSize().padding(relleno),
        ) {

onRefresh llama a pokemon.refresh(), que le dice a Paging «descarta lo que tienes y pide la primera página otra vez». Y padding(relleno) es el hueco de la barra, el mismo del escaparate.

Un contenedor. Quédate con esa palabra, porque en iOS el equivalente es un modificador de una línea y no envuelve nada.

Paso 3 · Qué se ve cuando carga o falla

Dentro del contenedor, la pantalla decide qué pintar con un when, que es un «si… si no…» de varias ramas:

androidApp/src/main/kotlin/com/sebastiannarvaez/pokedex/android/list/PokemonListScreen.kt
            when {
                // Primera carga, sin nada que enseñar todavia.
                pokemon.loadState.refresh is LoadState.Loading && pokemon.itemCount == 0 ->
                    Centrado { CircularProgressIndicator() }
 
                pokemon.loadState.refresh is LoadState.Error && pokemon.itemCount == 0 -> {
                    val error = (pokemon.loadState.refresh as LoadState.Error).error.aAppError().aUiError()
                    Centrado {
                        EstadoDeError(
                            titulo = error.titulo,
                            detalle = error.detalle,
                            reintentar = if (error.sePuedeReintentar) ({ pokemon.retry() }) else null,
                        )
                    }
                }
 
                else -> Rejilla(pokemon)
            }

Centrado { … } es una función del mismo fichero que centra lo que le pases, y EstadoDeError(…) pinta el título, el detalle y, si hay, el botón. El as LoadState.Error solo es seguro porque la condición anterior, is LoadState.Error, ya comprobó el tipo: un as a secas lanza una excepción cuando el tipo no es el esperado. En reintentar = if (…) ({ pokemon.retry() }) else null, el parámetro es «una función o nada» ((() -> Unit)?): si el error no se puede reintentar se pasa null y el botón no aparece (los paréntesis alrededor de la lambda evitan que el else se la trague).

Tres casos. La primera carga muestra un círculo centrado. Si esa primera carga falla, aAppError().aUiError() (la traducción de Errores que se pueden contar: de la excepción a un título, un detalle y si tiene sentido reintentar) da el texto, y el botón Reintentar llama a pokemon.retry(). En cualquier otro caso, la rejilla.

Y el pie de la rejilla, donde se pinta el estado de append (cargar la página siguiente): un indicador mientras llega, o el error con su botón si falló. when (val estado = …) mira el valor y, a la vez, lo guarda en estado para usarlo dentro de las ramas. item (una celda) frente a items (muchas) añade una sola celda a la rejilla, y span dice cuántas columnas ocupa: maxLineSpan es «todas las de la fila», así que el pie ocupa una fila entera en vez de una sola celda. else -> Unit es «en cualquier otro caso, no pintes nada».

androidApp/src/main/kotlin/com/sebastiannarvaez/pokedex/android/list/PokemonListScreen.kt
        // El pie: cargando mas, o el error de ampliar con su reintento.
        when (val estado = pokemon.loadState.append) {
            is LoadState.Loading -> item(span = { GridItemSpan(maxLineSpan) }) {
                Centrado(alto = 72.dp) { CircularProgressIndicator(modifier = Modifier.size(28.dp)) }
            }
 
            is LoadState.Error -> item(span = { GridItemSpan(maxLineSpan) }) {
                val error = estado.error.aAppError().aUiError()
                EstadoDeError(
                    titulo = error.titulo,
                    detalle = error.detalle,
                    reintentar = if (error.sePuedeReintentar) ({ pokemon.retry() }) else null,
                )
            }
 
            else -> Unit
        }

Paso 4 · La clave de cada celda

La línea key = pokemon.itemKey { it.id } dice a Compose qué identifica a cada celda: el id del Pokémon. Con una clave estable, cuando la lista cambia (llega una página, se refresca) Compose sabe qué celda es cuál y no las confunde. La regla es que dos celdas no pueden compartir clave.

itemKey es el ayudante oficial de paging-compose: construye la clave a partir del elemento. Como la lista se crea con enablePlaceholders = false (en PokemonListViewModel), nunca hay huecos sin dato dentro de itemCount y la clave siempre sale del id.

Provócalo tú. En la función Rejilla (hacia el final del fichero), cambia la línea key = pokemon.itemKey { it.id }, por una clave que no es única (el _ significa «recibo el índice y no lo uso») y ejecuta la app:

PokemonListScreen.kt: solo para provocarlo
            key = { _ -> 1 },   // la misma clave para todas las celdas

La app se cierra al arrancar la rejilla, y en Logcat (la pestaña de mensajes del dispositivo en Android Studio, que viste en El ciclo de vida de la Activity) aparece:

java.lang.IllegalArgumentException: Key "1" was already used. If you are using LazyColumn/Row please make sure you provide a unique key for each item.

Devuelve la línea a key = pokemon.itemKey { it.id },.

Paso 5 · La tarjeta, y lo que oye quien no ve

La tarjeta pone la ilustración con contentDescription = null (la imagen es decorativa) y, en cambio, describe la tarjeta entera, una sola vez:

androidApp/src/main/kotlin/com/sebastiannarvaez/pokedex/android/list/PokemonCard.kt
            // Descripcion unica para el lector de pantalla, que si no leeria
            // el numero y el nombre como dos cosas sueltas.
            .semantics {
                contentDescription = buildString {
                    append(pokemon.name.replaceFirstChar { it.uppercase() })
                    if (pokemon.types.isNotEmpty()) {
                        append(", tipo ")
                        append(pokemon.types.joinToString(" y ") { it.etiqueta })
                    }
                }
            },

Pieza a pieza: replaceFirstChar { it.uppercase() } pone en mayúscula la primera letra del nombre (PokeAPI lo trae en minúsculas: «bulbasaur»), y joinToString(" y ") { it.etiqueta } convierte la lista de tipos en un solo texto con « y » entre uno y otro, tomando de cada tipo su etiqueta en castellano («Planta», «Veneno»: la propiedad de TypeColors.kt). Así, TalkBack (el lector de pantalla de Android) leería «Bulbasaur, tipo Planta y Veneno». Sin esto leería cuatro cosas sueltas: el número, el nombre y un tipo por cada etiqueta.

No hace falta activar TalkBack ahora: aquí basta con saber dónde se escribe la descripción y qué leería. Cómo se comprueba una descripción como esta con un lector de pantalla lo verás más adelante, en Accesibilidad.

Punto de control

  • Tirando hacia abajo desde arriba se refresca la lista.
  • Sabes dónde está la descripción de cada tarjeta (semantics { contentDescription = … } en PokemonCard.kt) y qué leería TalkBack: «Bulbasaur, tipo Planta y Veneno».
  • Si provocaste el fallo de la clave, la línea está devuelta a key = pokemon.itemKey { it.id }, y la app vuelve a arrancar en la rejilla.
  • ./gradlew :androidApp:assembleDebug termina en BUILD SUCCESSFUL. Esta lección no cambia ficheros ni añade tests: siguen los de antes, 34 / 33 / 35 con tus ejercicios (JVM / Android host / iOS; 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.

Con esto, la app de Android está terminada. Falta la misma pantalla en SwiftUI: La lista en iOS.

Comprueba lo que entendiste

4 preguntas · necesitas 3 para completar la lección

  1. 1.En la rejilla, key = pokemon.itemKey { it.id }. ¿Qué pasa si todas las celdas comparten la misma clave?

  2. 2.La tarjeta lleva una contentDescription en el contenedor y null en la imagen. ¿Por qué?

  3. 3.¿Qué es PullToRefreshBox y qué le tienes que dar?

  4. 4.La barra grande se encoge al bajar por la rejilla. ¿Qué hace posible que la barra se entere del desplazamiento?

Faltan 4 de 4