How to implement keyboard handling in SwiftUI
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
-
@FocusState private var focus: SignUpField?— The optional binding lets you express "no field focused" asnil. Setting it tonilfrom anywhere in the view body programmatically dismisses the keyboard without any UIKit plumbing. -
.focused($focus, equals: .email)— Each field registers itself with the shared binding. SwiftUI moves first-responder status automatically wheneverfocuschanges, so you never manually callbecomeFirstResponder(). -
.submitLabel(.next)+.onSubmit—submitLabelchanges the Return key glyph to "Next", "Done", etc. matching user expectations.onSubmitfires when the user taps that key, advancing focus to the next enum case. -
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. -
.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
-
iOS 15 compat:
@FocusStatewas introduced in iOS 15, but the.keyboardtoolbar placement andsubmitLabelmodifier require iOS 15+.textInputAutocapitalizationrequires iOS 15+. All three are available on iOS 17 with no workarounds needed. -
Setting focus in
onAppear: On some simulator builds, writing to a@FocusStatebinding synchronously insideonAppearis ignored because the view hasn't fully entered the hierarchy. Wrap it in a shortTask { @MainActor in focus = .firstName }if the auto-focus doesn't fire reliably. -
Accessibility: Screen readers navigate between fields using swipe gestures, not the Return key, so
onSubmitchaining does not apply to VoiceOver. Always ensure each field has a distinct, descriptive label via theTextFieldplaceholder or.accessibilityLabel(_:)— don't rely solely on positional cues like "Field 1". -
SecureField +
onSubmit:SecureFieldsupportsonSubmitandsubmitLabelexactly likeTextField, but it does not support.focused($focus, equals:)with two-way programmatic typing — you can still focus it, just not inject characters programmatically (by design, for security).
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?
@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?
.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?
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.