La lista sin red en iOS
Al terminar: Mostrar en SwiftUI el mismo aviso desde el estado compartido y probar el presentador.
Al terminar esta lección, la lista de iOS avisará igual que la de Android: cuando la red falle y haya Pokémon guardados, verás una franja con un icono de Wi-Fi tachado, «Sin conexión, mostrando lo guardado», y un botón «Reintentar». La lista no se tapa ni se vacía. Y lo hará sin escribir una sola regla nueva en Swift: la decisión de avisar la toma Kotlin, y Swift solo pinta.
Importa porque es el ejemplo más limpio de para qué sirve el código compartido: la regla «avisa si hay filas y el mediador ha fallado» se escribió una vez, en la lección anterior, y la usan las dos apps. Temas: una bandera nueva en el estado compartido, cómo la rellena el presentador, la franja en SwiftUI y un test más del presentador.
Los pasos de Kotlin (1 a 3) se hacen en Android Studio, y sus rutas son las de la vista Project, la que enseña las carpetas tal como están en el disco. Si el panel de la izquierda todavía dice «Android», cámbialo con el selector de arriba del panel. El paso de Swift (4) es en Xcode.
Cómo llega el aviso a Swift
En Android, la pantalla le pregunta a Paging directamente. En iOS no puede: Swift no habla con Paging. Todo lo que Swift sabe de la lista está en el PagedListState, así que la regla tiene que viajar dentro de ese estado, como una bandera más:
- En Kotlin,
PagedListStategana una propiedadsinConexion(verdadero o falso). - El presentador la rellena llamando a la misma
mostrandoLoGuardadode Android. - En Swift, la vista pinta la franja solo si
estado.sinConexiones verdadero.
Ahora la parte de Swift con un ejemplo ajeno a la Pokédex. Una vista de recetas recibe un estado ya plano (una lista y una bandera) y pinta la franja solo si la bandera está encendida. Es solo para leer: no lo pegues en tu proyecto.
import SwiftUI
// El estado que llega ya "plano": una lista y una bandera.
struct EstadoDeRecetas {
var recetas: [String]
var sinConexion: Bool // true: se ven recetas guardadas porque la red falló
}
struct RecetasView: View {
let estado: EstadoDeRecetas
let reintentar: () -> Void // lo que pasa al pulsar el botón, lo decide quien crea la vista
var body: some View {
ScrollView {
// Si la bandera está encendida, aparece la franja; si no, no ocupa nada.
if estado.sinConexion {
HStack(spacing: 12) {
// Label = icono + texto. wifi.slash es un SF Symbol: el Wi-Fi tachado.
Label("Sin conexión, mostrando lo guardado", systemImage: "wifi.slash")
.font(.subheadline)
.frame(maxWidth: .infinity, alignment: .leading) // empuja el botón a la derecha
Button("Reintentar", action: reintentar)
.font(.subheadline.weight(.semibold))
}
.padding(.horizontal, 14)
.padding(.vertical, 10)
// `.thinMaterial`: un fondo translúcido del sistema que se adapta solo al
// modo claro y al oscuro. Aquí, con las esquinas redondeadas.
.background(.thinMaterial, in: RoundedRectangle(cornerRadius: 12, style: .continuous))
.padding(.horizontal, 16)
}
LazyVStack {
ForEach(estado.recetas, id: \.self) { Text($0) }
}
}
}
}Con la bandera en true, la vista enseña la franja encima de las dos recetas; con false, solo las recetas. Es casi la forma de lo que viene en la Pokédex; la diferencia es dónde se coloca la franja, y se ve en el paso 4.
Paso 1 · La bandera, en el estado compartido
Qué. Se modifica PagedListState.kt, en commonMain de shared.
Abre PagedListState.kt y añade una propiedad al final de la lista de parámetros, con valor por defecto:
/** El error al pedir la pagina siguiente. La lista sigue ahi. */
val errorAlAmpliar: UiError? = null,
/** La red ha fallado y la lista ensena lo guardado: se avisa sin taparla. */
val sinConexion: Boolean = false,
) {
val vacia: Boolean get() = items.isEmpty() && !cargando && error == null
}Solo es nueva la propiedad sinConexion y su comentario; el resto del fichero no cambia. Va la última para no mover los argumentos de nadie que en Kotlin ya construya un PagedListState (en Swift, como verás enseguida, se rompe igual).
Paso 2 · El presentador rellena la bandera
Qué. Se modifica PokemonListPresenter.kt, en iosMain. En la función publicar, dentro de la construcción del PagedListState, añade una línea después de errorAlAmpliar:
errorAlAmpliar = (append as? LoadState.Error)?.error?.aAppError()?.aUiError(),
sinConexion = estados?.mostrandoLoGuardado(hayFilas = items.isNotEmpty()) ?: false,
)estados es el objeto con todos los estados de carga que el presentador ya leía, y puede ser null al principio (antes de que llegue el primero): por eso el ?. y el ?: false, «si no hay estados todavía, no avises». mostrandoLoGuardado está en el mismo paquete (feature.list) que el presentador, así que no hace falta ningún import.
Paso 3 · Un test más del presentador
Qué. Se modifica PokemonListPresenterTest.kt, en iosTest. Dos cambios.
Primero, el import de assertFalse, junto a los demás kotlin.test:
import kotlin.test.assertFalseSegundo, en el primer test (laPrimeraPaginaLlegaAlEstado), con red todo va bien y no debe haber aviso. Añade una línea antes de cancelAndIgnoreRemainingEvents():
assertEquals(estado.items.size, estado.items.map { it.id }.distinct().size, "hay repetidos")
assertFalse(estado.sinConexion)
cancelAndIgnoreRemainingEvents()Y el test nuevo, al final de la clase. Cuenta la historia completa en dos tiempos: primero con red (la base se llena), después sin red y con la copia caducada (el refresco falla). Esperado: la lista sigue ahí y la bandera se enciende.
@Test
fun sinRedPeroConLoGuardadoSeVeLaListaYElAviso() = runTest {
// Primero con red: la base se llena.
val conRed = presentador(total = 60, falla = false, scheduler = testScheduler)
conRed.state.test {
// Espera al primer estado con la lista llena, como el primer test.
var estado = awaitItem()
while (estado.items.isEmpty()) estado = awaitItem()
cancelAndIgnoreRemainingEvents()
}
conRed.close()
// Despues, sin red y con la copia caducada: el refresco falla.
// 24 horas en milisegundos: 24 * 60 minutos * 60 segundos * 1000.
reloj += 24 * 60 * 60 * 1000L
val sinRed = presentador(total = 60, falla = true, scheduler = testScheduler)
sinRed.state.test {
var estado = awaitItem()
while (!estado.sinConexion) estado = awaitItem()
assertEquals("pokemon-1", estado.items.first().name)
cancelAndIgnoreRemainingEvents()
}
apagar()
}reloj es la hora de mentira que ya usa el repositorio del test (la preparaste en Los tests del repositorio): sumarle 24 horas, más que las 12 de caducidad, hace que la copia parezca vieja y obliga a intentar la red, que en este segundo presentador falla a propósito (falla = true). Los dos while esperan, uno al primer estado con la lista llena y otro al primero con la bandera encendida, igual que los otros tests esperan al primero con datos. conRed.close() cierra el primer presentador a mitad del test, y apagar() (el ayudante de Los tests del repositorio) apaga todo al final, como en los otros tests. Los dos presentadores comparten la misma base en memoria (db, la del principio de la clase): por eso el segundo ve ya la lista que guardó el primero, aunque su red falle. Ejecútalo:
./gradlew :shared:iosSimulatorArm64TestDebe terminar en BUILD SUCCESSFUL con 43 tests en iOS: los 42 que quedaron en La lista sin red en Android más este (42 + 1 = 43; si añadiste ejercicios con tests, tu cifra será mayor). Este test solo existe en iosTest: la JVM y Android host no cambian.
Paso 4 · La franja en SwiftUI
Qué. Se modifica PokemonListView.swift. Ábrelo en Xcode (abre iosApp/iosApp.xcodeproj; en el navegador de proyecto, Cmd+1: iosApp → iosApp → List → PokemonListView.swift). Tres cambios.
1 · El estado inicial pasa el argumento nuevo (esto es lo que arregla el error del paso 2):
@State private var estado = PagedListState(
items: [], cargando: true, cargandoMas: false, error: nil, errorAlAmpliar: nil, sinConexion: false
)2 · La franja, fuera del ScrollView. Busca ScrollView { (Cmd+F) en el fichero: es la lista, y termina con la línea .refreshable { presenter?.refresh() }, que ya existe y se queda como está. Escribe lo siguiente justo debajo de ella, sin tocar nada de lo de dentro del ScrollView:
// Lo guardado se sigue viendo; el aviso solo cuenta por qué no
// está al día. Va fuera del ScrollView, pegado arriba: dentro se
// iría con el scroll y al llegar al final ya no se vería.
// `safeAreaInset` además aparta la lista para que no lo tape.
.safeAreaInset(edge: .top) {
if estado.sinConexion {
AvisoSinConexion { presenter?.retry() }
.padding(.horizontal, 16)
.padding(.vertical, 8)
}
}.safeAreaInset(edge: .top) reserva un hueco fijo arriba de la vista y pone ahí lo que le des; la lista se aparta para no quedar tapada. Como no forma parte de lo que se desplaza, la franja se queda pegada arriba aunque bajes hasta el final (por el mismo motivo que en Android la franja iba fuera de la rejilla). Cuando sinConexion es false, el if no pone nada y el hueco desaparece. presenter?.retry() es el reintento que ya usaba el pie de error.
3 · La vista de la franja, en el mismo fichero, justo encima de private struct ErrorView. Es la del ejemplo de las recetas, con el texto de la Pokédex:
/// Una franja discreta, con un SF Symbol y el botón de reintentar al lado.
private struct AvisoSinConexion: View {
let reintentar: () -> Void
var body: some View {
HStack(spacing: 12) {
Label("Sin conexión, mostrando lo guardado", systemImage: "wifi.slash")
.font(.subheadline)
.frame(maxWidth: .infinity, alignment: .leading)
Button("Reintentar", action: reintentar)
.font(.subheadline.weight(.semibold))
}
.padding(.horizontal, 14)
.padding(.vertical, 10)
.background(.thinMaterial, in: RoundedRectangle(cornerRadius: 12, style: .continuous))
}
}Es private: solo la usa esta pantalla. .thinMaterial es el fondo translúcido del ejemplo de las recetas; en Android hubo que elegir un color del tema.
Paso 5 · Comprobarlo
Compila una vez (Product → Build, Cmd+B) para que Xcode regenere Shared con la bandera nueva, y ejecuta en el simulador (Cmd+R) con red: espera a que la lista cargue.
Para quitar la red al simulador, desconecta la de tu Mac: apaga el Wi-Fi desde el icono de la barra de menús (o desenchufa el cable de red). El simulador usa la conexión del Mac. Antes de apagarlo, deja abierta esta página de la guía (y cárgala entera): sin Wi-Fi, no podrás abrir otra página. Después:
- Cierra la app y ábrela de nuevo: en Xcode, pulsa Stop (Cmd+.) y después Run (Cmd+R). La lista aparece al instante y sin franja. Es lo esperado: la copia tiene menos de 12 horas y la app ni siquiera intenta la red.
- Tira de la lista hacia abajo para refrescar. El círculo de refrescar se recoge enseguida, y la franja con el Wi-Fi tachado tarda unos segundos en aparecer. El círculo se va al instante porque
presenter?.refresh()encarga el refresco y vuelve sin esperar (en Dos fuentes: la pantalla y las pruebas el cierre sí esperaba a la red conawait); mientras tanto el cliente intenta la petición tres veces antes de rendirse. Cuando sale la franja, la lista no se vacía. - Baja hasta el final de lo guardado: la franja sigue arriba, fija. El mediador intenta pedir más, falla, y debajo de las tarjetas sale el aviso de error con su «Reintentar».
- Enciende el Wi-Fi y pulsa «Reintentar» (el de la franja o el del aviso de abajo hacen lo mismo). Con el refresco en error, «Reintentar» lanza solo el refresco y descarta la petición pendiente de la página siguiente: el mediador vuelve a pedir desde el principio, la franja desaparece y el aviso de abajo (el del punto 3 de esta lista) también.
Un matiz: si la copia tiene más de 12 horas y el refresco inicial al abrir falla, al bajar solo verás la franja, sin aviso debajo. La causa: Paging no pide más páginas mientras el refresco inicial del mediador no haya salido bien; si falla, queda esperando. Un refresco fallido posterior (el de tirar) no bloquea nada, porque ya hubo uno bueno. No hace falta que lo provoques: te pasará solo si abres la app sin red pasadas 12 horas desde la última copia; y es la misma regla y el mismo Paging que en Android.
Si el simulador no deja de tener red al apagar el Wi-Fi del Mac (depende de cómo estés conectado), no pasa nada: el test del paso 3 ya demuestra, sin simulador, que el presentador enciende la bandera cuando la red falla y hay lista guardada; lo que verás en pantalla es esa bandera dibujada.
Punto de control
./gradlew :shared:iosSimulatorArm64Testtermina enBUILD SUCCESSFULcon 43 tests (42 + 1:sinRedPeroConLoGuardadoSeVeLaListaYElAviso; si añadiste ejercicios con tests, tu cifra será mayor)../gradlew :shared:jvmTestsigue en 68 tests y./gradlew :shared:testAndroidHostTesten 40 (también con tu cifra si tienes ejercicios): esta lección no añade ninguno común.- Xcode compila sin el error
missing argument for parameter 'sinConexion'. - Sin red, tirar de la lista muestra la franja con «Sin conexión, mostrando lo guardado» y «Reintentar», fija arriba aunque bajes, y la lista no se vacía.
- Con la red de vuelta, «Reintentar» quita la franja.
Comprueba lo que entendiste
6 preguntas · necesitas 5 para completar la lección
1.¿Por qué la decisión de avisar viaja dentro de
PagedListStatey no se calcula en Swift?2.¿Dónde está escrita la regla que decide si se avisa, y quién la usa?
3.Añades
sinConexion: Boolean = falseaPagedListStatey Xcode da «missing argument for parameter». ¿Por qué?4.En el test nuevo, ¿para qué se suman 24 horas a
relojantes de crear el segundo presentador?5.La franja de iOS va con
.safeAreaInset(edge: .top), fuera delScrollView. ¿Qué se consigue con eso?6.En el presentador, ¿por qué
sinConexion = estados?.mostrandoLoGuardado(…) ?: false?