- Published on
Managing Keyboard Toolbar Items in SwiftUI (iOS 15+)
- Authors

- Name
- Mick MacCallum
- @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
UITextFieldorUITextViewwithinputAccessoryView(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:
.toolbar: Declares toolbar items for the viewToolbarItemGroup(placement: .keyboard): Places items above the keyboard@FocusState: Tracks and controls keyboard focus- 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
}
}
}
}
}
Navigation Between Fields
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.
Continue Learning
Creating an iMessage-Style Input Accessory View in SwiftUI
Learn how to create a custom input accessory view that stays above the keyboard, similar to iMessage's input bar.
Adding a character limit to a TextEditor or TextField in SwiftUI
Learn how to create a modifier that limits the characters that can be entered in a TextEditor or TextField view in SwiftUI.
Building a Floating Action Button (FAB) that Respects Keyboard in SwiftUI
Learn how to create a Material Design-style floating action button in SwiftUI that intelligently moves above the keyboard and avoids blocking text input fields.
