wandres.dev
FORMULARIOS Y ENTRADA · texto, foco y validación

TextField y compañía: formato, teclado y tecla de retorno

Un campo de texto es el punto donde una cadena arbitraria, escrita por un humano con prisa, se convierte en un valor tipado del que depende el resto de la aplicación. SwiftUI ofrece para ese cruce dos inicializadores con contratos distintos: uno que entrega la cadena en bruto y otro que interpone un FormatStyle capaz de formatear y de analizar. Esta lección estudia cuándo elegir cada uno, por qué el teclado numérico es una sugerencia y no una garantía, qué prometen textContentType y las políticas de autocorrección, y cómo submitLabel junto a onSubmit convierte la tecla de retorno en parte del diseño de la interacción.

⏱ 18 min

Todo formulario es un traductor. A un lado hay un teclado que produce cadenas sin restricción alguna; al otro, un modelo que espera un Decimal, una Date o un correo válido. SwiftUI no oculta ese abismo: te obliga a decidir en qué punto exacto ocurre la traducción, quién la hace y qué pasa cuando falla. La diferencia entre un campo que se siente nativo y uno que irrita al usuario rara vez está en el aspecto; está en si el teclado que aparece corresponde al dato pedido, en si la tecla de retorno hace lo que promete y en si el valor mal escrito se pierde o se conserva.

🎯 Al terminar esta lección sabrás
  • Distinguir los contratos de TextField(_:text:) y TextField(_:value:format:).
  • Aplicar estilos de formato y entender cuándo se analiza la cadena introducida.
  • Configurar teclado, autorrelleno y autocorrección como pistas semánticas, no como validación.
  • Usar submitLabel y onSubmit para dar sentido a la tecla de retorno.

Texto crudo frente a valor tipado

El inicializador con text: te entrega exactamente lo que el usuario teclea: una String que cambia en cada pulsación. Tú asumes la conversión, la validación y el manejo del error. El inicializador con value:format: interpone un FormatStyle que además conforma ParseableFormatStyle: al mostrar, formatea el valor; al confirmar, intenta analizar la cadena y escribirla en el binding tipado.

struct PagoView: View {
    @State private var concepto = ""
    @State private var importe: Decimal = 0
    @State private var unidades = 1

    var body: some View {
        Form {
            TextField("Concepto", text: $concepto)
            TextField("Importe", value: $importe,
                      format: .currency(code: "EUR"))
            TextField("Unidades", value: $unidades,
                      format: .number.precision(.fractionLength(0)))
        }
    }
}

La asimetría importante es temporal. Con text: el binding se actualiza en cada tecla; con value:format: el análisis se difiere a la confirmación —perder el foco o enviar—, y si la cadena no es analizable el campo revierte al último valor válido. Ese comportamiento es deseable en importes y fechas, y desastroso si esperabas validar carácter a carácter.

El estilo de formato no es un adorno de salida: fija a la vez cómo se muestra y qué se acepta al analizar, y ambas caras dependen de la configuración regional del dispositivo. Un usuario con la coma como separador decimal escribirá coma, y .number la entenderá; el mismo texto analizado con un separador impuesto a mano fallaría.

.number.precision(.fractionLength(2))     // dos decimales siempre
.number.grouping(.never)                  // sin separador de millares
.percent                                  // el 15 por ciento se guarda como 0.15
.currency(code: divisa.rawValue)          // símbolo y decimales segun divisa

Ese detalle del porcentaje concentra buena parte de los errores de formulario: el valor almacenado y el valor mostrado no coinciden, y quien valide contra el número tecleado en lugar de contra el valor del binding acabará rechazando entradas correctas.

⚠️
El valor tipado no cambia en cada pulsación

Si necesitas reaccionar mientras el usuario escribe —contar caracteres, habilitar un botón, sugerir resultados— usa text: y analiza tú. Con value:format: no hay estado intermedio observable: entre el último valor válido y la próxima confirmación, el modelo no sabe nada de lo que está pasando en pantalla. Y ojo con revertir en silencio: el usuario que escribe algo inanalizable ve desaparecer su texto sin explicación.

El teclado propone, no dispone

Los modificadores de entrada configuran la experiencia del teclado software. Ninguno de ellos restringe lo que puede llegar al binding: un teclado físico, el portapapeles, el dictado o una extensión de teclado de terceros producen cualquier carácter. Trátalos como declaraciones de intención que mejoran la ergonomía, y valida siempre en el modelo.

⌨️

keyboardType

.decimalPad para importes, .numberPad para códigos, .emailAddress para correos. Cambia las teclas visibles; no filtra la entrada.

🔐

textContentType

.emailAddress, .newPassword, .oneTimeCode. Activa autorrelleno, contraseñas fuertes sugeridas y el código llegado por mensaje.

🔤

Corrección

.textInputAutocapitalization(.never) y .autocorrectionDisabled() para identificadores, cupones y nombres de usuario.

📝

Texto largo

TextField(axis: .vertical) con .lineLimit(3...8) crece con el contenido; SecureField oculta lo escrito.

TextField("Correo", text: $correo)
    .keyboardType(.emailAddress)
    .textContentType(.emailAddress)
    .textInputAutocapitalization(.never)
    .autocorrectionDisabled()

Los modificadores de entrada se heredan por el subárbol, así que aplicarlos a un contenedor afecta a todos los campos que contenga. Es cómodo para desactivar la autocorrección en una sección entera de códigos, y peligroso si en esa sección hay un campo de texto libre que sí se beneficiaba de ella.

El caso de .oneTimeCode merece atención: sin él, el sistema no ofrece el código recibido por mensaje sobre el teclado, y obligas al usuario a memorizar seis dígitos y cambiar de aplicación. Es una línea de código que elimina un abandono medible.

La tecla de retorno como parte del diseño

submitLabel cambia el glifo de la tecla de retorno: .next, .done, .search, .go, .send, .join. No cambia el comportamiento, y ahí está el riesgo: la etiqueta es una promesa que tú debes cumplir en onSubmit. Prometer .next y no mover el foco es peor que no prometer nada.

TextField("Buscar", text: $consulta)
    .submitLabel(.search)
    .onSubmit { ejecutarBusqueda() }
flowchart LR
K[Teclas del usuario] --> S[Cadena en pantalla]
S --> P[ParseStrategy del FormatStyle]
P --> V[Valor tipado del modelo]
V --> F[FormatStyle al mostrar]
F --> S
S --> R[Tecla de retorno]
R --> O[onSubmit ejecuta la promesa]

onSubmit acepta un ámbito: .onSubmit(of: .text) es el predeterminado y .onSubmit(of: .search) responde solo al campo de búsqueda de searchable. Cuando anidas formularios y no quieres que un envío interior burbujee hacia el exterior, .submitScope() corta esa propagación en el subárbol que marques.

Conviene separar dos responsabilidades que a menudo se mezclan en el mismo cierre. onSubmit responde a un gesto de intención —el usuario dio por terminado el campo— y es el sitio del avance de foco o del envío. Los efectos que dependen del valor, en cambio, pertenecen a onChange sobre el estado, porque se disparan escriba el usuario con el teclado, pegue del portapapeles o rellene el sistema por autorrelleno, tres caminos de los que solo el primero pasa por la tecla de retorno.

La compañía del campo de texto

TextField casi nunca viaja solo. Sus parientes cubren dominios que él no puede modelar, y cada uno trae restricciones propias que conviene conocer antes de descubrirlas en pruebas.

SecureField("Contraseña", text: $clave)
    .textContentType(.newPassword)

TextField("Notas", text: $notas, axis: .vertical)
    .lineLimit(3...8)

TextEditor(text: $articulo)
    .frame(minHeight: 200)

SecureField desactiva por su cuenta la corrección y el historial, y con .newPassword habilita la contraseña fuerte sugerida por el sistema, siempre que el campo de identidad contiguo declare .username. El interruptor de mostrar contraseña, tan pedido en diseño, obliga a intercambiar SecureField por TextField: como son vistas de tipos distintos, SwiftUI reconstruye el subárbol y el foco se pierde, así que hay que devolverlo explícitamente tras el cambio.

ℹ️
Texto largo: dos herramientas con contratos opuestos

TextField con axis: .vertical sigue siendo un campo: tiene texto de sugerencia, crece dentro del rango que le des con lineLimit y encaja en una fila de formulario. TextEditor es un área de edición de documento: no ofrece texto de sugerencia, no se autodimensiona y espera que tú le des altura. Para un comentario de tres líneas, el primero; para redactar, el segundo.

Fuera de un Form, un campo suelto no dibuja fondo ni borde, y ahí es donde tiene sentido .textFieldStyle(.roundedBorder). Dentro de un Form, aplicarlo es contraproducente: añade un borde sobre la fila que el contenedor ya estaba dibujando por ti.

Un campo de texto es una frontera entre dos sistemas de tipos

Dentro del campo vive el dominio más permisivo que existe en la aplicación: la cadena arbitraria, sin longitud máxima real, sin alfabeto garantizado, producida por un ser humano que puede estar distraído, en movimiento o usando un teclado que tú nunca has visto. Fuera vive el modelo, que exige un Decimal con dos decimales o una Date dentro de un rango. Toda la dificultad del diseño de formularios se concentra en dónde colocas esa aduana y qué haces con lo que rechazas. Elegir value:format: es delegar la aduana en el sistema y aceptar su política: analizar al confirmar y revertir en silencio si falla; es la opción correcta cuando el valor es intrínsecamente numérico o temporal y el usuario entiende el formato local mejor que cualquier explicación tuya. Elegir text: es quedarte tú la aduana, con la obligación de conservar lo que el usuario escribió aunque no sea válido todavía, porque todavía no es válido y es inválido son estados distintos que solo tú puedes diferenciar. El error conceptual más caro de esta lección es el que comete quien pone .keyboardType(.numberPad) y a continuación escribe Int(texto)! convencido de que el teclado le protege: el teclado es una superficie de sugerencia, no una restricción del lenguaje de entrada, y esa exclamación es una caída en producción esperando a un iPad con teclado externo. La regla, tan simple como incómoda: cada pista de entrada mejora la ergonomía y ninguna sustituye a la validación.

⚔️ Construye una aduana honesta
  1. Escribe una pantalla con tres campos: concepto con text:, importe con value:format: en moneda y unidades con .number.
  2. Escribe letras en el campo de importe, cambia de campo y observa exactamente qué ocurre con lo tecleado; documenta si te parece aceptable para tu usuario.
  3. Añade a un campo de correo keyboardType, textContentType, textInputAutocapitalization y autocorrectionDisabled, y comprueba con un teclado físico que puedes introducir caracteres que el teclado software no ofrecía.
  4. Pon submitLabel(.next) en el primer campo sin implementar nada en onSubmit, prueba la pantalla y anota la sensación de promesa incumplida.
  5. Convierte el campo de importe a text: con análisis propio y decide, con argumentos, cuál de las dos versiones se queda en tu aplicación.