La lista en iOS
Al terminar: Pintar la misma lista en SwiftUI con AsyncImage, prefetch y refresco.
La misma lista, otra vez, en Swift. Y ese «otra vez» es la lección entera: al terminar tendrás dos pantallas que enseñan los mismos Pokémon, sacados del mismo Paging, y que no comparten una sola línea de aspecto.
El estado viene del presentador de Kotlin de la lección El puente de paginación para iOS. No se instala nada: todo lo de Swift que se usa viene con el sistema. Casi todo lo demás —AsyncImage, materiales, SF Symbols, tipografía redondeada— es de Apple, y no tiene equivalente en Android. Al final de la lección hay una tabla con las cinco diferencias, y la última fila resume el curso.
Las piezas nuevas, con un ejemplo ajeno
Tres piezas de SwiftUI que no has usado hasta ahora (en los fundamentos de Swift solo hubo List y ForEach), y otras dos que ya conoces, .refreshable y los SF Symbols:
LazyVGrides la rejilla perezosa: crea solo las celdas que se ven, comoLazyVerticalGriden Android. Las columnas se le dan conGridItem(.adaptive(minimum: 150)): «tantas columnas como quepan, con al menos 150 puntos de ancho cada una» (un punto es la unidad de medida de iOS, como el dp de Android: no es un píxel real, y el sistema la ajusta a la nitidez de cada pantalla)..refreshablees la que ya conoces: el gesto de tirar hacia abajo para refrescar, como un modificador que se pone sobre la lista.AsyncImagedescarga y pinta una imagen de una URL. Es del sistema: no hay que instalar nada. Como la imagen tarda, te pasa la fase de la carga (cargando, llegó o falló) y tú decides qué pintar en cada una.- SF Symbols son los iconos vectoriales que Apple incluye en el sistema (miles), y ya los usaste en las pestañas (
systemImage: "house") y en el aviso sin conexión de los árbitros (wifi.slash). Se piden por nombre; fuera de una pestaña o unaLabel, conImage(systemName: "photo"). - VoiceOver es el lector de pantalla de iOS.
.accessibilityElement(children: .ignore)junta todo lo de dentro en un solo elemento, y.accessibilityLabel(…)le da su texto.
Todas juntas, sobre un escaparate de tienda con datos ajenos (no se escribe en tu proyecto):
import SwiftUI
struct Producto: Identifiable { // Identifiable: cada producto tiene un `id` propio
let id: Int
let nombre: String
let foto: String
}
struct EscaparateVista: View {
let productos: [Producto]
private let columnas = [GridItem(.adaptive(minimum: 150))] // tantas columnas de 150 como quepan
var body: some View {
NavigationStack { // lo que da el título grande
ScrollView { // lo que se desplaza
LazyVGrid(columns: columnas) { // rejilla perezosa
ForEach(productos) { producto in // una celda por producto
VStack {
AsyncImage(url: URL(string: producto.foto)) { fase in // la imagen de red del sistema
switch fase {
case .success(let imagen): imagen.resizable().scaledToFit() // llegó
case .failure: Image(systemName: "photo") // falló
default: ProgressView() // cargando
}
}
.frame(height: 120)
Text(producto.nombre)
}
.accessibilityElement(children: .ignore) // una sola etiqueta por tarjeta
.accessibilityLabel(producto.nombre) // lo que lee VoiceOver
}
}
}
.refreshable { } // aquí pedirías los datos otra vez
.navigationTitle("Tienda") // título grande que se encoge solo
}
}
}Es el escaparate de Android de la lección Refresco, barra y accesibilidad en Android, pieza a pieza. Lo que en Compose es un componente que envuelve (PullToRefreshBox, LargeTopAppBar con su comportamiento y su nestedScroll), aquí es un modificador que se pone encima (.refreshable, .navigationTitle). Y lo que en Android necesitaba una librería (Coil, con su mensajero de red), aquí es AsyncImage, del sistema. No son dos formas de hacer lo mismo: son dos ideas de quién manda, y la tabla del final las junta.
Paso 1 · La carpeta y los dos ficheros de apoyo
Esta lección crea tres ficheros que se usan entre sí: la pantalla pinta tarjetas, y la tarjeta usa los colores de cada tipo. Para que Xcode nunca te enseñe errores rojos, los creamos del último que se usa al primero: primero los colores (TypeStyle.swift), después la tarjeta (PokemonCardView.swift) y, en el paso 2, la pantalla (PokemonListView.swift). Los dos primeros se copian ahora y se leen por dentro en los pasos 5 y 6.
Crea la carpeta. Cada pantalla de la Pokédex va en su propia carpeta, el mismo criterio por funcionalidad que en Marcador. Los ficheros nuevos van en una carpeta List, dentro de la carpeta de la app (la que contiene ContentView.swift): en Xcode, clic derecho sobre esa carpeta iosApp → New Folder → List, como en Las funciones que cruzan a Swift. Si ese menú no te lo ofrece, en la Terminal de Android Studio, en ~/Pokedex-KMP: mkdir -p iosApp/iosApp/List. Xcode la muestra sola en el Project navigator (el panel izquierdo), porque la carpeta de la app está sincronizada: Xcode refleja lo que hay en el disco y cualquier .swift que aparezca dentro entra en la app sin tocar nada más.
Primero, los colores. TypeStyle.swift es la gemela de TypeColors.kt, otra tabla de dieciocho tipos. En Xcode, clic derecho sobre la carpeta List → New File from Template… → Swift File → Next, nombre TypeStyle → Create. Pulsa Copiar fichero, selecciona todo en el editor y pega (Cmd+A / Cmd+V).
Después, la tarjeta. PokemonCardView.swift es la tarjeta de cada Pokémon: una vista con el degradado del tipo, la ilustración, el nombre y las etiquetas. Es largo y de maquetación, así que se copia igual: clic derecho sobre List → New File from Template… → Swift File, nombre PokemonCardView, Copiar fichero, seleccionar todo y pegar.
Con estos dos, Xcode no marca ningún error: la tarjeta ya encuentra sus colores.
Paso 2 · La pantalla, y quién la sostiene
El archivo. PokemonListView.swift es la pantalla: el título grande, la rejilla y el refresco.
Créalo. En Xcode, clic derecho sobre la carpeta List → New File from Template… → Swift File → Next, nombre PokemonListView → Create. (Plan B si ese menú no te lo ofrece: touch iosApp/iosApp/List/PokemonListView.swift en la Terminal, y si Xcode no lo muestra, cierra el proyecto y ábrelo de nuevo.) Son unas cien líneas, con los estados de carga y de error incluidos, y lo propio de KMP cabe en unas pocas: el fichero se copia entero (Copiar fichero, seleccionar todo, pegar con Cmd+A / Cmd+V) y a continuación se leen las piezas que importan.
Su cabecera monta las tres piezas: el dueño del ciclo de vida, el estado y el presentador.
struct PokemonListView: View {
@StateObject private var owner = IosViewModelStoreOwner()
@State private var estado = PagedListState(
items: [], cargando: true, cargandoMas: false, error: nil, errorAlAmpliar: nil
)
@State private var presenter: PokemonListPresenter?
private let columnas = [GridItem(.adaptive(minimum: 164), spacing: 12)]Fíjate en el valor inicial de estado (la razón completa de por qué los valores por defecto de Kotlin no cruzan está en la sección «La trampa: los argumentos por defecto no cruzan» de Corrutinas en commonMain): se construye en Swift, con cargando: true. Una data class de Kotlin llega al framework con un constructor que pide todos los parámetros: los = emptyList() y = false del lado de Kotlin no cruzan. (Esto tiene una consecuencia más adelante: cuando el estado gane un parámetro nuevo, sinConexion, en La lista sin red en iOS, este valor inicial tendrá que escribirlo también.)
Y el cuerpo. p.stateFlow y asyncSequence(for:) son el puente de Un Flow hasta Swift (ahí está el for try await; el sufijo …Flow, en Cancelar desde Swift). El modificador .task arranca una tarea (en Swift, el trabajo asíncrono que Kotlin llama corrutina) cuando la vista aparece y la cancela cuando desaparece (.task frente a .onAppear); por eso el catch de dentro no tiene nada que hacer. El .navigationBarTitleDisplayMode(.large) es nuevo: pide el título grande en vez del pequeño:
var body: some View {
NavigationStack {
contenido
.navigationTitle("Pokédex")
// El título grande que se encoge al hacer scroll es de iOS, y
// no es lo mismo que la barra grande de Material: aquí lo pone
// el sistema y hereda el aspecto de la versión instalada.
.navigationBarTitleDisplayMode(.large)
}
.task {
let p = presenter ?? ListIosKt.pokemonListPresenter(owner: owner)
presenter = p
do {
for try await nuevo in asyncSequence(for: p.stateFlow) {
estado = nuevo
}
} catch {
// Cancelar no es fallar: la vista desapareció.
}
}
}Compara con Android: allí el encogido de la barra lo gobierna un scrollBehavior que se crea a mano y se conecta con nestedScroll. Aquí son dos modificadores y lo hace el sistema, con el aspecto de la versión de iOS que tenga el usuario instalada. Ninguna de las dos formas es mejor; son dos sistemas con dos ideas de quién manda.
Paso 3 · Cómo se pide la página siguiente
En Compose no se pide: collectAsLazyPagingItems se entera solo de qué elementos entran en pantalla. En SwiftUI hay que decirlo.
LazyVGrid(columns: columnas, spacing: 12) {
ForEach(Array(estado.items.enumerated()), id: \.element.id) { indice, pokemon in
PokemonCardView(pokemon: pokemon)
// Así se pide la página siguiente: la vista dice por
// dónde va mirando y Paging decide si hace falta.
.onAppear { presenter?.loadAround(index: Int32(indice)) }
}
}
.padding(16)Para saber en qué posición está cada Pokémon hace falta enumerated(): recorre la lista dando parejas (posición, Pokémon). Array(…) las junta en una lista que ForEach sí sabe recorrer, y { indice, pokemon in … } pone nombre a cada mitad de la pareja. Por eso el identificador es \.element.id: element es el Pokémon dentro de la pareja, y de él se toma el id.
Provócalo tú. Borra la línea .onAppear { presenter?.loadAround(index: Int32(indice)) } de la tarjeta, ejecuta la app (Cmd+R) y baja por la rejilla. Verás sesenta Pokémon (el último es el N.º 60), y ni uno más por mucho que bajes.
Sesenta porque es lo que pide la primera carga: initialLoadSize vale tres veces pageSize por defecto, y pageSize es 20. Esa página llega sola, sin que nadie la pida, porque la dispara el propio Pager al empezar. A partir de ahí, silencio: Paging solo pide más cuando alguien accede a un elemento cercano al final de lo cargado, y el único que puede contárselo es la vista. Sin loadAround, Paging cree que nadie ha mirado nada desde el principio.
Lo que enseña el experimento: en iOS el prefetch (pedir antes de que haga falta) no es automático, es una obligación de la vista. En Compose, collectAsLazyPagingItems lo hace por dentro cuando pides pokemon[indice], y por eso pasa desapercibido. Aquí es una línea visible, y si se te olvida no hay error, no hay aviso y no hay traza: hay una lista que se acaba en el 60 y parece que PokeAPI solo tiene sesenta Pokémon. Es el mismo patrón que el mensajero de red de Coil de La lista en Android: los fallos caros de una interfaz no lanzan excepciones, enseñan algo plausible.
Devuelve la línea .onAppear { presenter?.loadAround(index: Int32(indice)) } a su sitio antes de seguir.
Paso 4 · Refrescar, que aquí es un modificador
// El gesto nativo de tirar para refrescar. En Android hay que
// envolver la lista en un componente; aquí es un modificador.
.refreshable { presenter?.refresh() }La cita habla de List; aquí el modificador va sobre un ScrollView, y hace lo mismo. Una línea contra un componente que envuelve. Es la diferencia de fondo entre los dos sistemas: Compose construye la interfaz anidando contenedores, SwiftUI la construye decorando vistas.
Paso 5 · La tarjeta, con imágenes que trae el sistema
PokemonCardView.swift, la tarjeta, ya la copiaste en el paso 1: aquí se lee por dentro. Lo que en Android necesitaba Coil y su mensajero, aquí viene puesto. Image(systemName:) pinta un símbolo de SF Symbols, la biblioteca de miles de iconos vectoriales que Apple incluye en el sistema (no hay que descargar ni añadir nada):
AsyncImage(url: URL(string: pokemon.artworkUrl)) { fase in
switch fase {
case .success(let imagen):
imagen.resizable().scaledToFit()
case .failure:
// SF Symbols: el vocabulario visual del sistema, que en
// Android no existe.
Image(systemName: "photo.badge.exclamationmark")
.font(.title2)
.foregroundStyle(.secondary)
default:
ProgressView()
}
}
.padding(14)Tres estados —cargando, éxito, fallo— en un switch, sin librería y sin dependencia nueva. Y el resto del acabado, también del sistema:
.background(.regularMaterial)
.clipShape(RoundedRectangle(cornerRadius: 22, style: .continuous))
// Una sola etiqueta para VoiceOver, en vez de leer número, nombre y
// tipos como cuatro cosas sueltas.
.accessibilityElement(children: .ignore)
.accessibilityLabel(
pokemon.types.isEmpty
? pokemon.name.capitalized
: "\(pokemon.name.capitalized), tipo \(pokemon.types.map(\.etiqueta).joined(separator: " y "))"
).regularMaterial es un material de Apple: se desenfoca y se adapta al fondo. La tipografía redondeada del nombre es esta línea, que está unas más arriba en el fichero:
.font(.system(.headline, design: .rounded, weight: .bold))Las otras líneas de ese bloque, una a una: .clipShape(RoundedRectangle(cornerRadius: 22, style: .continuous)) recorta la tarjeta con esquinas redondeadas de 22 puntos (.continuous es la curva suave que usa Apple en las esquinas de sus propias apps). .capitalized pone en mayúscula la primera letra de cada palabra: «bulbasaur» pasa a «Bulbasaur». pokemon.types.map(\.etiqueta) saca de cada tipo su nombre en castellano (la ruta \.etiqueta es la misma idea de key path que viste en Colecciones y key paths), y .joined(separator: " y ") los junta en un solo texto: «Planta y Veneno».
Con esa etiqueta, VoiceOver leería «Bulbasaur, tipo Planta y Veneno» de una vez. La accesibilidad se resuelve igual que en Android: una etiqueta por tarjeta, no cuatro sueltas.
Paso 6 · Los colores, otra vez
TypeStyle.swift, la gemela de TypeColors.kt, también está ya copiado: son dieciocho tipos en una tabla larga y repetitiva. Lo que importa de él:
/// El color y el nombre de cada tipo, para iOS.
///
/// Vive aquí y no en el módulo compartido a propósito: **el aspecto no se
/// comparte**. Android tiene su propia tabla, con sus propios colores, y las
/// dos son correctas. Lo que se comparte es el tipo.
extension PokemonType {
var tinte: Color {
switch name {
case "NORMAL": return Color(red: 0.56, green: 0.60, blue: 0.63)
case "FIGHTING": return Color(red: 0.81, green: 0.25, blue: 0.41)
// … hasta los dieciocho tipos
default: return .gray
}
}Fíjate en el switch name: se compara contra el nombre del caso del enum, no contra el caso (todo enum de Kotlin llega a Swift con una propiedad name que guarda ese nombre como texto: "NORMAL", "FIGHTING"…). Un enum de Kotlin llega a Swift como una clase con instancias, no como un enum de Swift, así que no hay exhaustividad y hace falta el default. Es exactamente el efecto de frontera que llevó a que UiError fuera una data class.
Por último, ContentView.swift (el que ya tenías). Hasta ahora nadie llama a la pantalla nueva, así que aquí es donde por fin se ve: sustituye todo su contenido por esto, import incluido, y ejecuta la app (Cmd+R) en el simulador:
import SwiftUI
struct ContentView: View {
var body: some View {
PokemonListView()
}
}
#Preview {
ContentView()
}Los dos import de más que tenía (Shared y KMPNativeCoroutinesAsync) se van: ya no se usan aquí, los usa PokemonListView.
Las cinco diferencias, juntas
| Android | iOS | |
|---|---|---|
| Imágenes de red | Coil más fetcher explícito | AsyncImage, en el sistema |
| Tirar para refrescar | componente que envuelve | modificador de una línea |
| Título que encoge | scrollBehavior a mano | lo hace el sistema |
| Color del tema | dinámico (Material You), del fondo de pantalla | el color de acento del sistema; solo los tipos llevan colores propios |
| Puente con Paging | regalado | escrito a mano (lección El puente de paginación para iOS) |
Esa última fila es el resumen del curso: lo que se comparte es la lógica, y el precio de la interfaz nativa se paga en el puente, una vez. Las otras cuatro filas son el premio: cada app se parece a su sistema, no a un punto medio que no se parece a nada.
Si la app fuera solo de iOS (no es un paso)
Esta sección es una lectura extra: no cambia nada de tu proyecto, no escribes nada en él y no entra en el punto de control. Responde a una duda razonable: «¿y si no hubiera Kotlin debajo?». Si vas justo de tiempo, sáltala; la lección ya está completa sin ella.
Apple no tiene una librería como Paging 3. En una app solo de iOS la paginación se hace a mano, con un modelo observable: una clase que guarda lo que la pantalla necesita y avisa cuando cambia. Hacen falta cuatro cosas:
- La lista de lo cargado hasta ahora.
cargando: una bandera que dice «ya hay una petición en marcha».hayMas: otra, que dice si quedan páginas por pedir.- Pedir la siguiente cuando aparecen las últimas filas (el mismo
.onAppeardel paso 3) y, con.refreshable, empezar de nuevo.
El ejemplo es una biblioteca de 45 libros que llegan de 20 en 20. pedirPagina simula la red con una espera de 200 milisegundos (Task.sleep). Es Swift puro: pégalo entero en tu Playground de Xcode (el de Tu primer paquete de Swift; borra antes lo que tuviera). Las últimas líneas hacen de pantalla: llaman al modelo como lo haría la lista. Ahí hay un await suelto, sin función alrededor, que el Playground admite; si tu Xcode lo marca en rojo, envuelve esas últimas líneas en Task { … }, como en async/await y Task.
import Observation
@MainActor @Observable
final class Biblioteca {
private(set) var libros: [String] = [] // lo que la pantalla pinta; solo el modelo lo cambia
private(set) var cargando = false // ¿hay una petición en curso?
private(set) var hayMas = true // ¿quedan páginas por pedir?
func cargarSiguiente() async {
guard !cargando, hayMas else { return } // si ya hay petición o no queda nada, salir
cargando = true // se marca ANTES de esperar
let nuevos = await pedirPagina(desde: libros.count)
libros += nuevos
hayMas = nuevos.count == 20 // una página corta era la última
cargando = false
}
func alAparecer(fila: Int) async { // lo que llamaría .onAppear de cada fila
if fila >= libros.count - 5 { await cargarSiguiente() } // quedan 5 o menos: pide
}
func reiniciar() async { // lo que llamaría .refreshable
libros = []
hayMas = true
await cargarSiguiente()
}
}
func pedirPagina(desde inicio: Int) async -> [String] { // la "red": 45 libros, 20 por página
print(" petición: libros desde el \(inicio + 1)")
try? await Task.sleep(for: .milliseconds(200))
let fin = min(inicio + 20, 45)
return inicio >= fin ? [] : (inicio..<fin).map { "Libro \($0 + 1)" }
}
// Desde aquí, la «pantalla».
let biblioteca = Biblioteca()
await biblioteca.cargarSiguiente() // la primera página
print("cargados: \(biblioteca.libros.count)")
// Las filas 17 y 18 aparecen a la vez. `async let` lanza una llamada sin esperarla,
// así que las dos están en marcha a la vez; `await` espera a las dos.
async let fila17: Void = biblioteca.alAparecer(fila: 17)
async let fila18: Void = biblioteca.alAparecer(fila: 18)
_ = await (fila17, fila18)
print("cargados: \(biblioteca.libros.count), hayMas: \(biblioteca.hayMas)")
await biblioteca.alAparecer(fila: 38) // llega al final de la segunda página
print("cargados: \(biblioteca.libros.count), hayMas: \(biblioteca.hayMas)")
await biblioteca.alAparecer(fila: 44) // ya no queda nada que pedir
print("cargados: \(biblioteca.libros.count)")
await biblioteca.reiniciar() // tirar para refrescar
print("tras reiniciar: \(biblioteca.libros.count)")Lo que quizá no reconozcas del ejemplo:
final class: una clase de la que nadie hereda; aquí es solo el modelo.try? await Task.sleep(…): espera 200 milisegundos; el?ignora el error por si cancelan la tarea (Errores: throws, try y do/catch).inicio >= fin ? [] : (…): el operador ternario, unifque devuelve un valor: lista vacía si no queda nada, y si no, los libros.(inicio..<fin).map { "Libro \($0 + 1)" }: recorre los números deiniciohastafin - 1y, por cada uno ($0), crea el texto «Libro N».async let fila17: Void = …y_ = await (fila17, fila18):async letlanza una llamada sin esperarla, así las dos filas piden a la vez (lo viste enasync let: varias tareas a la vez); elawaitfinal espera a las dos y_ =tira el resultado, que no hay.
Al ejecutarlo sale:
petición: libros desde el 1
cargados: 20
petición: libros desde el 21
cargados: 40, hayMas: true
petición: libros desde el 41
cargados: 45, hayMas: false
cargados: 45
petición: libros desde el 1
tras reiniciar: 20Dos cosas que mirar. La primera, las filas 17 y 18 que aparecen a la vez: una sola petición (desde el 21). La segunda fila se encuentra cargando en true y sale por el guard. La segunda, la tercera página: trae solo 5 libros (del 41 al 45), así que hayMas pasa a false y la llamada siguiente (alAparecer(fila: 44)) ya no pide nada.
Para ver para qué sirve la bandera, imagina que la línea del guard fuera solo guard hayMas else { return }, sin mirar cargando. Así saldría (no hace falta que lo ejecutes):
petición: libros desde el 1
cargados: 20
petición: libros desde el 21
petición: libros desde el 21
cargados: 60, hayMas: true
cargados: 60, hayMas: true
cargados: 60
petición: libros desde el 1
tras reiniciar: 20Mira la tercera y la cuarta línea: la misma petición, dos veces. Son 60 «libros» cuando solo hay 45: las dos respuestas se suman, con la mitad repetida.
En una app de verdad, la vista guardaría el modelo con @State private var biblioteca = Biblioteca() y lo conectaría así: .onAppear { Task { await biblioteca.alAparecer(fila: indice) } } en cada fila (Task { … } abre el trabajo asíncrono desde un sitio que no lo es) y .refreshable { await biblioteca.reiniciar() } en la lista. .refreshable espera a que termine esa función y entonces quita solo el indicador.
Qué te ahorra el puente de Kotlin. Todo lo de arriba (la lista, las dos banderas, la regla de «quedan cinco» y el reinicio) lo hace Paging dentro del presentador, una sola vez para las dos apps, y además cuenta los fallos y ofrece reintentar, que este modelo no hace. Swift solo cuenta por dónde va mirando (loadAround) y pinta el estado que le llega. En una app solo de iOS este modelo es tuyo, y hay que escribirlo, probarlo y añadirle el error y el reintento.
Punto de control
- La app arranca en el simulador y muestra la rejilla de dos columnas con ilustraciones.
- Al llegar al final de lo cargado entran veinte Pokémon más, sin repetidos.
- Tirando hacia abajo desde arriba se refresca.
- El título «Pokédex» se encoge al hacer scroll sin que hayas configurado ningún comportamiento.
- Sabes qué leería VoiceOver en cada tarjeta, «Bulbasaur, tipo Planta y Veneno», y dónde se escribe (
.accessibilityLabelenPokemonCardView.swift). No hace falta activarlo ahora: cómo se comprueba una descripción como esta lo verás más adelante, en Accesibilidad. - Si hiciste el experimento del acomodador, el
.onAppearestá de vuelta. - Los tests siguen igual que al terminar Probar el puente en iOS: 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.
Comprueba lo que entendiste
7 preguntas · necesitas 5 para completar la lección
1.Borras el
.onAppear { presenter?.loadAround(index:) }de la tarjeta. ¿Qué ve el usuario?2.¿Por qué la llamada es
loadAround(index: Int32(indice))y noloadAround(index: indice)?3.El estado inicial se construye en Swift pasando los cinco parámetros, aunque en Kotlin
PagedListStatelos tenga todos con valor por defecto. ¿Por qué?4.¿Qué necesita iOS para cargar las ilustraciones de PokeAPI?
5.
TypeStyle.swiftcompara conswitch namey necesita undefault. ¿Por qué no puede hacer unswitchexhaustivo sobrePokemonType?6.En el modelo observable de la biblioteca, dos filas del final aparecen a la vez y solo sale una petición. ¿Qué lo consigue?
7.En una app solo de iOS, ¿qué tendrías que escribir tú que en la Pokédex hace el presentador de Kotlin con Paging?