Saltar al contenido
K
Favoritos con RoomPaso a paso28 min Intermedio

Favoritos en Android

Al terminar: Añadir el corazón a la rejilla de la lista y que quede guardado entre sesiones.

Los favoritos ya funcionan y nadie los ve. Al terminar esta lección la rejilla de Android tendrá un corazón en cada tarjeta que se llena al tocarlo, sobrevive al girar el teléfono y no le roba el gesto a la tarjeta.

Son pocas líneas de Compose, y casi todas las decisiones que llevan dentro son de accesibilidad y de zonas táctiles. Ahí es donde se nota la diferencia entre poner un icono y poner un control. Lo de Compose (estado elevado, Modifier) se aplica aquí tal cual. Lo propio de Kotlin Multiplatform es de dónde viene el dato: el FavoritesViewModel está en shared y esta pantalla lo pide a Koin como a cualquier otro.

Paso 1 · La tarjeta recibe el estado y avisa del evento

androidApp
src/main/kotlin/com/sebastiannarvaez/pokedex/android/list
PokemonCard.ktse modifica: el corazón
PokemonListScreen.ktse modifica: quien lo alimenta
Dos ficheros de la app de Android (módulo androidApp), ninguno del módulo compartido.

El archivo. PokemonCard.kt es la tarjeta de la rejilla; la creaste en La lista en Android. Ábrelo en la vista Project: androidApp/src/main/kotlin/com/sebastiannarvaez/pokedex/android/list/PokemonCard.kt.

La firma de la función crece en dos parámetros, uno de entrada y otro de salida. Añade esFavorito y alMarcar, de modo que quede así:

PokemonCard: la firma, comentada
@Composable
fun PokemonCard(
    pokemon: Pokemon,
    esFavorito: Boolean,   // entra: ¿está guardado?
    alPulsar: () -> Unit,
    alMarcar: () -> Unit,  // sale: «me han tocado el corazón»
    modifier: Modifier = Modifier,
) {

Mientras no llegues al paso 3, Android Studio marcará en rojo la llamada a PokemonCard dentro de Rejilla: es lo esperado. Si PokemonCard.kt tiene una función @Preview que también llama a PokemonCard(…), saldrá en rojo igual: añádele esFavorito = false y alMarcar = {} a esa llamada.

Es el patrón de elevar el estado: la tarjeta no sabe nada de favoritos. No tiene un ViewModel, no consulta la base de datos y no guarda estado. Recibe un Boolean y recibe también una lambda (alMarcar) que llama cuando la tocan. Eso es lo que la deja probar en aislamiento y previsualizar sin que nada arranque.

Paso 2 · El corazón, con su propia zona táctil

La tarjeta (PokemonCard) pinta una imagen sobre un Box, un contenedor que apila lo que lleva dentro. El corazón va al final de ese Box, después del AsyncImage: en un Box, lo último que se escribe se dibuja encima (lo viste en Compose: composables y modifiers). Si fuera antes, la ilustración podría taparlo si llegaran a solaparse. Así queda el Box entero, con lo nuevo al final:

PokemonCard: el Box, con el corazón al final
Box(
    modifier = Modifier
        .fillMaxWidth()
        .aspectRatio(1.15f)
        .background(Brush.verticalGradient(listOf(tono.copy(alpha = 0.38f), tono.copy(alpha = 0.10f)))),
) {
    Text(
        text = "N.º ${pokemon.id.toString().padStart(4, '0')}",
        // …los mismos parámetros que ya tenías
        modifier = Modifier.align(Alignment.TopStart).padding(12.dp),
    )
 
    AsyncImage(
        model = pokemon.artworkUrl,
        // …los mismos parámetros que ya tenías
    )
 
    // El corazon va despues de la imagen: en un Box lo ultimo que se escribe se
    // dibuja encima. Con su propia zona tactil: marcar no debe abrir la ficha.
    IconButton(
        onClick = alMarcar,
        modifier = Modifier.align(Alignment.TopEnd).size(36.dp),
    ) {
        Icon(
            imageVector = if (esFavorito) Icons.Filled.Favorite else Icons.Filled.FavoriteBorder,
            contentDescription = if (esFavorito) "Quitar de favoritos" else "Añadir a favoritos",
            tint = if (esFavorito) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant,
            modifier = Modifier.size(20.dp),
        )
    }
}

Lo único nuevo es el bloque IconButton del final, con su comentario: pégalo detrás del AsyncImage. El resto del Box ya lo tienes.

Los nombres que Android Studio subraye en rojo (Icons, Icon, IconButton, Favorite, FavoriteBorder) son import que faltan: pulsa Option+Enter sobre cada uno y elige el de androidx.compose.material…. Los iconos Icons.Filled.* vienen de la librería material-icons-core, que ya instalaste en Navegación en Android con Navigation 3.

Tres detalles que parecen decoración y no lo son.

IconButton y no Icon con clickable. Los dos responden en una zona de 48 dp: Compose agranda por su cuenta la zona de toque de cualquier control pequeño, no solo la del IconButton. Lo que IconButton añade es lo que no se ve:

  • Le dice a TalkBack que es un botón (su «papel», Role.Button). Con clickable a secas, TalkBack anunciaría el icono sin decir que se puede pulsar.
  • Trae el resalte redondo al pulsar.
  • Respeta el estado desactivado.

Hacerlo a mano con un Icon es posible, pero hay que reescribir lo que IconButton ya trae: un clickable(role = Role.Button, …) y, si quieres que el control reserve sitio en la pantalla, minimumInteractiveComponentSize() (la zona de toque crece, pero el espacio que ocupa en la pantalla no). La regla sirve para cualquier componente de Material: si hay uno para eso, trae decisiones de accesibilidad dentro que no se ven en el código.

Hay tres tamaños, no dos. El dibujo mide 20 dp, para que no tape la ilustración. El botón, es decir, lo que se ve al pulsar (el círculo de resalte), mide 36 dp, porque se lo pedimos con size(36.dp). Y la zona que responde al dedo mide 48 dp: Compose la amplía hasta ese mínimo, el que recomienda Google para cualquier control táctil, aunque lo que se vea sea más pequeño. Son números distintos porque resuelven problemas distintos.

Paso 3 · La lista y la rejilla lo pasan celda a celda

El archivo. PokemonListScreen.kt (en la misma carpeta, …/android/list/PokemonListScreen.kt) tiene, entre otras, tres funciones que importan aquí: PokemonListScreen (la que pide los ViewModel, paso 4), PokemonListContent (la que pinta la pantalla, privada) y Rejilla (la cuadrícula, privada). Los dos datos nuevos, idsFavoritos: Set<Int> (los números marcados) y alMarcar: (Pokemon) -> Unit (el aviso, con el Pokémon entero), bajan por las tres. Hasta el paso 4, la llamada a PokemonListContent saldrá en rojo. Primero la firma nueva de PokemonListContent, con dos parámetros más (déjalos antes de modifier):

PokemonListContent: parámetros nuevos
private fun PokemonListContent(
    // …los parámetros que ya tenías (pokemon, busqueda, expandida, alEscribir, alPulsar…)
    idsFavoritos: Set<Int>,           // NUEVO: los números marcados
    alMarcar: (Pokemon) -> Unit,      // NUEVO: «me han tocado el corazón de este»
    modifier: Modifier = Modifier,
) {

Dentro de ella, la llamada a Rejilla (la rama else del when) los reenvía:

PokemonListContent: la llamada a Rejilla, antes y después
// antes
else -> Rejilla(pokemon, alPulsar)
// después
else -> Rejilla(pokemon, alPulsar, idsFavoritos, alMarcar)

Y Rejilla los recibe:

androidApp/src/main/kotlin/com/sebastiannarvaez/pokedex/android/list/PokemonListScreen.kt
private fun Rejilla(
    pokemon: LazyPagingItems<Pokemon>,
    alPulsar: (Int) -> Unit,
    idsFavoritos: Set<Int>,
    alMarcar: (Pokemon) -> Unit,
) {

Y dentro del items, sustituye la llamada a PokemonCard(…) que ya tenías por esta (es la misma con dos argumentos más), con la consulta al Set de la lección de núcleo:

androidApp/src/main/kotlin/com/sebastiannarvaez/pokedex/android/list/PokemonListScreen.kt
            pokemon[indice]?.let { p ->
                PokemonCard(
                    pokemon = p,
                    esFavorito = p.id in idsFavoritos,
                    alPulsar = { alPulsar(p.id) },
                    alMarcar = { alMarcar(p) },
                )
            }

Ahí está, en una línea: p.id in idsFavoritos. Un Set es una colección sin repetidos que responde «está o no está» sin recorrerse entera (Colecciones), así que cuesta lo mismo con 3 favoritos que con 300. Se ejecuta una vez por celda visible, unas doce en un teléfono. Cuando marcas un favorito, lo que cambia es ese conjunto; el PagingData ni se entera y la lista no se recompone entera (la recomposición es Compose volviendo a ejecutar los composables cuyo dato cambió; se explica en Recomposición).

Paso 4 · La pantalla junta los dos ViewModel

El archivo. PokemonListScreen.kt (el mismo) es quien tiene los ViewModel de la lista. La función PokemonListScreen pide uno más, el de favoritos, que koinViewModel() entrega desde el grafo de Koin de Arrancar Koin desde Android y desde iOS. Añade el parámetro favoritos (y el import de FavoritesViewModel, que sale de shared: com.sebastiannarvaez.pokedex.feature.favorites.FavoritesViewModel, o Option+Enter sobre el nombre en rojo):

androidApp/src/main/kotlin/com/sebastiannarvaez/pokedex/android/list/PokemonListScreen.kt
fun PokemonListScreen(
    alPulsar: (Int) -> Unit,
    modifier: Modifier = Modifier,
    viewModel: PokemonListViewModel = koinViewModel(),
    buscador: PokemonSearchViewModel = koinViewModel(),
    favoritos: FavoritesViewModel = koinViewModel(),
) {

Debajo de val busqueda by …, añade la tercera línea (la de idsFavoritos). Las dos primeras ya las tienes; así se ve que las páginas y los favoritos se recogen por caminos distintos, que es la decisión de Favoritos en el núcleo hecha código:

androidApp/src/main/kotlin/com/sebastiannarvaez/pokedex/android/list/PokemonListScreen.kt
    val pokemon = viewModel.pokemon.collectAsLazyPagingItems()
    val busqueda by buscador.uiState.collectAsStateWithLifecycle()
    val idsFavoritos by favoritos.favoriteIds.collectAsStateWithLifecycle()

Las páginas van por collectAsLazyPagingItems y los favoritos por collectAsStateWithLifecycle. Dos flujos, dos recolectores, ningún punto donde se mezclen antes de llegar a la pantalla.

Y la llamada a PokemonListContent, que ya tienes en esa misma función, recibe dos argumentos más (junto a alPulsar = alPulsar), los que acabas de declarar en el paso 3:

androidApp/src/main/kotlin/com/sebastiannarvaez/pokedex/android/list/PokemonListScreen.kt
        idsFavoritos = idsFavoritos,
        alMarcar = { p -> favoritos.toggle(p.id, p.name) },

Con esto el rojo del paso 3 desaparece. Fíjate en que la lambda manda el nombre además del número. Es la otra mitad de la decisión de guardar el nombre en la tabla: quien marca el favorito tiene el Pokemon entero delante, así que es el momento más barato de capturar el nombre. Si solo se mandara el número, el repositorio tendría que pedirlo, y pedirlo es red.

Paso 5 · Comprobar en el emulador

Esto no se da por bueno leyendo el código. Ejecuta la app como en Ejecutar en Android (configuración androidApp), espera a que cargue la rejilla y comprueba:

· Tocar el corazón: se rellena y se pone rojo, la ficha no se abre.
· Tocar la tarjeta a un dedo de distancia del corazón (1 cm, por ejemplo sobre la ilustración): se abre la ficha.
· Girar el teléfono (botón de girar en la barra del emulador, o Cmd+← / Cmd+→): los corazones marcados siguen marcados.
· Cerrar la app del todo (Stop en Android Studio) y volver a abrirla con Run: siguen marcados.
· Con TalkBack encendido, el botón se anuncia como «Añadir a favoritos» y, tras pulsarlo, como «Quitar de favoritos».

TalkBack en el emulador. El emulador con Google Play, el que instalaste para el curso, suele traerlo instalado. El emulador está en inglés, así que el camino es Settings → Accessibility → TalkBack → Use TalkBack; acepta el permiso que pide con Allow. Después sale un segundo diálogo, el de las notificaciones de Android Accessibility Suite: con TalkBack ya encendido se pulsa con doble toque. Qué hace TalkBack: lee en voz alta lo que tocas, y para pulsar un elemento seleccionado hay que dar doble toque. Con el corazón de una tarjeta debería leer «Añadir a favoritos» (y, ya marcado, «Quitar de favoritos»), como dice su descripción. Para apagarlo, vuelve al mismo sitio y desactiva Use TalkBack; mientras esté encendido, el resto de la app se maneja con ese doble toque. Si en Accessibility no aparece TalkBack, instala «Android Accessibility Suite» desde Play Store.

El tercero y el cuarto no son mérito de esta lección, sino de que el estado vive en la base de datos y no en la pantalla. Merece la pena comprobarlos igual: es la primera vez en la Pokédex que algo sobrevive a cerrar la app.

Punto de control

  • La rejilla pinta el corazón y marcar uno no abre la ficha; tocar a un centímetro del corazón sí abre la ficha.
  • Los favoritos siguen ahí después de girar el teléfono y después de cerrar la app.
  • PokemonCard no tiene ningún ViewModel dentro: recibe esFavorito y alMarcar (una lambda que llama al tocarlo), y el IconButton va después del AsyncImage en el Box.
  • Con TalkBack, la descripción del botón cambia según el estado.
  • PokemonListScreen recoge las páginas con collectAsLazyPagingItems y los identificadores con collectAsStateWithLifecycle.
  • ./gradlew :shared:jvmTest :shared:testAndroidHostTest :shared:iosSimulatorArm64Test sigue en BUILD SUCCESSFUL con 51 tests en la JVM, 40 en Android host y 42 en iOS: la lección no añade ninguno, porque es pantalla.

Comprueba lo que entendiste

6 preguntas · necesitas 5 para completar la lección

  1. 1.¿Qué añade IconButton frente a un Icon con Modifier.clickable?

  2. 2.¿Por qué la contentDescription del corazón cambia con el estado?

  3. 3.¿Qué hace esFavorito = p.id in idsFavoritos dentro del items de la rejilla?

  4. 4.¿Por qué alMarcar manda el nombre además del número?

  5. 5.¿Qué sabe PokemonCard sobre favoritos?

  6. 6.En el Box de la tarjeta, ¿dónde se escribe el IconButton del corazón para que se dibuje por encima de la ilustración?

Faltan 6 de 6