Saltar al contenido
K
Autenticación con SupabasePaso a paso28 min Avanzado

El formulario de entrada en iOS

Al terminar: Escribir en SwiftUI el formulario de entrada, con los mismos errores por campo.

La misma puerta, otra vez, y no se parece en nada. Esta lección es la mejor demostración de por qué la guía no comparte la interfaz: dos pantallas que hacen exactamente lo mismo, escritas con vocabularios distintos, y las dos correctas en su sistema.

Al terminar tendrás en iOS el formulario de entrada escrito por ti: un selector «Entrar» / «Crear cuenta», un campo de correo, un campo de contraseña que no enseña lo que escribes, un botón, el error de cada campo en rojo debajo de él y una alerta para los fallos del servidor. Todavía nadie lo llama: la lección siguiente lo pone a la entrada de la app. Por eso aquí no se ve en pantalla, y se comprueba compilando (Cmd+B).

Nada de lo que decide el formulario se escribe otra vez. El AuthViewModel y el AuthFormState son los de Kotlin que escribiste en El formulario de entrada en Android: ellos guardan lo escrito, saben qué campos están tocados y calculan los errores. La pantalla de iOS solo los dibuja. No se instala nada: todo lo de Swift que se usa viene con el sistema.

shared
src/iosMain/kotlin/com/sebastiannarvaez/pokedex/feature/auth
AuthIos.ktse crea (paso 1)
iosApp/iosApp
Authcarpeta nueva
AuthView.swiftse crea (paso 2)
Qué se crea. `shared` y `iosApp` con las carpetas reales del disco (vista Project).

Lo nuevo, con un ejemplo ajeno al proyecto

El formulario junta cuatro piezas de SwiftUI que no habías usado. Primero cada una en una frase, y después las cuatro juntas en un ejemplo que no tiene nada que ver con Pokémon: la entrada a un club de lectura.

  • SecureField es un TextField que pinta puntos en vez de letras: lo que se teclea no se ve ni sale en las capturas de pantalla. Se usa para las contraseñas.
  • Binding(get:set:) es un enlace que montas tú a mano. Ya conoces el $texto que da SwiftUI (@Binding): lee y escribe un @State. Aquí el texto no vive en el @State de la vista, sino en otra parte, y el enlace dice con dos cortas funciones cómo leerlo (get) y cómo escribirlo (set; el valor nuevo llega como $0). El campo nunca guarda el texto por su cuenta: lo que ves es lo que dice la fuente.
  • .alert(…) es el aviso del sistema que aparece en el centro de la pantalla y obliga a contestar. Se muestra cuando un Bool pasa a verdadero, como el .confirmationDialog de Favoritos en iOS. Con presenting: recibe además el dato que va a enseñar, y ese dato llega a las llaves del final.
  • .symbolEffect(.bounce, value: x) anima un símbolo del sistema (los iconos de Image(systemName:)): con value: lo repite cada vez que x cambia.

Y los tres modificadores del campo de correo: .keyboardType(.emailAddress) saca el teclado con la arroba y el punto a mano, .textInputAutocapitalization(.never) quita la mayúscula automática (la viste con .words en el formulario de Marcador) y .autocorrectionDisabled() apaga el corrector, que convertiría un correo en palabras del diccionario.

ejemplo mínimo: la entrada a un club de lectura (con comentarios)
import SwiftUI
 
// La ficha del socio: lo escrito y si el apodo ya está tocado.
struct FichaDeSocio {
    var apodo = ""
    var clave = ""
    var apodoTocado = false
    var fallo: String? = nil
 
    // El error se calcula cada vez, no se guarda; y solo se ve si el campo está tocado.
    var errorApodo: String? {
        apodoTocado && apodo.isEmpty ? "Escribe un apodo" : nil
    }
}
 
struct EntradaAlClubView: View {
    @State private var ficha = FichaDeSocio()
    @State private var mostrandoFallo = false   // ¿está abierta la alerta?
    @State private var intentos = 0
 
    var body: some View {
        VStack(spacing: 12) {
            Image(systemName: "book.closed")
                .font(.system(size: 48))
                .symbolEffect(.bounce, value: intentos)   // rebota cada vez que `intentos` cambia
 
            TextField("Apodo", text: Binding(
                get: { ficha.apodo },                           // leer: lo que dice la ficha
                set: { ficha.apodo = $0; ficha.apodoTocado = true }   // escribir: guardarlo y marcarlo tocado
            ))
            .keyboardType(.emailAddress)
            .textInputAutocapitalization(.never)
            .autocorrectionDisabled()
 
            // El error, debajo del campo; si no hay error, no se pinta nada.
            if let error = ficha.errorApodo {
                Text(error).font(.caption).foregroundStyle(.red)
            }
 
            SecureField("Clave", text: Binding(     // puntos en vez de letras
                get: { ficha.clave },
                set: { ficha.clave = $0 }
            ))
 
            Button("Entrar") {
                intentos += 1
                ficha.fallo = "La clave no es la del club"
                mostrandoFallo = true                  // abre la alerta
            }
        }
        .textFieldStyle(.roundedBorder)
        .padding()
        // `presenting:` pasa `ficha.fallo` a las llaves del final, como `texto`.
        .alert(
            "No se pudo entrar",
            isPresented: $mostrandoFallo,
            presenting: ficha.fallo
        ) { _ in   // la `_` es «aquí llega el dato, pero no lo uso»
            Button("Entendido", role: .cancel) { }   // el botón que cierra la alerta
        } message: { texto in
            Text(texto)
        }
    }
}
 
#Preview { EntradaAlClubView() }

Si quieres verlo funcionar, no hace falta que sea en la app: crea un fichero Swift nuevo en Xcode (clic derecho sobre la carpeta de la app → New File from Template… → Swift File), pega el ejemplo y abre la vista previa en el lienzo (Editor → Canvas, o Option+Cmd+Return; la línea #Preview es la que la dibuja). Bórralo al terminar.

Lo que hace, según el código: al abrirlo no hay ningún error a la vista. Al escribir una letra en «Apodo» el campo queda tocado y no pasa nada; al borrarla sale «Escribe un apodo» en rojo debajo. Lo que escribas en «Clave» se ve como puntos. Al pulsar «Entrar» el símbolo rebota y sube la alerta «No se pudo entrar», con «La clave no es la del club» debajo; «Entendido» la cierra.

Fíjate en que el Binding del apodo hace dos cosas al escribir: guarda el texto y marca el campo como tocado. En el formulario de la Pokédex ese «marcar» no se hace en la pantalla: lo hace Kotlin, dentro de escribirEmail. La pantalla solo le pasa cada letra.

Paso 1 · Lo que Swift necesita del núcleo

El archivo. AuthIos.kt vive en iosMain y es lo único de Kotlin que toca esta lección. Hace lo que hicieron ListIos o SettingsIos: dar a Swift una forma de crear el ViewModel y de leer sus flujos. Créalo con clic derecho sobre el módulo shared → New → File y la ruta:

src/iosMain/kotlin/com/sebastiannarvaez/pokedex/feature/auth/AuthIos.kt

El concepto nuevo es una función suelta, hayCuentas(). No la usa AuthView, sino la raíz de la app y Ajustes (las dos, en la lección siguiente): necesitan saber si hay cuentas configuradas, para no pedir sesión cuando no la hay. Podría leer AppConfig del grafo de Koin, pero eso obligaría a exponer Koin a Swift; una función basta y deja la librería de inyección donde estaba.

El resto repite el patrón de dos lecciones: la fábrica con el dueño del almacén (El mismo ViewModel en iOS) y dos propiedades …ForIos para los flujos (Cancelar desde Swift, donde está explicado el sufijo …Flow que genera la librería). Escríbelo:

shared/src/iosMain/kotlin/com/sebastiannarvaez/pokedex/feature/auth/AuthIos.kt
package com.sebastiannarvaez.pokedex.feature.auth
 
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.ViewModelStoreOwner
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import com.rickclephas.kmp.nativecoroutines.NativeCoroutinesState
import com.sebastiannarvaez.pokedex.core.AppConfig
import kotlinx.coroutines.flow.StateFlow
import org.koin.mp.KoinPlatform
 
// Crea el AuthViewModel dentro del almacén del dueño que le pasa Swift
// (IosViewModelStoreOwner). Es la misma receta que HomeIos, ListIos o
// SettingsIos: Koin lo construye y el almacén decide cuándo muere.
fun authViewModel(owner: ViewModelStoreOwner): AuthViewModel {
    val koin = KoinPlatform.getKoin()
    return ViewModelProvider.create(
        owner = owner,
        factory = viewModelFactory { initializer { koin.get<AuthViewModel>() } },
    )[AuthViewModel::class]
}
 
// Quién eres, para Swift. Propiedad de extensión y no la del ViewModel: la
// anotación solo existe en iosMain, y exige una propiedad (no una función).
@NativeCoroutinesState
val AuthViewModel.authStateForIos: StateFlow<AuthState>
    get() = state
 
// Lo que se está escribiendo, para Swift.
@NativeCoroutinesState
val AuthViewModel.authFormForIos: StateFlow<AuthFormState>
    get() = form
 
// ¿Hay que pedir cuenta? Swift podría leer `AppConfig` del grafo, pero eso
// obligaría a exponer Koin al otro lado. Una función basta y deja la librería
// de inyección donde estaba.
fun hayCuentas(): Boolean = KoinPlatform.getKoin().get<AppConfig>().haySupabase

Compila el framework en la Terminal de Android Studio, en la carpeta de tu proyecto, antes de seguir:

./gradlew :shared:linkDebugFrameworkIosSimulatorArm64

Paso 2 · La misma pantalla, otro vocabulario

El archivo. AuthView.swift es el formulario de iOS. Va en una carpeta nueva Auth, dentro de la carpeta de la app (la que contiene ContentView.swift). El método es el de La lista en iOS: primero la carpeta, aquí con la Terminal de Android Studio (el plan B de esa lección), en la carpeta de tu proyecto:

mkdir -p iosApp/iosApp/Auth

Y después, en Xcode, clic derecho sobre la carpeta Auth del Project navigator → New File from Template… → Swift File → Next, nombre AuthView → Create (plan B: touch iosApp/iosApp/Auth/AuthView.swift).

Lo escribes en cinco bloques, cada uno con su explicación. Después de cada bloque el fichero compila (Cmd+B).

Bloque 1 · Las propiedades, la cabecera y el selector

Primero lo que la pantalla necesita saber, y lo de arriba: el símbolo, el título y el selector. Este es el fichero con lo de arriba escrito y un hueco señalado en el medio (se rellena en los bloques siguientes). Selecciona todo lo que Xcode creó y escribe esto:

iosApp/iosApp/Auth/AuthView.swift
import SwiftUI
import Shared
import KMPNativeCoroutinesAsync
 
struct AuthView: View {
 
    let viewModel: AuthViewModel
 
    @State private var formulario = AuthFormState(email: "", password: "", emailTocado: false, passwordTocado: false, enviando: false, error: nil)
    @State private var registrando = false
    @State private var mostrandoError = false
 
    /// Mover el foco de correo a contraseña con la tecla «siguiente» del
    /// teclado. Sin esto hay que tocar el segundo campo con el dedo.
    @FocusState private var campo: Campo?
 
    var body: some View {
        VStack(spacing: 24) {
            Spacer()
 
            Image(systemName: registrando ? "person.badge.plus" : "person.crop.circle")
                .font(.system(size: 56))
                .foregroundStyle(.tint)
                // El símbolo rebota al cambiar de modo. Es un detalle de tres
                // palabras que solo existe en iOS.
                .symbolEffect(.bounce, value: registrando)
 
            VStack(spacing: 6) {
                Text("Pokédex")
                    .font(.largeTitle.bold())
                Text("Entra para guardar tus favoritos y recibir el Pokémon del día.")
                    .font(.subheadline)
                    .foregroundStyle(.secondary)
                    .multilineTextAlignment(.center)
            }
 
            Picker("", selection: $registrando) {
                Text("Entrar").tag(false)
                Text("Crear cuenta").tag(true)
            }
            .pickerStyle(.segmented)
 
            // (aquí van los campos y el botón)
 
            Spacer()
            Spacer()
        }
        .padding(.horizontal, 32)
    }
 
    private enum Campo: Hashable {
        case correo, password
    }
}

Qué hay aquí, de arriba abajo:

  • let viewModel: AuthViewModel: el de Kotlin, que le pasará quien cree la vista. La pantalla no lo crea.
  • El estado inicial de formulario lleva todos los argumentos porque los valores por defecto de Kotlin no cruzan a Swift (la razón completa, en Las funciones que cruzan a Swift). Fíjate en que incluye emailTocado y passwordTocado, los dos «tocados» del núcleo. Lo recordarás en las lecciones de la cuenta, cuando este constructor crezca.
  • registrando es un Bool de la vista: dice qué lado del selector está activo. Es lo único que vive solo en Swift; no le importa al núcleo.
  • @FocusState es el cartel que dice qué campo tiene el cursor (lo presentaste en el formulario de Marcador). El enum Campo tiene un caso por campo y se declara Hashable, una palabra que significa «se puede usar como clave y comparar»: SwiftUI la exige para saber a qué campo apunta el cartel. Un enum sin datos como este ya la cumple; solo hay que nombrarla (Equatable y Hashable).
  • .foregroundStyle(.tint) pinta el símbolo con el color de acento de la app, y .multilineTextAlignment(.center) centra el texto cuando ocupa varias líneas.
  • .symbolEffect(.bounce, value: registrando): el símbolo rebota cada vez que cambias de lado el selector (ver arriba).

Bloque 2 · Lo que decide la vista: enviar y los textos

Dos cosas se resuelven en la pantalla y no en Kotlin. Una, qué hace el botón: si estás en «Crear cuenta», llama a registrar(); si no, a entrar(). Otra, qué texto sale para cada error: el núcleo solo dice «el correo está vacío» (un caso del enum), y la pantalla elige las palabras. Así el núcleo no sabe nada de idiomas.

Escríbelas debajo de body (antes del enum Campo):

iosApp/iosApp/Auth/AuthView.swift
    private func enviar() {
        guard formulario.sePuedeEnviar else { return }
        campo = nil
        if registrando {
            viewModel.registrar()
        } else {
            viewModel.entrar()
        }
    }
 
    // El enum de Kotlin se traduce a texto aquí, en la vista. En Swift llega
    // como una clase y no como un enum de Swift: el `switch` necesita `default`.
    private func texto(de error: ErrorEmail) -> String {
        switch error {
        case .vacio: "Escribe tu correo"
        default: "Escribe un correo completo, como nombre@correo.com"
        }
    }
 
    private func texto(de error: ErrorPassword) -> String {
        switch error {
        case .vacia: "Escribe tu contraseña"
        default: "Al menos \(AuthFormState.companion.MINIMO_PASSWORD) caracteres"
        }
    }
  • guard formulario.sePuedeEnviar else { return }: si el formulario no es válido, no hace nada (el botón ya estará gris, pero la tecla «ir» del teclado también llama a esta función, y no debe saltarse la regla).
  • campo = nil: quita el foco, y con él se esconde el teclado.
  • Dos funciones texto(de:) con el mismo nombre se distinguen por el tipo que reciben, como en el formulario de Marcador.
  • El default: un enum de Kotlin llega a Swift como clase, no como enum de Swift, y el compilador no sabe cuántos casos tiene. Por eso el switch pide siempre un default. Lo viste en Ajustes en iOS, con Tema.
  • AuthFormState.companion.MINIMO_PASSWORD: el 6 vive en el companion object de Kotlin (donde van las constantes de la clase) y desde Swift se pide con .companion, así la pantalla y las reglas nunca discrepan.

Bloque 3 · Los dos campos

Sustituye el comentario // (aquí van los campos y el botón) por los campos. Cada uno sigue la misma idea: get lee lo que dice el núcleo, set le cuenta al núcleo cada letra. El campo nunca guarda el texto por su cuenta; lo que ves es lo que el ViewModel de Kotlin ha aceptado. Y debajo de cada campo, el error: solo lo hay si el campo está tocado, y lo decide Kotlin (errorEmail vale nil hasta que se escribe algo).

iosApp/iosApp/Auth/AuthView.swift
            VStack(alignment: .leading, spacing: 12) {
                TextField("Correo", text: Binding(
                    get: { formulario.email },
                    set: { viewModel.escribirEmail(valor: $0) }
                ))
                .textContentType(.emailAddress)
                .keyboardType(.emailAddress)
                .textInputAutocapitalization(.never)
                .autocorrectionDisabled()
                .focused($campo, equals: .correo)
                .submitLabel(.next)
                .onSubmit { campo = .password }
 
                // El error, debajo del campo y solo si ya está tocado: lo decide
                // el núcleo (`errorEmail` es nil hasta que se escribe algo).
                if let error = formulario.errorEmail {
                    Text(texto(de: error))
                        .font(.caption)
                        .foregroundStyle(.red)
                }
 
                SecureField("Contraseña", text: Binding(
                    get: { formulario.password },
                    set: { viewModel.escribirPassword(valor: $0) }
                ))
                // `.newPassword` al registrarse hace que iOS proponga una
                // contraseña fuerte y la guarde. `.password` al entrar hace que
                // ofrezca la que ya tiene.
                .textContentType(registrando ? .newPassword : .password)
                .focused($campo, equals: .password)
                .submitLabel(.go)
                .onSubmit(enviar)
 
                if let error = formulario.errorPassword {
                    Text(texto(de: error))
                        .font(.caption)
                        .foregroundStyle(.red)
                }
            }
            .textFieldStyle(.roundedBorder)
  • El VStack de los dos campos lleva alignment: .leading para que los errores salgan alineados a la izquierda, bajo su campo. .textFieldStyle(.roundedBorder) le da a todos los campos de dentro el borde redondeado.
  • if let error = formulario.errorEmail abre el opcional: si hay error (tocado y mal), sale el texto en rojo y pequeño (.font(.caption)); si es nil, no se pinta nada.
  • .onSubmit(enviar) en la contraseña: en vez de unas llaves se le pasa la función enviar tal cual. La tecla «ir» del teclado envía el formulario, como el botón.
  • .textContentType declara qué significa el texto del campo, y con eso el sistema (su gestor de contraseñas, que se llama Llavero de iCloud o «Contraseñas») ofrece la contraseña guardada al entrar y propone una fuerte al registrarse (.newPassword). Dos palabras; en Android no hay equivalente de una línea.

¿Y si escribes muy deprisa? Cada letra viaja al ViewModel de Kotlin y vuelve por un flujo, y podría parecer que alguna se pierde. No: un StateFlow entrega siempre el último valor, así que el campo nunca recibe uno anterior a lo que acabas de escribir. Lo verás en la lección siguiente, cuando el formulario ya esté en pantalla: escribe un correo largo lo más rápido que puedas y comprueba que no falta ninguna letra.

Bloque 4 · El botón

Debajo del VStack de los campos (y encima de los dos Spacer() finales):

iosApp/iosApp/Auth/AuthView.swift
            Button(action: enviar) {
                if formulario.enviando {
                    ProgressView().tint(.white)
                } else {
                    Text(registrando ? "Crear cuenta" : "Entrar")
                        .frame(maxWidth: .infinity)
                }
            }
            .buttonStyle(.borderedProminent)
            .controlSize(.large)
            .disabled(!formulario.sePuedeEnviar)

El botón recibe la acción como argumento (action: enviar, la función del bloque 2) y su contenido entre llaves. Mientras la petición va (formulario.enviando), enseña una ruedecita en vez del texto; si no, «Crear cuenta» o «Entrar» según el selector. .controlSize(.large) lo hace más alto y fácil de tocar, y .disabled(!formulario.sePuedeEnviar) lo deja gris mientras el formulario no sea válido o haya una petición en vuelo.

Bloque 5 · El error del servidor y la alimentación del formulario

Lo último va después de .padding(.horizontal, 32), encadenado a la VStack de fuera. Son tres modificadores:

iosApp/iosApp/Auth/AuthView.swift
        // El error se pregunta, no se queda en la pantalla. En Android se pinta
        // un aviso encima del botón; aquí la alerta es lo que el usuario espera
        // cuando una acción suya falla.
        .alert(
            formulario.error?.titulo ?? "",
            isPresented: $mostrandoError,
            presenting: formulario.error
        ) { _ in
            Button("Entendido", role: .cancel) { viewModel.descartarError() }
        } message: { error in
            Text(error.detalle)
        }
        .onChange(of: formulario.error) { _, nuevo in
            mostrandoError = nuevo != nil
        }
        .task {
            formulario = viewModel.authFormForIos
            do {
                for try await nuevo in asyncSequence(for: viewModel.authFormForIosFlow) {
                    formulario = nuevo
                }
            } catch { }
        }
  • La alerta usa dos closures al final de la llamada, cada una con su propia etiqueta: una para los botones y otra, message:, para el texto. Es la sintaxis de trailing closures múltiples de Closures. Con presenting: formulario.error, el error llega a las llaves; ?? "" pone un texto vacío si no hay error (entonces la alerta no se enseña).
  • .onChange(of: formulario.error) vigila el error: cuando aparece uno, mostrandoError pasa a verdadero y la alerta sube; cuando desaparece, baja. «Entendido» llama a descartarError() para borrar el error en el núcleo.
  • La .task alimenta el formulario: toma el valor actual (authFormForIos) y después escucha cada cambio (authFormForIosFlow), con el mismo for try await de El mismo ViewModel en iOS. Así lo que escribes en un campo pasa por el ViewModel de Kotlin y vuelve aquí. El catch { } vacío es a propósito: el bucle termina con un error cuando la vista desaparece y se cancela la tarea, y eso no es un fallo.

Compila con Cmd+B. Si Xcode se queja de que no encuentra AuthFormState, ErrorEmail o authFormForIos, vuelve a compilar el framework (el comando del paso 1) y repite.

Dos cosas no se pueden hacer aún. Un #Preview no sirve para esta pantalla, porque el ViewModel viene de Koin y la vista previa no lo arranca. Y nadie llama a AuthView: eso llega en la lección siguiente, con el guardián de la raíz. Por eso el formulario se prueba entonces, en el simulador.

Cuántos tests hay

Esta lección no añade ninguno: lo que has escrito es pantalla, y las reglas que decide ya tienen sus pruebas en Kotlin. Para dejarlo verificado, en la Terminal, en la carpeta de tu proyecto, lanza las tres tareas sin filtro: ./gradlew :shared:jvmTest :shared:testAndroidHostTest :shared:iosSimulatorArm64Test, y abre los informes como en Ajustes en el núcleo (open shared/build/reports/tests/jvmTest/index.html, y lo mismo con testAndroidHostTest e iosSimulatorArm64Test). Salen 117, 79 y 82.

Punto de control

  • Existe AuthIos.kt en shared/src/iosMain/…/feature/auth/, y ./gradlew :shared:linkDebugFrameworkIosSimulatorArm64 termina sin errores.
  • Existe iosApp/iosApp/Auth/AuthView.swift, escrito por ti, y la compilación (Cmd+B) termina sin errores: Swift ve el ViewModel de Kotlin, sus dos flujos y los errores ErrorEmail y ErrorPassword.
  • Sabes decir qué hacen SecureField, Binding(get:set:), .alert y .symbolEffect, y por qué el switch de los errores lleva default.
  • Los informes de tests dan 117 (JVM), 79 (Android host) y 82 (iOS): sin tests nuevos.

Comprueba lo que entendiste

6 preguntas · necesitas 5 para completar la lección

  1. 1.¿Por qué la vista pregunta si hay cuentas con una función del módulo compartido en vez de leer AppConfig del grafo?

  2. 2.¿Qué consigue .textContentType(.newPassword) en el campo de registro?

  3. 3.¿Por qué authStateForIos es una propiedad de extensión en iosMain y no la propiedad del ViewModel?

  4. 4.Escribes muy deprisa en el campo de correo. Cada letra va al ViewModel de Kotlin y vuelve por un flujo. ¿Se pierden letras?

  5. 5.En el formulario de iOS, ¿quién decide que el error del correo no salga nada más abrir la pantalla?

  6. 6.Los switch sobre ErrorEmail en Swift llevan default. ¿Por qué?

Faltan 6 de 6