avatar
Published on

Managing Keyboard Toolbar Items in SwiftUI (iOS 15+)

Authors
  • avatar
    Name
    Mick MacCallum
    Twitter
    @0x7fs

Starting in iOS 15, SwiftUI introduced .toolbar with keyboard placement, giving you native control over the accessory bar that appears above the keyboard. This is perfect for adding "Done" buttons, text formatting controls, or custom actions without the complexity of UIKit's input accessory views.

The keyboard toolbar is cleaner and more integrated than older approaches, automatically handling keyboard appearance animations and safe area adjustments.

The Traditional Problem

Before iOS 15, adding buttons above the keyboard required either:

  • Using UITextField or UITextView with inputAccessoryView (UIKit bridge)
  • Complex positioning with keyboard notification observers
  • Third-party libraries

All of these approaches were cumbersome and didn't feel native to SwiftUI.

The Modern Solution: Keyboard Toolbar

iOS 15+ provides .toolbar with .keyboard placement:

import SwiftUI

struct KeyboardToolbarExample: View {
    @State private var text = ""
    @FocusState private var isFocused: Bool

    var body: some View {
        TextEditor(text: $text)
            .focused($isFocused)
            .toolbar {
                ToolbarItemGroup(placement: .keyboard) {
                    Spacer()

                    Button("Done") {
                        isFocused = false
                    }
                    .bold()
                }
            }
            .padding()
    }
}

How It Works

The keyboard toolbar uses SwiftUI's toolbar system:

  1. .toolbar: Declares toolbar items for the view
  2. ToolbarItemGroup(placement: .keyboard): Places items above the keyboard
  3. @FocusState: Tracks and controls keyboard focus
  4. Automatic animations: SwiftUI handles appearance/dismissal

The toolbar only appears when a focused text field brings up the keyboard.

Done Button Pattern

The most common use case - dismissing the keyboard:

struct DoneButtonExample: View {
    @State private var email = ""
    @State private var password = ""
    @FocusState private var focusedField: Field?

    enum Field {
        case email, password
    }

    var body: some View {
        Form {
            TextField("Email", text: $email)
                .focused($focusedField, equals: .email)
                .textContentType(.emailAddress)
                .autocapitalization(.none)

            SecureField("Password", text: $password)
                .focused($focusedField, equals: .password)
                .textContentType(.password)
        }
        .toolbar {
            ToolbarItemGroup(placement: .keyboard) {
                Spacer()

                Button("Done") {
                    focusedField = nil
                }
                .foregroundColor(.blue)
            }
        }
    }
}

Multiple Toolbar Items

Add several buttons to the keyboard toolbar:

struct RichTextEditor: View {
    @State private var text = ""
    @FocusState private var isFocused: Bool

    var body: some View {
        VStack {
            TextEditor(text: $text)
                .focused($isFocused)
                .border(Color.gray.opacity(0.3), width: 1)
                .frame(height: 200)
                .padding()

            Spacer()
        }
        .toolbar {
            ToolbarItemGroup(placement: .keyboard) {
                // Formatting buttons
                Button {
                    insertMarkdown("**")
                } label: {
                    Image(systemName: "bold")
                }

                Button {
                    insertMarkdown("*")
                } label: {
                    Image(systemName: "italic")
                }

                Button {
                    insertMarkdown("`")
                } label: {
                    Image(systemName: "chevron.left.forwardslash.chevron.right")
                }

                Spacer()

                // Done button
                Button("Done") {
                    isFocused = false
                }
                .bold()
            }
        }
    }

    func insertMarkdown(_ marker: String) {
        text += marker
    }
}

Field-Specific Toolbars

Show different toolbar buttons based on which field is focused:

struct ConditionalToolbar: View {
    @State private var title = ""
    @State private var amount = ""
    @State private var notes = ""
    @FocusState private var focusedField: Field?

    enum Field {
        case title, amount, notes
    }

    var body: some View {
        Form {
            Section("Expense Details") {
                TextField("Title", text: $title)
                    .focused($focusedField, equals: .title)

                TextField("Amount", text: $amount)
                    .focused($focusedField, equals: .amount)
                    .keyboardType(.decimalPad)

                TextEditor(text: $notes)
                    .focused($focusedField, equals: .notes)
                    .frame(height: 100)
            }
        }
        .toolbar {
            ToolbarItemGroup(placement: .keyboard) {
                // Show number shortcuts only for amount field
                if focusedField == .amount {
                    HStack(spacing: 12) {
                        ForEach(["10", "25", "50", "100"], id: \.self) { value in
                            Button(value) {
                                amount = value
                            }
                            .buttonStyle(.bordered)
                        }
                    }
                }

                Spacer()

                Button("Done") {
                    focusedField = nil
                }
            }
        }
    }
}

Add Previous/Next buttons to move between text fields:

struct NavigableFields: View {
    @State private var field1 = ""
    @State private var field2 = ""
    @State private var field3 = ""
    @FocusState private var focusedField: Field?

    enum Field: Int, CaseIterable {
        case field1, field2, field3
    }

    var body: some View {
        Form {
            TextField("First Name", text: $field1)
                .focused($focusedField, equals: .field1)

            TextField("Last Name", text: $field2)
                .focused($focusedField, equals: .field2)

            TextField("Email", text: $field3)
                .focused($focusedField, equals: .field3)
        }
        .toolbar {
            ToolbarItemGroup(placement: .keyboard) {
                Button {
                    moveToPrevious()
                } label: {
                    Image(systemName: "chevron.up")
                }
                .disabled(!hasPrevious)

                Button {
                    moveToNext()
                } label: {
                    Image(systemName: "chevron.down")
                }
                .disabled(!hasNext)

                Spacer()

                Button("Done") {
                    focusedField = nil
                }
            }
        }
    }

    var hasPrevious: Bool {
        guard let current = focusedField else { return false }
        return current.rawValue > 0
    }

    var hasNext: Bool {
        guard let current = focusedField else { return false }
        return current.rawValue < Field.allCases.count - 1
    }

    func moveToPrevious() {
        guard let current = focusedField,
              current.rawValue > 0,
              let previous = Field(rawValue: current.rawValue - 1) else { return }
        focusedField = previous
    }

    func moveToNext() {
        guard let current = focusedField,
              current.rawValue < Field.allCases.count - 1,
              let next = Field(rawValue: current.rawValue + 1) else { return }
        focusedField = next
    }
}

Styled Toolbar Buttons

Customize the appearance of toolbar buttons:

struct StyledToolbar: View {
    @State private var text = ""
    @FocusState private var isFocused: Bool

    var body: some View {
        TextEditor(text: $text)
            .focused($isFocused)
            .toolbar {
                ToolbarItemGroup(placement: .keyboard) {
                    Button {
                        // Action
                    } label: {
                        Label("Bold", systemImage: "bold")
                            .labelStyle(.iconOnly)
                    }
                    .buttonStyle(.bordered)
                    .tint(.blue)

                    Button {
                        // Action
                    } label: {
                        Label("Italic", systemImage: "italic")
                            .labelStyle(.iconOnly)
                    }
                    .buttonStyle(.bordered)
                    .tint(.blue)

                    Spacer()

                    Button("Done") {
                        isFocused = false
                    }
                    .buttonStyle(.borderedProminent)
                }
            }
            .padding()
    }
}

Clear Text Button

Add a button to quickly clear the text field:

struct ClearableTextField: View {
    @State private var searchText = ""
    @FocusState private var isFocused: Bool

    var body: some View {
        TextField("Search", text: $searchText)
            .focused($isFocused)
            .textFieldStyle(.roundedBorder)
            .padding()
            .toolbar {
                ToolbarItemGroup(placement: .keyboard) {
                    Button("Clear") {
                        searchText = ""
                    }
                    .disabled(searchText.isEmpty)

                    Spacer()

                    Button("Done") {
                        isFocused = false
                    }
                }
            }
    }
}

Counter Display

Show character count or word count:

struct CounterToolbar: View {
    @State private var text = ""
    @FocusState private var isFocused: Bool

    let maxCharacters = 280

    var remainingCharacters: Int {
        maxCharacters - text.count
    }

    var counterColor: Color {
        switch remainingCharacters {
        case ..<0: return .red
        case 0..<20: return .orange
        default: return .secondary
        }
    }

    var body: some View {
        VStack {
            TextEditor(text: $text)
                .focused($isFocused)
                .border(Color.gray.opacity(0.3), width: 1)
                .padding()

            Spacer()
        }
        .toolbar {
            ToolbarItemGroup(placement: .keyboard) {
                Text("\(remainingCharacters)")
                    .foregroundColor(counterColor)
                    .font(.caption)
                    .monospacedDigit()

                Spacer()

                Button("Post") {
                    submitPost()
                }
                .disabled(text.isEmpty || remainingCharacters < 0)
                .bold()
            }
        }
    }

    func submitPost() {
        // Submit the post
        isFocused = false
    }
}

Integration with TextEditor

TextEditor works seamlessly with keyboard toolbars:

struct NoteEditor: View {
    @State private var note = ""
    @FocusState private var isEditing: Bool

    var body: some View {
        NavigationView {
            TextEditor(text: $note)
                .focused($isEditing)
                .padding()
                .navigationTitle("New Note")
                .toolbar {
                    ToolbarItemGroup(placement: .keyboard) {
                        Button {
                            insertTimestamp()
                        } label: {
                            Image(systemName: "clock")
                        }

                        Spacer()

                        Button("Done") {
                            isEditing = false
                        }
                    }
                }
        }
    }

    func insertTimestamp() {
        let timestamp = Date().formatted(date: .abbreviated, time: .shortened)
        note += "\n\(timestamp): "
    }
}

Important Notes

iOS Version: The .keyboard placement requires iOS 15+. For earlier versions, you'll need UIKit input accessory views.

Focus Management: Always use @FocusState to properly manage keyboard focus. Without it, you can't programmatically dismiss the keyboard.

Toolbar Persistence: The toolbar appears only when the keyboard is visible. It automatically animates in and out with the keyboard.

Multiple Fields: When you have multiple text fields, the keyboard toolbar is shared across all focused fields unless you conditionally change its content.

Testing: Test on actual devices to ensure toolbar button sizes are comfortable for touch targets (minimum 44x44 points).

Best Practices

Do:

  • Keep toolbar buttons concise (3-5 buttons maximum)
  • Always include a "Done" button for dismissing the keyboard
  • Use SF Symbols for icon buttons
  • Provide visual feedback (disabled states, colors)

Don't:

  • Overcrowd the toolbar with too many buttons
  • Use text labels that are too long
  • Forget to handle keyboard dismissal
  • Rely on toolbar for critical functionality (it's hidden when keyboard is dismissed)

The keyboard toolbar is a clean, native way to enhance text input in your SwiftUI apps without the complexity of UIKit bridges.