Formularios en SwiftUI: Form, foco y guardar
Al terminar: Montar el formulario con Form, teclado numérico, foco y un botón Guardar que solo se activa si todo es válido.
Al terminar, la plantilla de cada equipo tendrá un botón «Añadir jugador» arriba a la derecha. Abre un formulario con dos campos, «Nombre» y «Dorsal (opcional)», que enseña cada error en rojo debajo de su campo, un contador «3/25» mientras escribes el nombre y un botón «Guardar» que solo se activa cuando todo es válido. Al guardar, vuelve a la plantilla con el jugador nuevo, y sigue ahí cuando cierras la app y la abres de nuevo.
Importa porque es la primera pantalla de Marcador donde el usuario escribe algo, y casi todo lo que se hace mal en un formulario se ve aquí: el teclado equivocado, el botón que se puede pulsar sin querer, el error que sale antes de tiempo. Temas: Form y Section con footer, escribir en el struct del ViewModel con $viewModel.formulario.nombre, el teclado numérico y las mayúsculas, el foco con @FocusState, .confirmationAction, y las pruebas del ViewModel. Nivel 2: usar.
Form: la lista de los formularios
Hasta ahora has pintado listas con List. Para pedir datos iOS tiene Form, que es una List con el aspecto de la pantalla de Ajustes: grupos con esquinas redondeadas, y cada grupo es una Section. Una Section puede llevar un header (encima) y un footer (debajo): el pie es el sitio natural para el mensaje de error de un campo.
Verás una forma de escribir nueva: Section { … } footer: { … }. Después del primer bloque (los campos) puede ir otro bloque con nombre, footer:, que es el pie. Son dos closures seguidas, y la segunda lleva su etiqueta delante.
El teclado, a medida del campo
Cada campo pide un teclado distinto, y se decide con modificadores:
.keyboardType(.numberPad): el teclado de cifras, para el dorsal. Este teclado no tiene tecla de retorno, y eso decide dónde va «Guardar»: en la barra de arriba, no en el teclado. (El teclado se quita al guardar, porque la pantalla se cierra, o al volver atrás con el botón de la barra; sin salir, no hay un botón para quitarlo, y pasar al campo del nombre cambia al teclado de letras.) Otro teclado,.emailAddress, es el de los correos: trae la arroba a mano..textInputAutocapitalization(.words): pide al teclado que ponga en mayúscula la primera letra de cada palabra, pensado para los nombres propios. Con.neverno pone mayúsculas nunca, como conviene en un correo..submitLabel(.next): elige el rótulo de la tecla de acción del teclado;.nextes el de «siguiente»..onSubmit { … }es el bloque que se ejecuta al pulsar esa tecla.
El foco: qué campo tiene el cursor
El foco es el campo que está recibiendo lo que se teclea. Para saber cuál es, y para moverlo desde el código, SwiftUI tiene @FocusState (una marca que se pone delante de una variable, como @State, y le añade un comportamiento): una variable cuyo valor es el campo con el foco. Se declara así: un enum con un caso por campo, enum Campo { case nombre, dorsal }, y una variable @FocusState private var campo: Campo?. Vale nil cuando ningún campo tiene el foco. Cada campo se une a su caso con .focused($campo, equals: .nombre). Y a la inversa, asignar campo = .dorsal mueve el cursor al dorsal.
Es como el micrófono de una mesa redonda: @FocusState es el cartel que dice quién lo tiene ahora, y escribir un nombre en el cartel pasa el micrófono a esa persona (y puede no tenerlo nadie: nil).
Ejemplo: el alta de un socio, en un Form
Un ejemplo solo para leer: no lo escribas ni lo pegues en ningún sitio. Da por hecho el FormularioSocio de la lección anterior (correo y edad, con sus errores calculados). Lleva su propia NavigationStack para tener barra de título; la pantalla real de Marcador no la necesita, porque se apila en la que ya existe. Aquí está dentro de un Form, con todo lo de arriba: el $formulario.correo que escribe en el struct, el error en el pie, el foco, y «Guardar» arriba:
struct AltaDeSocioView: View {
// El struct de la lección anterior: lo escrito, los campos tocados y los errores calculados.
@State private var formulario = FormularioSocio()
// Qué campo tiene el foco (el cursor y el teclado). `nil`: ninguno.
enum Campo { case correo, edad }
@FocusState private var campo: Campo?
var body: some View {
NavigationStack {
Form {
Section {
// `$formulario.correo`: un enlace al campo `correo` del struct.
TextField("Correo", text: $formulario.correo)
.keyboardType(.emailAddress)
.textInputAutocapitalization(.never)
.focused($campo, equals: .correo) // este campo es el caso `.correo`
.submitLabel(.next) // el rótulo de la tecla de acción: «siguiente»
.onSubmit { campo = .edad } // al pulsarla, el foco pasa a la edad
} footer: {
// El error, solo si el campo está tocado. Una coma en el `if` es un «y»:
// «está tocado, Y además hay un error (lo saco en la constante `error`)».
if formulario.correoTocado, let error = formulario.errorCorreo() {
Text(texto(de: error)).foregroundStyle(.red)
}
}
Section {
TextField("Edad", text: $formulario.edad)
.keyboardType(.numberPad) // teclado de cifras, sin tecla de retorno
.focused($campo, equals: .edad)
} footer: {
if formulario.edadTocado, let error = formulario.errorEdad() {
Text(texto(de: error)).foregroundStyle(.red)
}
}
}
.navigationTitle("Nuevo socio")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
// `.confirmationAction`: el sitio de iOS para confirmar (arriba a la derecha).
ToolbarItem(placement: .confirmationAction) {
Button("Guardar") { print("guardado") }
.disabled(!formulario.esValido()) // gris mientras haya algún error
}
}
}
}
// El texto de cada error lo elige la pantalla; las reglas no saben de textos.
private func texto(de error: ErrorCorreo) -> String {
switch error {
case .vacio: "Escribe un correo"
case .sinArroba: "Falta la arroba"
}
}
private func texto(de error: ErrorEdad) -> String {
switch error {
case .noEsNumero: "Escribe solo números"
case .menorDeEdad: "Hay que ser mayor de edad"
}
}
}Lo que hace, según el código: al abrirlo, «Guardar» está gris (el formulario no es válido) y no hay ningún error a la vista. Al escribir «ana.club.com» en el correo, debajo sale «Falta la arroba» en rojo; con la arroba, desaparece. «Guardar» se activa cuando los dos campos son válidos.
La coma del if de los pies es la de Equipos y plantillas en iOS: un «y» entre dos condiciones. Fíjate en las dos funciones texto(de:): se llaman igual y se distinguen por el tipo del parámetro. Y en que switch devuelve el texto de cada caso sin return, como en Funciones y switch.
Reto · Ejercicio: la pantalla de una reserva
Es la pantalla de una reserva de mesa, parecida a la de Formularios en SwiftUI: el estado y las reglas pero más simple (un nombre y las personas, sin teléfono), y sin ViewModel: el texto vive en @State dentro de la vista. En la carpeta MarcadorApp/Ejercicios de tu proyecto, crea la subcarpeta Reserva (clic derecho sobre MarcadorApp/Ejercicios → New Folder) y dentro un fichero ReservaView.swift (clic derecho sobre Reserva → New File from Template… → Swift File, solo con el target MarcadorApp).
Escribe una vista ReservaView con su vista previa. Es un Form con dos grupos:
- El primero tiene dos campos: Nombre (mayúscula en cada palabra) y Personas (teclado numérico). Debajo del campo de personas, en el pie del grupo, sale «Del 1 al 12» en rojo solo si has escrito algo y no es un número del 1 al 12. Para convertir el texto a número usa
Int(personas), que danilsi no lo es, y para el rango,(1...12).contains(numero). - El segundo tiene un botón Reservar, desactivado mientras el nombre esté vacío (sin contar espacios) o el número de personas no sea válido.
Las piezas son las de arriba (Form, Section con footer, los dos teclados y el botón). Pruébalo en la vista previa del lienzo (Editor → Canvas; si dice que está en pausa, pulsa Resume). Si el lienzo de tu Xcode no responde al teclado, comprueba al menos que el fichero compila con Cmd+B. Lo que debe verse:
| Escribes | Debe verse |
|---|---|
| Nada | Los dos campos vacíos, sin error y Reservar gris |
Nombre Ana | Reservar sigue gris (faltan las personas) |
Personas 13 | «Del 1 al 12» en rojo y Reservar gris |
Personas 4 | Sin error y Reservar activo |
Ver solución
import SwiftUI
struct ReservaView: View {
@State private var nombre = ""
@State private var personas = ""
// El número de personas, o `nil` si el texto no es un número del 1 al 12.
private var personasValidas: Int? {
guard let numero = Int(personas), (1...12).contains(numero) else { return nil }
return numero
}
var body: some View {
Form {
Section {
TextField("Nombre", text: $nombre)
.textInputAutocapitalization(.words)
TextField("Personas", text: $personas)
.keyboardType(.numberPad)
} footer: {
// El error solo se ve si ya has escrito algo y no vale.
if !personas.isEmpty && personasValidas == nil {
Text("Del 1 al 12").foregroundStyle(.red)
}
}
Section {
Button("Reservar") { }
.disabled(nombre.trimmingCharacters(in: .whitespaces).isEmpty || personasValidas == nil)
}
}
}
}
#Preview {
ReservaView()
}guard let numero = Int(personas), (1...12).contains(numero) junta dos condiciones con una coma: si falla cualquiera, sale con nil. El error se calcula con lo escrito en ese momento, como en la lección anterior, y por eso no puede quedarse viejo.
Paso 1 · La pantalla: AnadirJugadorView.swift
Objetivo: el formulario de Marcador, con el ViewModel de la lección anterior.
Crea AnadirJugadorView.swift: clic derecho sobre MarcadorApp/Plantilla/UI (la de la app, no la de las pruebas) → New File from Template… → Swift File, solo con el target MarcadorApp. Es una pantalla de la plantilla, y ahí viven ya el formulario y su ViewModel.
Piezas nuevas de este paso: las que acabas de ver (Form, Section con footer, .keyboardType, .textInputAutocapitalization, @FocusState con .focused, .submitLabel y .onSubmit), y estas:
$viewModel.formulario.nombre: el$sobre un@Stateque guarda unViewModel@Observable, y después el camino hasta el campo. Es un enlace al texto delstruct: al escribir, se guarda ahí, y eldidSetlo marca como tocado._viewModel = State(wrappedValue: viewModel)en elinit: es la forma de dar su valor inicial a un@Statedesde fuera. El guion bajo es el@Stateen sí, no su contenido, igual que enCronicaView(La pantalla de la crónica y sus pruebas).ToolbarItem(placement: .confirmationAction) { … }: un elemento de la barra con un sitio que decide iOS: el de confirmar, arriba a la derecha. Es el sitio de «Guardar»..disabled(!viewModel.sePuedeGuardar): gris mientras no sea válido.
Intenta escribirla tú. Una vista que recibe su ViewModel ya montado (con State(wrappedValue:) en el init) y lee dismiss como cerrar. Un Form con dos Section:
- Primera: un
TextField("Nombre", …)con mayúscula en cada palabra, unido al caso.nombredel foco, con la tecla «siguiente» que pasa al dorsal; su pie enseña el error en rojo si lo hay, y si no, el contador «N/25» con la longitud del nombre sin espacios de los bordes. - Segunda: un
TextField("Dorsal (opcional)", …)de teclado numérico unido al caso.dorsal, con su error en rojo en el pie (y nada si no hay error).
Además: título «Añadir jugador», pequeño en la barra; un botón «Guardar» en la barra, en el sitio de confirmar, desactivado mientras no se pueda guardar, que al pulsarlo cierra la pantalla si guardar() dice que sí; y dos funciones texto(de:) con los seis textos de la tabla de la lección anterior.
Intenta primero. Cuando termines, compara con la solución.Mostrar el código
import SwiftUI
// LA PANTALLA DEL FORMULARIO (gemelo de `PantallaAnadirJugador`). `Form` es la lista agrupada de
// iOS para formularios (la de Ajustes). Lo suyo: el foco, el teclado y el texto de cada error.
struct AnadirJugadorView: View {
@State private var viewModel: AnadirJugadorViewModel
@Environment(\.dismiss) private var cerrar
// Qué campo tiene el foco (el teclado). `nil` = ninguno.
enum Campo { case nombre, dorsal }
@FocusState private var campo: Campo?
init(viewModel: AnadirJugadorViewModel) {
_viewModel = State(wrappedValue: viewModel)
}
var body: some View {
Form {
Section {
// `$viewModel.formulario.nombre`: un Binding que escribe en el struct del ViewModel.
TextField("Nombre", text: $viewModel.formulario.nombre)
.textInputAutocapitalization(.words)
.focused($campo, equals: .nombre)
.submitLabel(.next)
.onSubmit { campo = .dorsal }
} footer: {
if let error = viewModel.errorNombre {
Text(texto(de: error)).foregroundStyle(.red)
} else {
Text("\(viewModel.formulario.nombre.trimmingCharacters(in: .whitespaces).count)/\(FormularioJugador.maximoNombre)")
}
}
Section {
TextField("Dorsal (opcional)", text: $viewModel.formulario.dorsal)
.keyboardType(.numberPad)
.focused($campo, equals: .dorsal)
} footer: {
if let error = viewModel.errorDorsal {
Text(texto(de: error)).foregroundStyle(.red)
}
}
}
.navigationTitle("Añadir jugador")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
// `.confirmationAction`: el sitio de iOS para «Guardar» (arriba a la derecha).
ToolbarItem(placement: .confirmationAction) {
Button("Guardar") {
if viewModel.guardar() { cerrar() }
}
.disabled(!viewModel.sePuedeGuardar)
}
}
}
private func texto(de error: ErrorNombre) -> String {
switch error {
case .vacio: "Escribe un nombre"
case .largo: "Como mucho \(FormularioJugador.maximoNombre) letras"
case .repetido: "Ya hay un jugador con ese nombre"
}
}
private func texto(de error: ErrorDorsal) -> String {
switch error {
case .noEsNumero: "Escribe solo números"
case .fueraDeRango: "Del 1 al 99"
case .repetido: "Ese dorsal ya lo lleva otro jugador"
}
}
}
#Preview {
NavigationStack {
AnadirJugadorView(viewModel: AnadirJugadorViewModel(equipo: "Rayo FC", repositorio: RepositorioDePartidos()))
}
}Compila con Cmd+B. Tres decisiones:
- El botón no se puede pulsar mal, y
guardar()tampoco se fía. Hay tres barreras, las mismas de la lección anterior:.disabledapaga «Guardar» mientras haya algún error;guardar()vuelve a mirarsePuedeGuardary devuelvefalsesi no se puede; y, por último, elinit?deEquiporechaza un dorsal repetido. - La pantalla no pide el foco al abrirse. El teclado sale cuando tocas un campo, y
@FocusStatese usa solo para pasar del nombre al dorsal. Pruébalo: con el cursor en «Nombre», pulsa la tecla de acción del teclado y mira si el cursor pasa a «Dorsal». Si en el simulador no sale el teclado en pantalla, pulsa Cmd+K. - El texto de ayuda desaparece al escribir. «Nombre» y «Dorsal (opcional)» son el texto de sugerencia del campo: en cuanto hay algo escrito, ya no se ve. Es lo normal en un
TextFieldde iOS.
Paso 2 · Conectar la pantalla con la plantilla
Objetivo: que «Añadir jugador», en la plantilla, abra el formulario.
Cuatro cambios pequeños. Hasta que acabes los tres primeros, el proyecto estará en rojo: el switch de ListaDePartidosView (Xcode: switch must be exhaustive) no tiene todavía el caso nuevo. Primero, un caso con el nombre del equipo en Destino.swift:
Intenta primero. Cuando termines, compara con la solución.Mostrar el código
enum Destino: Hashable {
case partido(Int) // el id del partido (viaja en la clave, en un solo sitio)
case goleadores
case arbitros // la lista de árbitros (botón «Árbitros» de la barra de Partidos)
case cronica(Int) // la crónica del partido con ese id (botón «Crónica» del partido)
case equipos // la lista de equipos (botón «Equipos» de la barra de Partidos)
case plantilla(String) // la plantilla del equipo con ese nombre
case anadirJugador(String) // el formulario para añadir un jugador a ese equipo
}Segundo, en ContenedorDeLaApp.swift, una fábrica más, debajo de cronicaViewModel(id:), como las demás: cada pantalla recibe un ViewModel nuevo, con el repositorio de partidos del contenedor.
Intenta primero. Cuando termines, compara con la solución.Mostrar el código
func anadirJugadorViewModel(equipo: String) -> AnadirJugadorViewModel {
AnadirJugadorViewModel(equipo: equipo, repositorio: partidos)
}El resto del fichero no cambia: son tres líneas dentro de la clase, debajo de cronicaViewModel(id:).
Tercero, en ListaDePartidosView.swift, el caso nuevo del switch, después de .plantilla:
Intenta primero. Cuando termines, compara con la solución.Mostrar el código
case let .anadirJugador(equipo):
AnadirJugadorView(viewModel: contenedor.anadirJugadorViewModel(equipo: equipo))El resto del fichero no cambia.
Cuarto, el botón. En PlantillaView.swift, un .toolbar con un enlace, debajo de .navigationBarTitleDisplayMode(.inline):
Intenta primero. Cuando termines, compara con la solución.Mostrar el código
.toolbar {
NavigationLink("Añadir jugador", value: Destino.anadirJugador(equipo))
}El resto del fichero no cambia.
Ejecuta con Cmd+R. Entra en «Partidos» → «Equipos» → «Rayo FC» → «Añadir jugador».
- Al abrirlo, «Guardar» está gris, el nombre muestra «0/25» y no hay ningún error.
- Escribe «Bea» y el dorsal 9: el contador pasa a «3/25» y debajo del dorsal sale «Ese dorsal ya lo lleva otro jugador» en rojo; «Guardar» sigue gris.
- Con un 0, sale «Del 1 al 99».
- Cambia el dorsal a 7: el error desaparece y «Guardar» se activa.
- Pulsa «Guardar»: vuelves a «Rayo FC» y la plantilla termina con «Bea #7».
- Para la app (Cmd+.) y ejecútala otra vez (Cmd+R): «Equipos» dice «Rayo FC» con 4 jugadores y Bea sigue en la plantilla. Se guardó en el fichero del torneo. Haz esta comprobación ahora, antes de lanzar las pruebas del paso 3: las pruebas de interfaz arrancan la app con los datos de partida y pueden borrar a Bea.
Paso 3 · Las pruebas del ViewModel
Objetivo: tres pruebas con el repositorio falso, sin pantalla.
Crea AnadirJugadorViewModelTests.swift: clic derecho sobre MarcadorAppTests/Plantilla/UI (la carpeta de las pruebas del formulario) → New File from Template… → Swift File, solo con el target MarcadorAppTests. Tres pruebas, con el Rayo FC del falso (Ana #9, Luis y Marta #1):
- Al abrir, no se ve ningún error y no se puede guardar.
- Un dorsal que ya está en la plantilla guardada (9) da el error
repetido, yguardar()devuelvefalse. - Un jugador válido se guarda: el falso anota «Bea, 7», y después el mismo nombre ya es un error
repetidoporque el equipo tiene a Bea. Es la prueba de que los errores se recalculan solos.
repositorio.jugadoresAnadidos.map(\.jugador) saca, de cada pareja que anotó el falso, solo el jugador: \.jugador es un key path, el camino hasta ese campo de la pareja (como el \.nombre de Colecciones y key paths).
Intenta primero. Cuando termines, compara con la solución.Mostrar el código
import Testing
import Torneo
@testable import MarcadorApp
// El ViewModel del formulario con el repositorio falso (Rayo FC: Ana #9, Luis y Marta #1).
@MainActor
struct AnadirJugadorViewModelTests {
@Test func alAbrirNoSeVeNingunErrorYNoSePuedeGuardar() {
let viewModel = AnadirJugadorViewModel(equipo: "Rayo FC", repositorio: RepositorioFalso())
#expect(viewModel.errorNombre == nil)
#expect(viewModel.errorDorsal == nil)
#expect(!viewModel.sePuedeGuardar)
}
@Test func unDorsalDeLaPlantillaGuardadaEsUnError() {
let viewModel = AnadirJugadorViewModel(equipo: "Rayo FC", repositorio: RepositorioFalso())
viewModel.formulario.nombre = "Bea"
viewModel.formulario.dorsal = "9"
#expect(viewModel.errorDorsal == .repetido)
#expect(!viewModel.guardar())
}
@Test func guardarUnJugadorValidoLoAnadeAlEquipo() {
let repositorio = RepositorioFalso()
let viewModel = AnadirJugadorViewModel(equipo: "Rayo FC", repositorio: repositorio)
viewModel.formulario.nombre = "Bea"
viewModel.formulario.dorsal = "7"
#expect(viewModel.guardar())
#expect(repositorio.jugadoresAnadidos.map(\.jugador) == [Jugador(nombre: "Bea", dorsal: 7)])
// Ya está en la plantilla: el mismo nombre otra vez es un error.
#expect(viewModel.errorNombre == .repetido)
}
}Lanza Cmd+U.
Punto de control
- Cmd+B termina en
Build Succeededy Cmd+U da 61 pruebas en verde: 45 unitarias y 16 de interfaz (con tus ejercicios de pruebas, 64). Las tres nuevas son las deAnadirJugadorViewModelTests. - En el simulador, «Equipos» → «Rayo FC» → «Añadir jugador»: con «Bea» y el 9 sale «Ese dorsal ya lo lleva otro jugador» y «Guardar» sigue gris; con el 7, «Guardar» se activa y, al pulsarlo, vuelves a la plantilla con «Bea #7».
- Antes de lanzar las pruebas, comprobaste que Bea seguía en la plantilla del Rayo FC tras parar la app y ejecutarla de nuevo.
- La carpeta
EjerciciostieneReserva, y su vista previa deja «Reservar» gris hasta que hay nombre y un número de personas del 1 al 12 (si el lienzo no acepta el teclado, el fichero al menos compila). - Sabes decir para qué sirve
@FocusState, por qué «Guardar» va en la barra y no en el teclado, y por qué el botón yguardar()comprueban lo mismo (son dos de las tres barreras).
Comprueba lo que entendiste
5 preguntas · necesitas 4 para completar la lección
1.¿Qué hace
$viewModel.formulario.nombreen elTextFielddel nombre?2.¿Por qué «Guardar» va en la barra de arriba y no en el teclado?
3.En
@FocusState private var campo: Campo?, ¿qué valor tienecampocuando ningún campo tiene el foco?4.El formulario está recién abierto. ¿Qué se ve en «Guardar» y en los errores?
5.
guardar()compruebasePuedeGuardaraunque el botón ya esté desactivado cuando el formulario es inválido. ¿Por qué?