FocusState: el recorrido del teclado y el arte de cerrarlo
El foco es el recurso más escaso de un formulario: hay exactamente uno, lo controlan a la vez el sistema, el usuario y tu código, y cada movimiento en falso cuesta una pulsación o una frustración. SwiftUI lo modela con FocusState, una propiedad envuelta que es simultáneamente lectura y escritura: te dice qué campo está activo y te permite activarlo. Esta lección construye el recorrido completo de un formulario con la tecla de retorno, explica por qué el foco es estado y no comando, analiza las formas legítimas e ilegítimas de cerrar el teclado y sitúa el foco en su lugar correcto dentro de la accesibilidad.
Hay un momento en todo formulario en el que el usuario termina de escribir en un campo y espera algo. Si la aplicación no decide qué pasa entonces, decide el vacío: el teclado se queda, el foco se pierde, el pulgar viaja hasta el siguiente campo y el ritmo se rompe. FocusState existe para que esa decisión sea explícita y para que el recorrido por los campos sea una propiedad del diseño, no un accidente del orden en que colocaste las vistas.
- Modelar el foco como estado con un enumerado
HashableyFocusState. - Enlazar campos con
focusedy mover el foco desde el código. - Encadenar el recorrido con
submitLabelyonSubmithasta el envío. - Cerrar el teclado con criterio y respetar el foco de accesibilidad.
El foco es un estado, no una orden
FocusState es bidireccional. Cuando el usuario toca un campo, la propiedad se actualiza sola; cuando tú le asignas un valor, el sistema mueve el foco y abre el teclado. Esa simetría es lo que permite razonar sobre el foco igual que sobre cualquier otro estado: es idempotente, se puede observar, y nil significa que nadie tiene el foco.
struct AltaView: View {
enum Campo: Hashable { case nombre, correo, clave }
@State private var nombre = ""
@State private var correo = ""
@State private var clave = ""
@FocusState private var campoActivo: Campo?
var body: some View {
Form {
TextField("Nombre", text: $nombre)
.focused($campoActivo, equals: .nombre)
TextField("Correo", text: $correo)
.focused($campoActivo, equals: .correo)
SecureField("Contraseña", text: $clave)
.focused($campoActivo, equals: .clave)
}
}
}
Conviene notar que el enumerado describe campos, no vistas: dos campos que comparten componente reutilizable siguen teniendo casos distintos, y un campo que aparece condicionalmente simplemente no puede recibir el foco mientras no exista en el árbol.
Existe también la variante booleana, .focused($tecladoVisible), útil cuando hay un solo campo. Con dos o más, el enumerado opcional es superior: expresa la exclusividad mutua en el tipo, no en una convención entre varios booleanos que podrían estar activos a la vez y no deberían.
FocusState solo funciona dentro del árbol de la vista que lo declara. Si extraes un campo a una subvista, pasa la conexión con el tipo FocusState.Binding y enlázala allí con focused. Intentar coordinar el foco desde un modelo observable no funciona: el foco pertenece a la capa de presentación y el sistema puede modificarlo sin avisarte.
Encadenar el recorrido
El recorrido es una función del campo actual al siguiente, y el destino natural del último campo no es otro campo sino la acción de envío. Escríbelo como una transición explícita y tendrás, gratis, la documentación del formulario.
.submitLabel(campoActivo == .clave ? .done : .next)
.onSubmit {
switch campoActivo {
case .nombre: campoActivo = .correo
case .correo: campoActivo = .clave
case .clave: campoActivo = nil; enviar()
case .none: break
}
}
stateDiagram-v2 [*] --> Nombre Nombre --> Correo: retorno con next Correo --> Clave: retorno con next Clave --> Cerrado: retorno con done Cerrado --> Nombre: toque del usuario Cerrado --> [*]: envio
Escribir la transición como un switch sobre el campo activo, y no como un índice dentro de un array de campos, tiene una ventaja que se aprecia al mantener el código: cuando alguien inserte un campo nuevo en medio, el compilador exigirá atender el caso y el recorrido no se romperá en silencio. El orden del recorrido es una decisión de producto —a veces el usuario debe pasar por un campo opcional y a veces conviene saltárselo—, y merece estar escrito en un solo lugar legible.
Dos detalles convierten esto en producción. El primero: submitLabel debe ser coherente con el destino, y por eso se calcula a partir del campo activo en lugar de fijarse por campo. El segundo: si un campo intermedio es inválido, saltar al siguiente esconde el problema; considera devolver el foco al campo defectuoso, que es exactamente lo que hace el sistema en sus propios formularios.
Cerrar el teclado con criterio
Asignar nil a la propiedad de foco cierra el teclado. Esa es la vía canónica, y todas las demás son formas de llegar a ella. Lo que distingue una aplicación cuidada de una aproximada es cuáles ofrece y cuáles evita.
Al desplazar
.scrollDismissesKeyboard(.interactively) deja que el teclado siga al dedo; .immediately lo cierra al primer gesto. Es el gesto que el usuario ya conoce del sistema.
Barra del teclado
Un ToolbarItemGroup con placement: .keyboard da un botón visible de cierre y, si quieres, flechas de anterior y siguiente.
Al enviar
El final del recorrido pone campoActivo = nil antes de disparar la acción: el teclado no debe tapar el estado de carga ni el error.
Lo que conviene evitar
Un onTapGesture sobre todo el fondo para cerrar: se traga toques legítimos, no aparece para el lector de pantalla y compite con los gestos del sistema.
Form { /* campos */ }
.scrollDismissesKeyboard(.interactively)
.toolbar {
ToolbarItemGroup(placement: .keyboard) {
Spacer()
Button("Listo") { campoActivo = nil }
}
}
Enfocar el primer campo en onAppear acelera al usuario que venía a escribir y estorba al que venía a leer: el teclado ocupa media pantalla y oculta el contexto. Hazlo solo cuando la pantalla exista para escribir —una búsqueda, un campo único, un código de verificación— y nunca en formularios largos donde el usuario necesita ver antes de decidir.
Presentaciones, reconstrucciones y foco de accesibilidad
El foco no sobrevive a una reconstrucción del subárbol. Si presentas una hoja, cambias de pestaña o alteras la identidad de una vista con id, el campo enfocado desaparece y con él la propiedad vuelve a nil. Eso no es un fallo: es coherencia entre el estado del foco y el árbol que lo sostiene, y conviene apoyarse en ello en lugar de pelear.
.sheet(isPresented: $mostrandoHoja) {
HojaDeCodigo()
}
.onChange(of: mostrandoHoja) { _, presentada in
if !presentada { campoActivo = .correo }
}
En una hoja que existe solo para escribir —un código de verificación, un campo de búsqueda— enfocar al aparecer es correcto y esperado. Hazlo dentro de un task de la propia hoja, no desde el padre: el árbol tiene que estar montado para que el sistema acepte la asignación.
FocusState gobierna el teclado; AccessibilityFocusState gobierna dónde está posado VoiceOver. Son independientes y ambos son singulares. Cuando un intento de envío te obligue a señalar el primer campo defectuoso, mueve los dos: el teclado para que el usuario pueda corregir sin buscar, y el foco de accesibilidad para que quien escucha la pantalla oiga el error en lugar de encontrarlo por casualidad recorriendo elementos.
En cualquier instante existe como mucho un foco de teclado, y sobre él actúan tres agentes con autoridad legítima y objetivos distintos. El usuario lo mueve tocando, y su intención es soberana. El sistema lo mueve por su cuenta al presentar una hoja, al rotar el dispositivo, al restaurar una escena o al aparecer un teclado externo, y no te pide permiso. Tu código lo mueve para encadenar campos o para señalar un error. Modelar eso como estado observable y no como una serie de órdenes imperativas es lo que hace el problema tratable: puedes leer quién lo tiene, escribir quién debería tenerlo y aceptar que la escritura es una petición que el sistema puede reordenar respecto a la animación en curso. De ahí se deduce la ética práctica del foco. Moverlo hacia adelante cuando el usuario acaba de terminar un campo es cooperar con su intención. Moverlo hacia atrás, al primer campo inválido tras un intento de envío, es informar. Moverlo porque una petición de red terminó, mientras el usuario escribía en otro sitio, es robar: le borras la ubicación mental y, si usa un lector de pantalla, le interrumpes la lectura en curso. La regla que resume todo: el foco se mueve como consecuencia de un gesto del usuario, jamás como consecuencia de un evento asíncrono que él no provocó. Y su hermano en accesibilidad, AccessibilityFocusState, obedece la misma ley sobre otro recurso igual de singular: el punto donde el lector de pantalla está mirando.
- Declara un enumerado
Hashablecon tus campos y una única propiedadFocusStateopcional, y enlaza cada campo confocused. - Calcula
submitLabela partir del campo activo y encadena el recorrido enonSubmithasta que el último cierre el teclado y envíe. - Extrae un campo a una subvista y hazlo funcionar pasando el foco con el tipo
FocusState.Binding. - Añade
scrollDismissesKeyboardy una barra de teclado con un botón de cierre, y elimina cualquier gesto de toque al fondo que tuvieras. - Introduce un error deliberado en el segundo campo y haz que el intento de envío devuelva el foco allí en lugar de avanzar.