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
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í:
@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:
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). Conclickablea 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):
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:
// antes
else -> Rejilla(pokemon, alPulsar)
// después
else -> Rejilla(pokemon, alPulsar, idsFavoritos, alMarcar)Y Rejilla los recibe:
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:
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):
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:
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:
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.
PokemonCardno tiene ningúnViewModeldentro: recibeesFavoritoyalMarcar(una lambda que llama al tocarlo), y elIconButtonva después delAsyncImageen elBox.- Con TalkBack, la descripción del botón cambia según el estado.
PokemonListScreenrecoge las páginas concollectAsLazyPagingItemsy los identificadores concollectAsStateWithLifecycle../gradlew :shared:jvmTest :shared:testAndroidHostTest :shared:iosSimulatorArm64Testsigue enBUILD SUCCESSFULcon 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.¿Qué añade
IconButtonfrente a unIconconModifier.clickable?2.¿Por qué la
contentDescriptiondel corazón cambia con el estado?3.¿Qué hace
esFavorito = p.id in idsFavoritosdentro delitemsde la rejilla?4.¿Por qué
alMarcarmanda el nombre además del número?5.¿Qué sabe
PokemonCardsobre favoritos?6.En el
Boxde la tarjeta, ¿dónde se escribe elIconButtondel corazón para que se dibuje por encima de la ilustración?