Keyboard handling in SwiftUI in Swift, SwiftUI & iOS Apps
Text input is everywhere in modern apps, but poor keyboard handling can make even simple forms frustrating. When users tap a text field, we need to guide focus smoothly, dismiss the keyboard at the right moment, and keep content visible. This article walks through keyboard handling in SwiftUI, from managing focus state to scrolling views so users never lose sight of what they're typing.
Why keyboard handling matters
On iOS, the software keyboard takes up roughly 40% of the screen. Without intentional design, it masks form fields, buttons, and input validation messages. Users expect focus to move automatically between fields, the keyboard to disappear when they finish typing, and the interface to adjust so nothing gets hidden. Getting this right feels invisible to users—but getting it wrong frustrates them immediately.
Using @FocusState to move focus
The @FocusState property wrapper gives us programmatic control over which field owns the keyboard focus. Instead of relying on the system's default behavior, we track the active field and move focus as needed.
Here's a practical example: a sign-up form where focus moves from email to password to confirmation password:
import SwiftUI
struct SignUpForm: View {
@State private var email = ""
@State private var password = ""
@State private var confirmPassword = ""
enum FocusedField {
case email
case password
case confirmPassword
}
@FocusState private var focusedField: FocusedField?
var body: some View {
VStack(spacing: 16) {
TextField("Email", text: $email)
.focused($focusedField, equals: .email)
.textContentType(.emailAddress)
.keyboardType(.emailAddress)
.autocorrectionDisabled()
SecureField("Password", text: $password)
.focused($focusedField, equals: .password)
.textContentType(.newPassword)
SecureField("Confirm password", text: $confirmPassword)
.focused($focusedField, equals: .confirmPassword)
.textContentType(.newPassword)
Button("Create account") {
if validateForm() {
submitForm()
}
}
.disabled(!isFormValid())
}
.padding()
.onAppear {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
focusedField = .email
}
}
}
private func validateForm() -> Bool {
!email.isEmpty && !password.isEmpty && password == confirmPassword
}
private func isFormValid() -> Bool {
validateForm() && password.count >= 8
}
private func submitForm() {
// Handle submission
}
}In this pattern, we define an enum FocusedField with a case for each field. The @FocusState variable holds the currently focused field as an optional. We attach .focused($focusedField, equals: .email) to each field, binding it to the state. When we set focusedField = .password, focus moves to the password field automatically, and the keyboard follows.
The onAppear block with a small delay ensures the view hierarchy is fully laid out before we try to set focus—otherwise the binding may not find the field.
submitLabel and onSubmit for keyboard actions
When users tap the Return key on the keyboard, we want to move to the next field or submit the form. The submitLabel modifier changes what text appears on the Return button, and onSubmit runs code when the user taps it.
Let's extend our form to move focus when users press Return:
struct SignUpForm: View {
@State private var email = ""
@State private var password = ""
@State private var confirmPassword = ""
enum FocusedField {
case email
case password
case confirmPassword
}
@FocusState private var focusedField: FocusedField?
var body: some View {
VStack(spacing: 16) {
TextField("Email", text: $email)
.focused($focusedField, equals: .email)
.textContentType(.emailAddress)
.keyboardType(.emailAddress)
.submitLabel(.next)
.onSubmit {
focusedField = .password
}
SecureField("Password", text: $password)
.focused($focusedField, equals: .password)
.textContentType(.newPassword)
.submitLabel(.next)
.onSubmit {
focusedField = .confirmPassword
}
SecureField("Confirm password", text: $confirmPassword)
.focused($focusedField, equals: .confirmPassword)
.textContentType(.newPassword)
.submitLabel(.done)
.onSubmit {
focusedField = nil
submitForm()
}
Button("Create account") {
submitForm()
}
}
.padding()
.onAppear {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
focusedField = .email
}
}
}
private func submitForm() {
// Handle submission
}
}Setting focusedField = nil dismisses the keyboard. The submitLabel modifier offers options like .next, .done, .send, and .search, giving users a visual hint about what happens when they tap Return.
Toolbar items above the keyboard
iOS displays an accessory bar above the keyboard for tools like "Next," "Previous," and "Done." We can add custom views using the toolbar modifier with .keyboard placement.
This is useful for validation messages, character counters, or quick actions:
struct CommentField: View {
@State private var text = ""
@FocusState private var isFocused: Bool
let characterLimit = 280
var body: some View {
VStack {
TextEditor(text: $text)
.focused($isFocused)
.frame(minHeight: 100)
.border(Color.gray)
Text("Comment")
.font(.caption)
.foregroundColor(.gray)
}
.toolbar {
ToolbarItemGroup(placement: .keyboard) {
Spacer()
HStack(spacing: 12) {
Text("\(text.count) / \(characterLimit)")
.font(.caption)
.foregroundColor(
text.count > characterLimit ? .red : .gray
)
Button("Clear") {
text = ""
}
.disabled(text.isEmpty)
Button("Done") {
isFocused = false
}
}
}
}
}
}The .keyboard placement positions views in the accessory bar above the keyboard. We often add a Spacer() to push buttons to the right. This gives us quick access to common actions without cluttering the main interface.
Scroll to focused fields
When a focused text field sits near the bottom of a scrollable view, the keyboard may hide it. We need to scroll the view so the field stays visible. SwiftUI provides scrollDismissesKeyboard to control whether scrolling dismisses the keyboard, and we can manually scroll to a field using ScrollViewReader.
Here's a long form where we scroll to the focused field:
struct LongForm: View {
@State private var formData: [String] = Array(repeating: "", count: 10)
@FocusState private var focusedIndex: Int?
var body: some View {
ScrollViewReader { scrollProxy in
VStack(spacing: 16) {
Text("Detailed Information")
.font(.title2)
ForEach(0..We wrap the content in a ScrollViewReader and give each field an id. When focusedIndex changes, we call scrollProxy.scrollTo() to center the focused field on screen. The onChange modifier watches for focus changes and triggers the scroll. Setting scrollDismissesKeyboard(.interactively) allows users to scroll and dismiss the keyboard with a single gesture.
Common mistakes
- Setting focus without delay: Calling
focusedField = .emailinonAppearsynchronously may fail because the view tree isn't fully laid out. Always use a smallDispatchQueue.main.asyncAfterdelay. - Forgetting textContentType: Omitting
.textContentType()prevents autofill and password managers from working. Use.emailAddress,.newPassword,.telephoneNumber, etc., to help both users and security tools. - Not disabling autocorrection where it hurts: Email fields, usernames, and technical input often benefit from
.autocorrectionDisabled(). Passwords never need autocorrection. - Ignoring scrollDismissesKeyboard: By default, scrolling doesn't dismiss the keyboard in SwiftUI. Users expect this behavior, so set
.scrollDismissesKeyboard(.interactively)on scroll views containing text fields. - Over-automating focus: Moving focus automatically between fields feels slick but can confuse users. Reserve auto-focus for the first field on app launch; let users move between fields with Return or taps.
Key takeaways
@FocusStategives us programmatic control over keyboard focus. Define an enum for each field, bind with.focused($state, equals: .field), and set the state to move focus.- Use
submitLabelandonSubmitto guide users through multi-field forms. Return key actions should move focus forward or dismiss the keyboard at the end. - Toolbar items with
.keyboardplacement add quick actions and validation feedback above the keyboard without cluttering the main UI. - Always scroll to focused fields in long forms. Use
ScrollViewReaderwithonChangeto center the active field and prevent the keyboard from hiding input. - Combine focus management with proper
textContentTypeto enable autofill and password managers, improving both user experience and security. - Test keyboard behavior on actual devices—the simulator's keyboard behaves differently and may hide issues with scrolling and focus timing.






Comments
Loading comments…