Skip to content
ExponaBLOG
SWIFT, SWIFTUI & IOS APPS · DAY 095 / 100

Keyboard handling in SwiftUI in Swift, SwiftUI & iOS Apps

Smooth text input.

Expona Admin7 min read

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:

swift
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:

swift
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:

swift
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:

swift
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 = .email in onAppear synchronously may fail because the view tree isn't fully laid out. Always use a small DispatchQueue.main.asyncAfter delay.
  • 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

  • @FocusState gives 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 submitLabel and onSubmit to guide users through multi-field forms. Return key actions should move focus forward or dismiss the keyboard at the end.
  • Toolbar items with .keyboard placement add quick actions and validation feedback above the keyboard without cluttering the main UI.
  • Always scroll to focused fields in long forms. Use ScrollViewReader with onChange to center the active field and prevent the keyboard from hiding input.
  • Combine focus management with proper textContentType to 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.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles