La puerta y la cuenta en iOS
Al terminar: Montar la misma raíz en SwiftUI y aplicar el deep link pendiente después del login.
El formulario ya existe y nadie lo llama. Esta lección le pone la puerta: un guardián en la raíz de la app que decide qué se construye, la sección «Cuenta» en Ajustes con su diálogo para cerrar sesión, y un detalle fino que solo aparece cuando junto a la puerta hay una notificación.
Al terminar, la app de iOS arranca en el formulario si hay cuentas configuradas, y solo con sesión se construyen las pestañas. Sin cuentas, entra directa, como antes. Un toque en el aviso del Pokémon del día hecho antes de entrar se atiende al entrar. Y en Ajustes hay una sección «Cuenta» con el correo y «Cerrar sesión», que pide confirmación.
No se instala nada y no se toca shared. Todo se hace en Xcode, con iosApp/iosApp.xcodeproj abierto.
Lo nuevo, con un ejemplo ajeno al proyecto
El guardián es una vista que envuelve a otra: recibe la app entera como argumento y decide si la deja ver. Para escribirlo hacen falta tres piezas.
@ViewBuilder let contenido: () -> Contenidoguarda una función que construye la vista de dentro.@ViewBuilderpermite pasarla entre llaves al crear el guardián, como pasas el contenido a unVStack { … }. La función se llama (contenido()) solo cuando se quiere construir lo de dentro: mientras no se llama, esa vista no existe..transition(.opacity)dice cómo entra y sale una vista cuando aparece o desaparece: con.opacity, se funde..animation(.default, value: x)anima los cambios que provocax: cada vez quexcambia, lo que depende de él se mueve con la animación de siempre en vez de saltar.
Un portero de gimnasio, sin nada de Pokémon: envuelve una pantalla y solo la construye si hay carné.
import SwiftUI
// Un portero de gimnasio: envuelve cualquier pantalla y decide si la deja ver.
struct PorteroDelGimnasio<Contenido: View>: View {
// Lo que hay dentro del gimnasio. Se pasa entre llaves al crear el portero.
@ViewBuilder let contenido: () -> Contenido
// Tres estados posibles: mirando el carné, con carné, sin carné.
@State private var mirandoCarne = true
@State private var tieneCarne = false
var body: some View {
Group {
if mirandoCarne {
ProgressView()
} else if tieneCarne {
contenido() // aquí, y solo aquí, se construye lo de dentro
} else {
Button("Enseñar carné") { tieneCarne = true }
.transition(.opacity) // al aparecer o desaparecer, se funde
}
}
// Cada vez que `tieneCarne` cambia, el cambio se anima.
.animation(.default, value: tieneCarne)
.task {
// Aquí se miraría el carné guardado; este ejemplo lo da por no encontrado.
mirandoCarne = false
}
}
}
struct AppDelGimnasio: View {
var body: some View {
PorteroDelGimnasio {
Text("Sala de pesas") // esto es `contenido`: no se construye sin carné
}
}
}
#Preview { AppDelGimnasio() }Lo que hace: al abrirlo, una ruedecita un instante (está «mirando el carné»); como no hay carné, enseña el botón «Enseñar carné». Al pulsarlo, el botón se funde y aparece «Sala de pesas». Group es una caja sin aspecto propio que solo sirve para agrupar las ramas del if y colgarles modificadores a todas a la vez. En ningún momento hay dos pantallas construidas: o ruedecita, o botón, o la sala.
Paso 1 · El guardián, en la raíz
El archivo. RaizConSesion.swift es el gemelo del guardián de Android: envuelve la app y decide qué se construye. Mientras no haya sesión, TabView no se construye: no hay pestaña que alcanzar, ni enlace profundo que la abra, ni pila que restaurar. Proteger pantalla por pantalla dejaría siempre alguna fuera. Créalo en la carpeta Auth que hiciste en la lección anterior: clic derecho sobre Auth → New File from Template… → Swift File → RaizConSesion (plan B: touch iosApp/iosApp/Auth/RaizConSesion.swift).
import SwiftUI
import Shared
import KMPNativeCoroutinesAsync
/// El guardián, en la raíz y no dentro de la navegación.
///
/// Mientras no haya sesión, `TabView` **no se construye**: no hay pestaña que
/// alcanzar, ni enlace profundo que la abra, ni pila que restaurar. Proteger
/// pantalla por pantalla dejaría siempre alguna fuera.
struct RaizConSesion<Contenido: View>: View {
@ViewBuilder let contenido: () -> Contenido
@StateObject private var owner = IosViewModelStoreOwner()
@State private var viewModel: AuthViewModel?
@State private var estado = AuthState(comprobando: true, session: nil)
/// Se calcula una sola vez: la configuración no cambia mientras la app vive.
private let hayCuentas = AuthIosKt.hayCuentas()
var body: some View {
Group {
if !hayCuentas {
// Sin Supabase configurado la app va directa.
contenido()
} else if estado.comprobando {
ProgressView()
.controlSize(.large)
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else if estado.haySesion {
contenido()
} else if let viewModel { // `if let viewModel` = «si ya hay ViewModel», y lo llama igual dentro
AuthView(viewModel: viewModel)
// La transición la pinta el sistema: entrar no debe ser un
// corte seco entre dos pantallas.
.transition(.opacity)
}
}
.animation(.default, value: estado.haySesion)
.task {
guard hayCuentas else { return }
let vm = viewModel ?? AuthIosKt.authViewModel(owner: owner)
viewModel = vm
estado = vm.authStateForIos
do {
for try await nuevo in asyncSequence(for: vm.authStateForIosFlow) {
estado = nuevo
}
} catch { }
}
}
}De arriba abajo:
- Las propiedades.
owneres el dueño del almacén de ViewModels (el de El mismo ViewModel en iOS).viewModelempieza vacío (nil) porque el ViewModel llega después, desde la.task.estadotiene los dos datos de la sesión: si todavía se está leyendo el token guardado (comprobando) y, si ya se sabe, cuál es (session).AuthState(comprobando: true, session: nil)lleva todos los argumentos porque los valores por defecto de Kotlin no cruzan a Swift. hayCuentas, una constante que se calcula una vez: la configuración no cambia mientras la app vive.else if let viewModeles la forma corta deif let viewModel = viewModel: abre el opcional (vacío al principio) y, si ya tiene valor, lo deja disponible con el mismo nombre (Opcionales).- Las cuatro ramas del
if: sin cuentas, la app directa; comprobando, una ruedecita; con sesión, la app; y si no, el formulario. Solo dos de ellas llaman acontenido(): mientras no hay sesión (y sí hay cuentas), elTabViewni se construye. .taskarranca al aparecer la vista y se cancela al desaparecer. Crea el ViewModel una vez y lo reutiliza (viewModel ?? …), toma el valor actual primero, para no pintar un estado viejo, y después escucha cada cambio. Elcatch { }vacío es a propósito: elfor try awaittermina con un error cuando se cancela la tarea, y eso no es un fallo.
Dos líneas (.transition y .animation) y la app se funde de una pantalla a otra al entrar. En el formulario de Android, aparecer y desaparecer con animación lo hiciste con AnimatedVisibility; aquí son dos modificadores sueltos.
Colócalo. Abre iOSApp.swift y envuelve ContentView() dentro de WindowGroup:
WindowGroup {
ContentView()
} WindowGroup {
// Sin sesión no se compone nada de la app: ni pestañas, ni pilas.
RaizConSesion {
ContentView()
}
}Cmd+B: compila. Pero hay un efecto que no se ve en la compilación y que ahora toca arreglar.
Paso 2 · Un toque que llega antes de entrar
Mira lo que acabas de hacer: ContentView ya no existe siempre. Con cuentas configuradas y sin sesión, no se construye. Y ContentView es quien escucha dos cosas que llegan de fuera: los enlaces pokedex:// y el toque en el aviso del Pokémon del día.
Un enlace pokedex:// sin sesión se pierde. Los atiende .onOpenURL (Deep links en iOS), que está en ContentView. Mientras no haya sesión, ContentView ni se construye: el sistema entrega el enlace a una app que todavía no tiene quién lo escuche. Entras después y ves la lista, no la ficha. En Android el enlace espera en la actividad (La puerta y la cuenta en Android) y se recoge al entrar: es una diferencia real entre las dos apps.
Un toque en el aviso, en cambio, no tiene por qué perderse. El toque no va a .onOpenURL, sino al delegado de las notificaciones (Del aviso a la ficha, en iOS), y ese delegado se instala en iOSApp, antes de que exista ninguna pantalla. Así que lo recibe aunque no haya sesión: apunta el enlace en enlacePendiente y se queda esperando. El problema es qué pasa después.
En la lección del delegado el .onChange(of: notificaciones.enlacePendiente) no llevaba initial: true, y no hacía falta: ContentView existía siempre, así que veía llegar el enlace. Ahora puede aparecer después de que el enlace llegara. El valor ya está puesto, no cambia, y el .onChange no se entera. El aviso se tocó y la ficha no se abre; peor, el aviso del Pokémon del día es siempre el mismo, así que un segundo toque tampoco cambia el valor y tampoco hace nada.
Lo mismo en pequeño, con un buzón:
import SwiftUI
// Un buzón compartido: alguien deja una carta y la vista que mira lo lee.
// `ObservableObject` y `@Published`: cada vez que `carta` cambia, las vistas
// que miran el buzón se redibujan. `static let compartido` es UN buzón único
// al que se llega desde cualquier sitio con `Buzon.compartido`.
final class Buzon: ObservableObject {
static let compartido = Buzon()
@Published var carta: String?
}
// Esta vista solo existe cuando `abierto` es verdadero.
struct Lector: View {
@StateObject private var buzon = Buzon.compartido
@State private var leidas: [String] = []
var body: some View {
// `@StateObject` (arriba) hace que la vista guarde el buzón y se entere
// de sus cambios.
List(leidas, id: \.self) { Text($0) }
// Sin `initial: true`, solo se entera de las cartas que lleguen DESPUÉS
// de aparecer. Con él, mira también la que ya estaba esperando.
.onChange(of: buzon.carta, initial: true) { _, carta in
guard let carta else { return }
leidas.append(carta)
buzon.carta = nil // ya se leyó
}
}
}
struct Casa: View {
@State private var abierto = false
var body: some View {
VStack {
Button("Dejar una carta") { Buzon.compartido.carta = "Hola" }
Button("Abrir la puerta") { abierto = true }
if abierto { Lector() }
}
}
}Para probarlo, crea un fichero Swift aparte (como en la lección anterior), pega el ejemplo y añade al final #Preview { Casa() } para verlo en el lienzo (Editor → Canvas). Bórralo al terminar. Pulsa «Dejar una carta» y después «Abrir la puerta»: con initial: true la lista enseña «Hola» nada más aparecer; sin él, la lista saldría vacía y la carta se quedaría en el buzón. La puerta de la Pokédex es esa Casa, y el buzón es el delegado de las notificaciones.
El arreglo. Abre ContentView.swift. El observador del enlace, el que escribiste en la lección del delegado, pasa de esto:
.onChange(of: notificaciones.enlacePendiente) { _, url ina esto:
// `initial: true`: si el toque llegó antes de entrar (sin sesión esta vista
// aún no existía), el delegado lo guardó y se atiende al aparecer.
.onChange(of: notificaciones.enlacePendiente, initial: true) { _, url inDeja el comentario que ya tenías encima del .onChange y añade las dos líneas nuevas justo debajo de él. El cuerpo del .onChange no cambia: guard let url, abrir(url) y notificaciones.enlacePendiente = nil. Con initial: true, al construirse ContentView tras entrar, lo primero que hace es mirar si había un enlace esperando; si lo hay, abre la ficha y lo apaga, y si no, el guard lo para sin hacer nada.
Paso 3 · Cerrar sesión se confirma con un diálogo del sistema
El archivo. SettingsView.swift es la pantalla de Ajustes en iOS. Necesita tres cosas: estado para la sesión, una sección «Cuenta» dentro del Form y el diálogo de confirmación. Ábrelo (en Xcode, Cmd+Shift+O y escribe SettingsView) y haz cada cambio en su sitio; al final hay un esqueleto del fichero que muestra dónde queda cada cosa. El owner (el dueño del almacén de ViewModels) ya existe en esa pantalla desde Ajustes en iOS: se reutiliza.
Primero, estado, debajo de @Environment(\.openURL) private var openURL:
// El ViewModel de la sesión y la sesión actual (nil si no hay nadie).
@State private var auth: AuthViewModel?
@State private var sesion: Session?
// Si el diálogo de «¿Cerrar la sesión?» está abierto.
@State private var confirmandoSalida = falseDespués, la sección, dentro del Form, justo encima de Section("Acerca de"). En Android la misma información vive en una hoja inferior con un avatar y un botón; aquí es una sección más de una lista, porque en iOS los ajustes son una lista y meterle una hoja modal encima chirría.
// Sin sesión no hay sección. `if let` desenvuelve el opcional.
if let sesion {
Section {
LabeledContent("Correo", value: sesion.email ?? "Sesión iniciada")
Button("Cerrar sesión", role: .destructive) {
confirmandoSalida = true
}
} header: {
Text("Cuenta")
} footer: {
Text("Tus favoritos se guardan en este dispositivo y seguirán aquí cuando vuelvas a entrar.")
}
}Y el diálogo. Va justo debajo de .navigationTitle("Ajustes"), dentro del NavigationStack:
// El diálogo de confirmación de iOS sube desde abajo y marca en
// rojo la acción destructiva. Es el equivalente de la hoja inferior
// de Android, y ninguno de los dos se parece al otro.
.confirmationDialog(
"¿Cerrar la sesión?",
isPresented: $confirmandoSalida,
titleVisibility: .visible
) {
Button("Cerrar sesión", role: .destructive) { auth?.salir() }
Button("Cancelar", role: .cancel) { }
} message: {
Text("Tendrás que volver a entrar para usar la app.")
}Por último, la .task que alimenta la sesión. Va después de la llave que cierra el NavigationStack y antes de las .task que ya tenías:
// Una `.task` más, para la sesión (las de antes ya existen).
.task {
// Sin cuentas configuradas no hay sesión que mirar. `hayCuentas()`
// es la función de AuthIos.kt de la lección anterior: barata, se
// puede llamar donde haga falta.
guard AuthIosKt.hayCuentas() else { return }
let vm = auth ?? AuthIosKt.authViewModel(owner: owner)
auth = vm
sesion = vm.authStateForIos.session
do {
for try await nuevo in asyncSequence(for: vm.authStateForIosFlow) {
sesion = nuevo.session
}
} catch {
// Cancelada al desaparecer la vista: no es un fallo.
}
}Fíjate en que esta pantalla pide su propio AuthViewModel (con el owner que ya tenía y la misma fábrica que la raíz), pero eso no son dos copias de la sesión: los dos ViewModel se alimentan del mismo SessionRepository de Kotlin, el dueño único. Cuando el diálogo llama a auth?.salir(), la sesión cambia en Kotlin, la raíz se entera, y el guardián vuelve a enseñar el formulario con su fundido, sin que Ajustes tenga que avisar a nadie.
El esqueleto del fichero con todo en su sitio (… es lo que ya tenías, intacto):
struct SettingsView: View {
@StateObject private var owner = IosViewModelStoreOwner()
… // tus @State de antes
@Environment(\.openURL) private var openURL // de antes
@State private var auth: AuthViewModel? // nuevo
@State private var sesion: Session? // nuevo
@State private var confirmandoSalida = false // nuevo
var body: some View {
NavigationStack {
Form {
… // las secciones de antes
if let sesion { Section { … } header: { … } footer: { … } } // nuevo
Section("Acerca de") { … }
}
.navigationTitle("Ajustes")
.confirmationDialog( … ) { … } message: { … } // nuevo
}
.task { … la sesión … } // nuevo
.task { … } // las que ya tenías
}
}Paso 4 · Las dos pantallas, una al lado de la otra
| Android | iOS | |
|---|---|---|
| Elegir entrar o registrarse | SingleChoiceSegmentedButtonRow | Picker con .segmented |
| El error de un campo | supportingText bajo el campo | un Text rojo bajo el campo |
| El error de la acción | aviso en línea, encima del botón | .alert |
| La cuenta | ModalBottomSheet | sección de Form |
| Cerrar sesión | el botón de la hoja sale al instante | .confirmationDialog, que pide confirmación |
| Contraseña guardada | — | .textContentType, y el sistema la ofrece |
| Detalle propio | — | .symbolEffect(.bounce) al cambiar de modo |
| Teclado que no tapa | imePadding() | lo hace el sistema |
Ninguna columna es mejor. Lo que sería peor es una sola columna que intentara ser las dos.
Pruébalo
Compila (Cmd+B) y ejecuta (Cmd+R).
Sin cuentas. Con Config.xcconfig vacío (Configuración y secretos por entorno) la app entra directa a las pestañas, como antes.
Con cuentas. Pon tus dos valores en Config.xcconfig (acuérdate de escapar las barras de la URL, https:/$()/…) y ejecuta de nuevo: la app arranca en el formulario y no hay forma de llegar al TabView. Prueba el selector (el símbolo rebota y el botón cambia de nombre). Escribe una letra en el correo y bórrala: sale «Escribe tu correo» en rojo bajo el campo. Escribe a en el correo y 1 en la contraseña: los errores son «Escribe un correo completo, como nombre@correo.com» y «Al menos 6 caracteres», y el botón sigue gris. Crea una cuenta con «Crear cuenta»: pasas a las pestañas con el fundido.
La sesión sobrevive a cerrar la app. Detén la app (Stop, o Cmd+.) y ejecútala otra vez (Cmd+R). No ves el formulario: entras directo a las pestañas, porque restaurar() encontró la sesión en el Llavero.
Cerrar sesión. En Ajustes, la sección «Cuenta» enseña tu correo. «Cerrar sesión» sube el diálogo; para cerrarlo sin hacer nada, toca fuera del diálogo (en iOS 26 el botón «Cancelar» no se dibuja, aunque está en el código; en versiones anteriores sí sale). Su botón rojo «Cerrar sesión» te devuelve al formulario con un fundido.
El Llavero sobrevive a desinstalar. Con la sesión abierta, borra la app del simulador: en la Terminal, xcrun simctl uninstall booted <tu identificador> (booted es «el simulador que tengas abierto»; el identificador es el de tu app: en Xcode, selecciona el proyecto → tu target → General → Bundle Identifier, y ponlo en lugar de <tu identificador>; el tuyo puede llevar un añadido, como se avisó en Ejecutar en iOS). Vuelve a ejecutar desde Xcode (Cmd+R), que la instala de nuevo desde cero. Entras directo a las pestañas: el directorio de la app se borró, pero el Llavero no, tal como avisaba Guardar los tokens. En Android, según esa misma lección, desinstalar borra el directorio privado entero: la misma prueba te devuelve al formulario.
El toque sin sesión. Lo único que no tienes cómo provocar a mano es el de initial: true: la prueba temporal de 20 segundos la quitaste en la lección del delegado, y ya no hay botón. Lo tienes en el ejemplo del buzón, que sí puedes probar en un proyecto aparte, y en la cuarta pregunta del cuestionario.
Punto de control
- Con
Config.xcconfigvacío, la app entra directa a las pestañas. - Con valores puestos, arranca en el formulario y no hay forma de llegar al
TabView. - Tras crear una cuenta, detener la app y volver a ejecutarla, entras directo a las pestañas.
- Tras desinstalar la app del simulador y volver a ejecutarla desde Xcode, sigues dentro.
- El selector cambia la etiqueta del botón y el símbolo rebota; el error de cada campo sale en rojo debajo de él; un fallo del servidor abre una alerta, no un aviso en línea.
- En Ajustes hay una sección «Cuenta», y «Cerrar sesión» sube un diálogo de confirmación.
- El
.onChangedel enlace deContentViewllevainitial: true. - La compilación (Cmd+B) termina sin errores. Los informes de tests siguen en 117, 79 y 82: la lección no añade ninguno.
Comprueba lo que entendiste
6 preguntas · necesitas 5 para completar la lección
1.Sin sesión, ¿qué pasa con el
TabViewde la app?2.En
RaizConSesion, ¿por quécontenidoes una función (() -> Contenido) marcada con@ViewBuildery no la vista ya creada?3.El error de la acción se enseña con
.alerten iOS y con un aviso en línea en Android. ¿Qué dice eso del diseño de la guía?4.Tocas el aviso del Pokémon del día sin haber entrado. Tras entrar, ¿por qué se abre la ficha?
5.Llega un enlace
pokedex://pokemon/25con la app cerrada y sin sesión. ¿Qué ocurre?6.¿Por qué «Cerrar sesión» se confirma con
.confirmationDialogen iOS y con un botón de la hoja de la cuenta en Android?