```html SwiftUI: How to Implement Keyboard Handling (iOS 17+, 2026)

How to implement keyboard handling in SwiftUI

iOS 17+ Xcode 16+ Intermediate APIs: @FocusState · onSubmit Updated: May 11, 2026
TL;DR

Use @FocusState to track and drive which field is active, then attach .focused(_:equals:) and onSubmit to chain fields and dismiss the keyboard — no UIResponder hacks needed.

enum Field: Hashable { case name, email }

struct LoginForm: View {
    @FocusState private var focus: Field?
    @State private var name = ""
    @State private var email = ""

    var body: some View {
        VStack {
            TextField("Name", text: $name)
                .focused($focus, equals: .name)
                .onSubmit { focus = .email }

            TextField("Email", text: $email)
                .focused($focus, equals: .email)
                .onSubmit { focus = nil }  // dismisses keyboard
        }
    }
}

Full implementation

The pattern below builds a realistic sign-up form with four chained fields: first name, last name, email, and password. Each field advances focus to the next on the Return key; the final field submits the form. A toolbar button gives users a tap target to dismiss the keyboard at any time, and the form disables submission until all fields are non-empty.

import SwiftUI

// 1. One enum to rule all focusable fields
enum SignUpField: Hashable {
    case firstName, lastName, email, password
}

struct SignUpForm: View {
    @FocusState private var focus: SignUpField?

    @State private var firstName  = ""
    @State private var lastName   = ""
    @State private var email      = ""
    @State private var password   = ""
    @State private var submitted  = false

    private var isComplete: Bool {
        [firstName, lastName, email, password].allSatisfy { !$0.isEmpty }
    }

    var body: some View {
        NavigationStack {
            Form {
                Section("Personal") {
                    TextField("First name", text: $firstName)
                        .focused($focus, equals: .firstName)
                        .textContentType(.givenName)
                        .submitLabel(.next)
                        .onSubmit { focus = .lastName }

                    TextField("Last name", text: $lastName)
                        .focused($focus, equals: .lastName)
                        .textContentType(.familyName)
                        .submitLabel(.next)
                        .onSubmit { focus = .email }
                }

                Section("Account") {
                    TextField("Email", text: $email)
                        .focused($focus, equals: .email)
                        .textContentType(.emailAddress)
                        .keyboardType(.emailAddress)
                        .autocorrectionDisabled()
                        .textInputAutocapitalization(.never)
                        .submitLabel(.next)
                        .onSubmit { focus = .password }

                    SecureField("Password", text: $password)
                        .focused($focus, equals: .password)
                        .textContentType(.newPassword)
                        .submitLabel(.done)
                        .onSubmit { attemptSubmit() }
                }

                Section {
                    Button("Create account", action: attemptSubmit)
                        .disabled(!isComplete)
                }
            }
            .navigationTitle("Sign Up")
            // Keyboard toolbar with a Done button
            .toolbar {
                ToolbarItemGroup(placement: .keyboard) {
                    Spacer()
                    Button("Done") { focus = nil }
                }
            }
            .alert("Account created!", isPresented: $submitted) {
                Button("OK", role: .cancel) {}
            }
        }
        // Auto-focus the first field when the view appears
        .onAppear { focus = .firstName }
    }

    private func attemptSubmit() {
        guard isComplete else { return }
        focus = nil          // dismiss keyboard before presenting alert
        submitted = true
    }
}

#Preview {
    SignUpForm()
}

How it works

  1. @FocusState private var focus: SignUpField? — The optional binding lets you express "no field focused" as nil. Setting it to nil from anywhere in the view body programmatically dismisses the keyboard without any UIKit plumbing.
  2. .focused($focus, equals: .email) — Each field registers itself with the shared binding. SwiftUI moves first-responder status automatically whenever focus changes, so you never manually call becomeFirstResponder().
  3. .submitLabel(.next) + .onSubmitsubmitLabel changes the Return key glyph to "Next", "Done", etc. matching user expectations. onSubmit fires when the user taps that key, advancing focus to the next enum case.
  4. Keyboard toolbar with ToolbarItemGroup(placement: .keyboard) — Adds a contextual "Done" button directly above the keyboard. This is the iOS-standard escape hatch when no natural next field exists yet, and it's zero extra views in the hierarchy.
  5. .onAppear { focus = .firstName } — Automatically activates the first field so users can start typing immediately without an extra tap — a small but measurable conversion improvement on sign-up forms.

Variants

Boolean focus (single field)

When you only have one text field, skip the enum and use a plain Bool binding — cleaner and less ceremony.

struct SearchBar: View {
    @Binding var query: String
    @FocusState private var isActive: Bool

    var body: some View {
        HStack {
            TextField("Search…", text: $query)
                .focused($isActive)
                .submitLabel(.search)
                .onSubmit { isActive = false }

            if isActive {
                Button("Cancel") {
                    query = ""
                    isActive = false
                }
                .transition(.move(edge: .trailing).combined(with: .opacity))
            }
        }
        .animation(.easeInOut(duration: 0.2), value: isActive)
    }
}

Scroll to keep focused field visible

When your form is inside a ScrollView, use ScrollViewReader with a .onChange(of: focus) handler to call proxy.scrollTo(focus, anchor: .center). This prevents the keyboard from obscuring the active field — pair it with .id(SignUpField.email) on each row to give the proxy a target. In a plain Form or List, SwiftUI handles this automatically.

Common pitfalls

Prompt this with Claude Code

When using Soarias or Claude Code directly to implement this:

Implement keyboard handling in SwiftUI for iOS 17+.
Use @FocusState and onSubmit.
Chain fields in order: firstName → lastName → email → password → submit.
Add a keyboard toolbar "Done" button that sets focus to nil.
Make it accessible (VoiceOver labels, accessibilityHint on password).
Add a #Preview with realistic sample data.

Drop this prompt into Soarias during the Build phase after your screen mockups are approved — Claude Code will wire up the focus graph and generate a preview-ready form component you can slot straight into your feature branch.

Related

FAQ

Does this work on iOS 16?
Yes — @FocusState and onSubmit shipped in iOS 15, so both work on iOS 16 without modification. The .keyboard toolbar placement also works from iOS 15+. This guide targets iOS 17+ because that's where you get the full suite of related modifiers (textInputAutocapitalization, scrollDismissesKeyboard) without any availability guards.
How do I dismiss the keyboard when the user taps outside a field?
Add .scrollDismissesKeyboard(.interactively) to any surrounding ScrollView or List — this gives the swipe-to-dismiss gesture that users expect on iOS. For non-scrollable layouts, add a tap gesture on the background: .onTapGesture { focus = nil } on the container view, but make sure it doesn't swallow taps from child controls by using .contentShape(Rectangle()) carefully.
What's the UIKit equivalent?
In UIKit, you'd implement UITextFieldDelegate.textFieldShouldReturn(_:) and call nextField.becomeFirstResponder() or resignFirstResponder() manually. You'd also wire up a UIToolbar as the inputAccessoryView for the Done button. SwiftUI's @FocusState + onSubmit collapses all of that into a declarative binding — roughly 80% less code for the same behaviour.

Last reviewed: 2026-05-11 by the Soarias team.

```