Posts

Controlando el foco en SwiftUI con @FocusState

Arturo Rivas Arias

El foco parece un detalle menor hasta que construimos un formulario con varios campos de texto. Entonces aparecen necesidades muy concretas: colocar automáticamente el cursor en un campo, moverlo al siguiente al pulsar Next, volver al dato que contiene un error o cerrar el teclado cuando la edición termina. Desde iOS 15, SwiftUI permite resolver todos estos casos de forma declarativa mediante @FocusState.

La idea encaja perfectamente con el modelo de SwiftUI. En lugar de pedir directamente a un UITextField que se convierta en first responder, declaramos qué elemento debería tener el foco en cada momento y dejamos que el framework sincronice ese estado con la interfaz. Además, la relación funciona en ambas direcciones: si el usuario toca un campo, @FocusState se actualiza; si modificamos el estado desde nuestro código, SwiftUI mueve el foco a la vista correspondiente.

El caso más sencillo: un único campo

Para controlar una sola vista podemos utilizar un Bool.

struct CouponView: View {
    @State private var code = ""
    @FocusState private var isCodeFocused: Bool

    var body: some View {
        VStack(spacing: 16) {
            TextField("Código de descuento", text: $code)
                .textFieldStyle(.roundedBorder)
                .focused($isCodeFocused)

            Button("Introducir código") {
                isCodeFocused = true
            }
        }
        .padding()
    }
}

El modificador .focused(_:) enlaza el estado de foco del TextField con isCodeFocused. Si el usuario toca el campo, la propiedad pasa a valer true. Y si pulsamos el botón y asignamos true desde código, SwiftUI coloca el foco sobre el campo y muestra el teclado.

Este comportamiento bidireccional es lo que hace que @FocusState resulte mucho más cómodo que las soluciones que utilizábamos con UIKit. No necesitamos acceder a la instancia interna del control ni coordinar llamadas a becomeFirstResponder() y resignFirstResponder().

Quitar el foco y cerrar el teclado

El mismo estado nos sirve para hacer la operación contraria.

Button("Aplicar") {
    isCodeFocused = false
    applyCoupon(code)
}

Al establecer el valor en false, ninguna vista vinculada a ese estado debe conservar el foco. Si el elemento activo es un campo de texto, el teclado desaparece automáticamente.

También podemos utilizarlo para cerrar el teclado al tocar una zona determinada de la interfaz:

VStack {
    TextField("Código de descuento", text: $code)
        .focused($isCodeFocused)

    Spacer()
}
.contentShape(Rectangle())
.onTapGesture {
    isCodeFocused = false
}

Conviene no confundir este mecanismo con @Environment(\.dismiss). dismiss sirve para cerrar una presentación, como una vista o una pantalla presentada, mientras que @FocusState controla qué elemento recibe la entrada del usuario.

Varios campos: un enum es mucho mejor que varios booleanos

Cuando una pantalla contiene más de un campo podríamos crear un Bool para cada uno, pero enseguida acabaríamos manteniendo estados que conceptualmente son excluyentes. Normalmente solo uno de los campos puede tener el foco a la vez.

SwiftUI permite representar esa situación con cualquier tipo que conforme a Hashable. Un enum opcional encaja especialmente bien:

struct ShippingView: View {
    enum Field: Hashable {
        case recipient
        case street
        case postalCode
    }

    @State private var recipient = ""
    @State private var street = ""
    @State private var postalCode = ""

    @FocusState private var focusedField: Field?

    var body: some View {
        Form {
            TextField("Destinatario", text: $recipient)
                .focused($focusedField, equals: .recipient)

            TextField("Dirección", text: $street)
                .focused($focusedField, equals: .street)

            TextField("Código postal", text: $postalCode)
                .keyboardType(.numberPad)
                .focused($focusedField, equals: .postalCode)
        }
    }
}

Cada control asocia uno de los casos mediante .focused(_:equals:). Cuando el campo de dirección está activo, por ejemplo, focusedField vale .street. Cuando ninguno de ellos tiene el foco, el valor es nil.

Que la propiedad sea opcional no es accidental. El estado debe poder representar también el caso en el que ninguna de las vistas asociadas recibe entrada.

Mover el foco de forma programática

Una vez que el foco forma parte del estado de la vista, moverlo es tan sencillo como cambiar un valor.

Button("Continuar") {
    if recipient.isEmpty {
        focusedField = .recipient
    } else if street.isEmpty {
        focusedField = .street
    } else if postalCode.isEmpty {
        focusedField = .postalCode
    } else {
        focusedField = nil
        saveAddress()
    }
}

Este patrón resulta especialmente útil durante la validación. En lugar de mostrar únicamente un mensaje de error, podemos llevar al usuario directamente al primer campo que necesita corregir.

También podemos cerrar el teclado de forma explícita asignando nil:

focusedField = nil

La ventaja es que nuestro código no necesita saber qué control concreto está activo. Solo declara que a partir de ese momento ninguno debería tener el foco.

Combinar @FocusState con submitLabel y onSubmit

Una experiencia mucho más cuidada consiste en utilizar el botón de retorno del teclado para avanzar por el formulario. submitLabel(_:) permite definir la acción semántica mostrada por el teclado y onSubmit nos deja reaccionar cuando el usuario la ejecuta.

TextField("Destinatario", text: $recipient)
    .focused($focusedField, equals: .recipient)
    .submitLabel(.next)
    .onSubmit {
        focusedField = .street
    }

TextField("Dirección", text: $street)
    .focused($focusedField, equals: .street)
    .submitLabel(.next)
    .onSubmit {
        focusedField = .postalCode
    }

TextField("Código postal", text: $postalCode)
    .focused($focusedField, equals: .postalCode)
    .submitLabel(.done)
    .onSubmit {
        focusedField = nil
        saveAddress()
    }

De esta forma, el teclado deja de ser un elemento pasivo y se convierte en parte natural de la navegación del formulario. En los dos primeros campos mostramos una acción de continuación y en el último indicamos que la edición ha terminado.

El foco también puede afectar al aspecto de la vista

@FocusState no solo sirve para mover el cursor. Como podemos leer su valor, también podemos modificar el aspecto de una vista en función del elemento activo.

TextField("Dirección", text: $street)
    .padding(12)
    .background {
        RoundedRectangle(cornerRadius: 10)
            .stroke(
                focusedField == .street ? .tint : .secondary,
                lineWidth: focusedField == .street ? 2 : 1
            )
    }
    .focused($focusedField, equals: .street)

Esto permite crear componentes personalizados que reaccionan al foco sin mantener una segunda propiedad duplicada para saber si el usuario está editando el control.

Pasar el foco a componentes reutilizables

En una pantalla real es habitual extraer los campos a vistas independientes. En ese caso conviene recordar que $focusedField no es un Binding convencional, sino un FocusState<Value>.Binding.

Podemos recibirlo directamente en nuestro componente:

enum ProfileField: Hashable {
    case displayName
    case biography
}

struct ProfileTextField: View {
    let title: String
    let field: ProfileField
    @Binding var text: String

    var focus: FocusState<ProfileField?>.Binding

    var body: some View {
        TextField(title, text: $text)
            .focused(focus, equals: field)
    }
}

Y utilizarlo desde la vista propietaria del estado:

struct EditProfileView: View {
    @State private var displayName = ""
    @State private var biography = ""
    @FocusState private var focusedField: ProfileField?

    var body: some View {
        VStack {
            ProfileTextField(
                title: "Nombre visible",
                field: .displayName,
                text: $displayName,
                focus: $focusedField
            )

            ProfileTextField(
                title: "Biografía",
                field: .biography,
                text: $biography,
                focus: $focusedField
            )
        }
        .padding()
    }
}

Igual que ocurre con @State, lo más limpio suele ser mantener la propiedad @FocusState en la vista que realmente posee la lógica de navegación y pasar únicamente su binding a los componentes que lo necesitan.

Evita bindings de foco ambiguos

⚠️ Cada caso debería identificar de manera inequívoca una posición de foco. Reutilizar el mismo valor para dos vistas distintas puede provocar resultados inesperados cuando movemos el foco desde código.

enum Field: Hashable {
    case title
}

@FocusState private var focusedField: Field?

var body: some View {
    VStack {
        TextField("Título corto", text: $shortTitle)
            .focused($focusedField, equals: .title)

        TextField("Título completo", text: $fullTitle)
            .focused($focusedField, equals: .title) // ❌ Ambiguo
    }
}

Si el usuario toca cualquiera de los dos campos, SwiftUI puede actualizar correctamente el estado a .title. El problema aparece cuando hacemos lo contrario y asignamos .title programáticamente: existen dos candidatos posibles para recibir el foco. La solución es utilizar un caso diferente para cada posición.

enum Field: Hashable {
    case shortTitle
    case fullTitle
}

@FocusState no es solo una API para el teclado

Aunque los TextField son el ejemplo más evidente en iPhone, el sistema de foco de SwiftUI tiene un alcance mayor. El foco identifica qué elemento recibe la siguiente entrada y también resulta importante al utilizar teclado físico, mandos, Apple TV, iPad, Mac o determinados mecanismos de interacción de accesibilidad.

Por eso SwiftUI separa el concepto de foco del teclado en pantalla. APIs como focusable(), focusScope(_:), defaultFocus o searchFocused(_:) forman parte del mismo sistema y permiten construir interfaces mucho más sofisticadas cuando el dispositivo no se controla exclusivamente mediante gestos táctiles.

Un caso especialmente útil es searchFocused(_:), que aplica el mismo principio al campo generado por .searchable. Podemos observar si la búsqueda está activa o activarla desde código sin necesitar acceso directo al control que SwiftUI crea internamente.

struct CatalogView: View {
    @State private var query = ""
    @FocusState private var isSearchFocused: Bool

    var body: some View {
        NavigationStack {
            ProductList(query: query)
                .searchable(text: $query)
                .searchFocused($isSearchFocused)
                .toolbar {
                    Button("Buscar") {
                        isSearchFocused = true
                    }
                }
        }
    }
}

Conclusión

@FocusState convierte algo históricamente imperativo como el first responder en una pieza más del estado declarativo de SwiftUI. Para un único elemento, un Bool suele ser suficiente. Cuando existen varios candidatos, un enum opcional ofrece una representación mucho más clara y escalable.

A partir de ahí podemos mover el foco durante la validación, encadenar campos con onSubmit, cerrar el teclado asignando nil, adaptar la apariencia de los controles y reutilizar la misma lógica entre componentes. El resultado es menos código específico de plataforma y una interfaz cuyo comportamiento de entrada queda descrito directamente por su estado.