Saltar al contenido
K
Arquitectura en iOSPaso a paso50 min Intermedio

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 .never no pone mayúsculas nunca, como conviene en un correo.
  • .submitLabel(.next): elige el rótulo de la tecla de acción del teclado; .next es 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:

ejemplo mínimo: el alta de socio en un Form (con comentarios)
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 da nil si 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:

EscribesDebe verse
NadaLos dos campos vacíos, sin error y Reservar gris
Nombre AnaReservar sigue gris (faltan las personas)
Personas 13«Del 1 al 12» en rojo y Reservar gris
Personas 4Sin error y Reservar activo
Ver solución
MarcadorApp/Ejercicios/Reserva/ReservaView.swift
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.

MarcadorApp (proyecto)
MarcadorApp
Plantilla
UI
AnadirJugadorView.swiftse crea
PlantillaView.swiftse modifica
ContenedorDeLaApp.swiftse modifica
Destino.swiftse modifica
Partidos
UI
ListaDePartidosView.swiftse modifica
MarcadorAppTests
Plantilla
UI
AnadirJugadorViewModelTests.swiftse crea
Los ficheros de la lección en el Project navigator.

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 @State que guarda un ViewModel @Observable, y después el camino hasta el campo. Es un enlace al texto del struct: al escribir, se guarda ahí, y el didSet lo marca como tocado.
  • _viewModel = State(wrappedValue: viewModel) en el init: es la forma de dar su valor inicial a un @State desde fuera. El guion bajo es el @State en sí, no su contenido, igual que en CronicaView (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 .nombre del 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
MarcadorApp/Plantilla/UI/AnadirJugadorView.swift
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: .disabled apaga «Guardar» mientras haya algún error; guardar() vuelve a mirar sePuedeGuardar y devuelve false si no se puede; y, por último, el init? de Equipo rechaza un dorsal repetido.
  • La pantalla no pide el foco al abrirse. El teclado sale cuando tocas un campo, y @FocusState se 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 TextField de 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
MarcadorApp/Destino.swift
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
MarcadorApp/ContenedorDeLaApp.swift
    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
MarcadorApp/Partidos/UI/ListaDePartidosView.swift
                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
MarcadorApp/Plantilla/UI/PlantillaView.swift
        .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):

  1. Al abrir, no se ve ningún error y no se puede guardar.
  2. Un dorsal que ya está en la plantilla guardada (9) da el error repetido, y guardar() devuelve false.
  3. Un jugador válido se guarda: el falso anota «Bea, 7», y después el mismo nombre ya es un error repetido porque 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
MarcadorAppTests/Plantilla/UI/AnadirJugadorViewModelTests.swift
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 Succeeded y Cmd+U da 61 pruebas en verde: 45 unitarias y 16 de interfaz (con tus ejercicios de pruebas, 64). Las tres nuevas son las de AnadirJugadorViewModelTests.
  • 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 Ejercicios tiene Reserva, 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 y guardar() comprueban lo mismo (son dos de las tres barreras).

Comprueba lo que entendiste

5 preguntas · necesitas 4 para completar la lección

  1. 1.¿Qué hace $viewModel.formulario.nombre en el TextField del nombre?

  2. 2.¿Por qué «Guardar» va en la barra de arriba y no en el teclado?

  3. 3.En @FocusState private var campo: Campo?, ¿qué valor tiene campo cuando ningún campo tiene el foco?

  4. 4.El formulario está recién abierto. ¿Qué se ve en «Guardar» y en los errores?

  5. 5.guardar() comprueba sePuedeGuardar aunque el botón ya esté desactivado cuando el formulario es inválido. ¿Por qué?

Faltan 5 de 5