Saltar al contenido
K
La lista de PokémonPaso a paso48 min de lectura · 8 min de máquina Avanzado

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:

  • LazyVGrid es la rejilla perezosa: crea solo las celdas que se ven, como LazyVerticalGrid en Android. Las columnas se le dan con GridItem(.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).
  • .refreshable es la que ya conoces: el gesto de tirar hacia abajo para refrescar, como un modificador que se pone sobre la lista.
  • AsyncImage descarga 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 una Label, con Image(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):

ejemplo mínimo: un escaparate en SwiftUI (con comentarios)
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.

iosApp (Project navigator)
Listcarpeta nueva
TypeStyle.swiftpaso 1: colores y nombres, otra vez
PokemonCardView.swiftpaso 1: la tarjeta
PokemonListView.swiftpaso 2: la pantalla
ContentView.swiftse modifica: ahora solo llama a la lista
Tres ficheros de Swift nuevos. Ni uno de ellos toca Kotlin. Project navigator de Xcode.

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).

TypeStyle.swift

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.

PokemonCardView.swift

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.

PokemonListView.swift

Su cabecera monta las tres piezas: el dueño del ciclo de vida, el estado y el presentador.

iosApp/iosApp/List/PokemonListView.swift
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:

iosApp/iosApp/List/PokemonListView.swift
    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.

iosApp/iosApp/List/PokemonListView.swift
                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

iosApp/iosApp/List/PokemonListView.swift
            // 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):

iosApp/iosApp/List/PokemonCardView.swift
                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:

iosApp/iosApp/List/PokemonCardView.swift
        .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:

iosApp/iosApp/List/PokemonCardView.swift
                    .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:

iosApp/iosApp/List/TypeStyle.swift
/// 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:

iosApp/iosApp/ContentView.swift
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

AndroidiOS
Imágenes de redCoil más fetcher explícitoAsyncImage, en el sistema
Tirar para refrescarcomponente que envuelvemodificador de una línea
Título que encogescrollBehavior a manolo hace el sistema
Color del temadinámico (Material You), del fondo de pantallael color de acento del sistema; solo los tipos llevan colores propios
Puente con Pagingregaladoescrito 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 .onAppear del 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.

ejemplo mínimo: paginar a mano con un modelo observable (con comentarios)
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, un if que 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 de inicio hasta fin - 1 y, por cada uno ($0), crea el texto «Libro N».
  • async let fila17: Void = … y _ = await (fila17, fila18): async let lanza una llamada sin esperarla, así las dos filas piden a la vez (lo viste en async let: varias tareas a la vez); el await final 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: 20

Dos 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: 20

Mira 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 (.accessibilityLabel en PokemonCardView.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 .onAppear está 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. 1.Borras el .onAppear { presenter?.loadAround(index:) } de la tarjeta. ¿Qué ve el usuario?

  2. 2.¿Por qué la llamada es loadAround(index: Int32(indice)) y no loadAround(index: indice)?

  3. 3.El estado inicial se construye en Swift pasando los cinco parámetros, aunque en Kotlin PagedListState los tenga todos con valor por defecto. ¿Por qué?

  4. 4.¿Qué necesita iOS para cargar las ilustraciones de PokeAPI?

  5. 5.TypeStyle.swift compara con switch name y necesita un default. ¿Por qué no puede hacer un switch exhaustivo sobre PokemonType?

  6. 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. 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?

Faltan 7 de 7