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.
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.
SecureFieldes unTextFieldque 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$textoque da SwiftUI (@Binding): lee y escribe un@State. Aquí el texto no vive en el@Statede 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 unBoolpasa a verdadero, como el.confirmationDialogde Favoritos en iOS. Conpresenting: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 deImage(systemName:)): convalue:lo repite cada vez quexcambia.
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.
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.ktEl 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:
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>().haySupabaseCompila el framework en la Terminal de Android Studio, en la carpeta de tu proyecto, antes de seguir:
./gradlew :shared:linkDebugFrameworkIosSimulatorArm64Paso 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/AuthY 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:
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
formulariolleva 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 incluyeemailTocadoypasswordTocado, los dos «tocados» del núcleo. Lo recordarás en las lecciones de la cuenta, cuando este constructor crezca. registrandoes unBoolde la vista: dice qué lado del selector está activo. Es lo único que vive solo en Swift; no le importa al núcleo.@FocusStatees el cartel que dice qué campo tiene el cursor (lo presentaste en el formulario de Marcador). Elenum Campotiene un caso por campo y se declaraHashable, una palabra que significa «se puede usar como clave y comparar»: SwiftUI la exige para saber a qué campo apunta el cartel. Unenumsin 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):
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: unenumde Kotlin llega a Swift como clase, no comoenumde Swift, y el compilador no sabe cuántos casos tiene. Por eso elswitchpide siempre undefault. Lo viste en Ajustes en iOS, conTema. AuthFormState.companion.MINIMO_PASSWORD: el 6 vive en elcompanion objectde 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).
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
VStackde los dos campos llevaalignment: .leadingpara 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.errorEmailabre el opcional: si hay error (tocado y mal), sale el texto en rojo y pequeño (.font(.caption)); si esnil, no se pinta nada..onSubmit(enviar)en la contraseña: en vez de unas llaves se le pasa la funciónenviartal cual. La tecla «ir» del teclado envía el formulario, como el botón..textContentTypedeclara 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):
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:
// 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. Conpresenting: 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,mostrandoErrorpasa a verdadero y la alerta sube; cuando desaparece, baja. «Entendido» llama adescartarError()para borrar el error en el núcleo.- La
.taskalimenta el formulario: toma el valor actual (authFormForIos) y después escucha cada cambio (authFormForIosFlow), con el mismofor try awaitde El mismo ViewModel en iOS. Así lo que escribes en un campo pasa por elViewModelde Kotlin y vuelve aquí. Elcatch { }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.ktenshared/src/iosMain/…/feature/auth/, y./gradlew :shared:linkDebugFrameworkIosSimulatorArm64termina sin errores. - Existe
iosApp/iosApp/Auth/AuthView.swift, escrito por ti, y la compilación (Cmd+B) termina sin errores: Swift ve elViewModelde Kotlin, sus dos flujos y los erroresErrorEmailyErrorPassword. - Sabes decir qué hacen
SecureField,Binding(get:set:),.alerty.symbolEffect, y por qué elswitchde los errores llevadefault. - 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.¿Por qué la vista pregunta si hay cuentas con una función del módulo compartido en vez de leer
AppConfigdel grafo?2.¿Qué consigue
.textContentType(.newPassword)en el campo de registro?3.¿Por qué
authStateForIoses una propiedad de extensión eniosMainy no la propiedad del ViewModel?4.Escribes muy deprisa en el campo de correo. Cada letra va al
ViewModelde Kotlin y vuelve por un flujo. ¿Se pierden letras?5.En el formulario de iOS, ¿quién decide que el error del correo no salga nada más abrir la pantalla?
6.Los
switchsobreErrorEmailen Swift llevandefault. ¿Por qué?