Keyboard Navigation
Keyboard navigation enables users to navigate and interact with your app using only a keyboard or assistive input devices. In this post, we'll explore how to implement comprehensive keyboard navigation support in SwiftUI, making your apps accessible to users with motor impairments, power users, and anyone who prefers keyboard-based interaction.
What is Keyboard Navigation?
Keyboard navigation allows users to navigate through an app using keyboard keys (Tab, arrow keys, Enter, etc.) or external assistive devices like switch controls, rather than touch or mouse input. This is essential for:
-
Users with motor impairments who have difficulty with precise touch or mouse movements
-
Switch control users who navigate using adaptive switches
-
Power users who prefer keyboard efficiency
-
iPad users with external keyboards who want a desktop-like experience
-
Users with tremors or limited dexterity
Good keyboard navigation means users can access every interactive element, understand where focus is at all times, and complete all tasks without needing touch or mouse input.
Understanding Focus in SwiftUI
Focus determines which element receives keyboard input. SwiftUI provides several tools for managing focus:
-
@FocusState: Property wrapper to track and control focus state -
.focused(_:equals:): Modifier to bind focus to a specific value -
.focusable(): Makes non-standard elements focusable -
Automatic tab order: SwiftUI automatically creates a logical tab order
Basic Focus Management
import SwiftUI
struct BasicFocusExample: View {
enum Field: Hashable {
case username
case password
case email
}
@FocusState private var focusedField: Field?
@State private var username = ""
@State private var password = ""
@State private var email = ""
var body: some View {
Form {
Section {
TextField("Username", text: $username)
.focused($focusedField, equals: .username)
.textContentType(.username)
SecureField("Password", text: $password)
.focused($focusedField, equals: .password)
.textContentType(.password)
TextField("Email", text: $email)
.focused($focusedField, equals: .email)
.textContentType(.emailAddress)
.keyboardType(.emailAddress)
}
Section {
Button("Sign Up") {
handleSignUp()
}
}
}
.onAppear {
// Set initial focus
focusedField = .username
}
}
func handleSignUp() {
// Validate and process
if username.isEmpty {
focusedField = .username
} else if password.isEmpty {
focusedField = .password
} else if email.isEmpty {
focusedField = .email
}
}
}
Programmatic Focus Control
struct FocusControlExample: View {
enum FormField: Hashable {
case field1, field2, field3
}
@FocusState private var focusedField: FormField?
@State private var field1Text = ""
@State private var field2Text = ""
@State private var field3Text = ""
var body: some View {
VStack(spacing: 20) {
TextField("Field 1", text: $field1Text)
.focused($focusedField, equals: .field1)
.textFieldStyle(.roundedBorder)
.onSubmit {
focusedField = .field2
}
TextField("Field 2", text: $field2Text)
.focused($focusedField, equals: .field2)
.textFieldStyle(.roundedBorder)
.onSubmit {
focusedField = .field3
}
TextField("Field 3", text: $field3Text)
.focused($focusedField, equals: .field3)
.textFieldStyle(.roundedBorder)
.onSubmit {
submitForm()
}
HStack(spacing: 16) {
Button("Previous") {
moveFocusToPrevious()
}
.keyboardShortcut(.upArrow, modifiers: .command)
Button("Next") {
moveFocusToNext()
}
.keyboardShortcut(.downArrow, modifiers: .command)
Button("Submit") {
submitForm()
}
.keyboardShortcut(.return, modifiers: .command)
}
}
.padding()
}
func moveFocusToPrevious() {
switch focusedField {
case .field2:
focusedField = .field1
case .field3:
focusedField = .field2
default:
break
}
}
func moveFocusToNext() {
switch focusedField {
case .field1:
focusedField = .field2
case .field2:
focusedField = .field3
default:
break
}
}
func submitForm() {
print("Form submitted")
}
}
Making Custom Views Focusable
Not all views are focusable by default. Use .focusable() to make custom views keyboard-navigable:
struct CustomButton: View {
let title: String
let action: () -> Void
@FocusState private var isFocused: Bool
var body: some View {
Text(title)
.padding()
.background(isFocused ? Color.blue : Color.gray)
.foregroundColor(.white)
.cornerRadius(8)
.focusable()
.focused($isFocused)
.onTapGesture {
action()
}
.onKeyPress(.space) {
action()
return .handled
}
.onKeyPress(.return) {
action()
return .handled
}
}
}
struct CustomFocusableView: View {
var body: some View {
VStack(spacing: 20) {
CustomButton(title: "First Button") {
print("First tapped")
}
CustomButton(title: "Second Button") {
print("Second tapped")
}
CustomButton(title: "Third Button") {
print("Third tapped")
}
}
.padding()
}
}
Keyboard Shortcuts
Enhance keyboard navigation with shortcuts for common actions:
struct KeyboardShortcutsExample: View {
@State private var items: [String] = ["Item 1", "Item 2", "Item 3"]
@State private var selectedItem: String?
@State private var showingAddSheet = false
var body: some View {
NavigationView {
List(items, id: \.self, selection: $selectedItem) { item in
Text(item)
}
.navigationTitle("Items")
.toolbar {
ToolbarItemGroup {
Button(action: addItem) {
Label("Add", systemImage: "plus")
}
.keyboardShortcut("n", modifiers: .command)
Button(action: deleteSelectedItem) {
Label("Delete", systemImage: "trash")
}
.keyboardShortcut(.delete, modifiers: .command)
.disabled(selectedItem == nil)
Button(action: refreshItems) {
Label("Refresh", systemImage: "arrow.clockwise")
}
.keyboardShortcut("r", modifiers: .command)
}
}
}
}
func addItem() {
items.append("New Item \(items.count + 1)")
}
func deleteSelectedItem() {
if let selected = selectedItem {
items.removeAll { $0 == selected }
selectedItem = nil
}
}
func refreshItems() {
print("Refreshing items...")
}
}
Arrow Key Navigation in Custom Views
For custom collections or grids, implement arrow key navigation:
struct GridKeyboardNavigation: View {
@State private var selectedIndex: Int = 0
let items = Array(0..<20)
let columns = 4
var body: some View {
VStack {
Text("Use arrow keys to navigate")
.font(.headline)
.padding()
LazyVGrid(
columns: Array(repeating: GridItem(.flexible()), count: columns),
spacing: 16
) {
ForEach(items, id: \.self) { index in
GridCell(
index: index,
isSelected: selectedIndex == index
)
.onTapGesture {
selectedIndex = index
}
}
}
.padding()
.focusable()
.onKeyPress(.upArrow) {
moveSelection(by: -columns)
return .handled
}
.onKeyPress(.downArrow) {
moveSelection(by: columns)
return .handled
}
.onKeyPress(.leftArrow) {
moveSelection(by: -1)
return .handled
}
.onKeyPress(.rightArrow) {
moveSelection(by: 1)
return .handled
}
}
}
func moveSelection(by offset: Int) {
let newIndex = selectedIndex + offset
if newIndex >= 0 && newIndex < items.count {
selectedIndex = newIndex
}
}
}
struct GridCell: View {
let index: Int
let isSelected: Bool
var body: some View {
ZStack {
RoundedRectangle(cornerRadius: 8)
.fill(isSelected ? Color.blue : Color.gray.opacity(0.3))
Text("\(index)")
.foregroundColor(isSelected ? .white : .primary)
}
.frame(height: 80)
.overlay(
RoundedRectangle(cornerRadius: 8)
.stroke(isSelected ? Color.blue : Color.clear, lineWidth: 3)
)
}
}
Focus Sections and Groups
Group related content to improve keyboard navigation flow:
struct FocusedSectionsExample: View {
enum FocusedSection: Hashable {
case sidebar
case content
case details
}
@FocusState private var focusedSection: FocusedSection?
@State private var selectedItem: String?
var body: some View {
HStack(spacing: 0) {
// Sidebar
VStack(alignment: .leading) {
Text("Sidebar")
.font(.headline)
.padding()
List {
ForEach(["Item 1", "Item 2", "Item 3"], id: \.self) { item in
Button(item) {
selectedItem = item
}
}
}
}
.frame(width: 200)
.background(Color.gray.opacity(0.1))
.focusable()
.focused($focusedSection, equals: .sidebar)
Divider()
// Main content
VStack {
Text("Main Content")
.font(.headline)
.padding()
if let item = selectedItem {
Text("Selected: \(item)")
.padding()
} else {
Text("No selection")
.foregroundColor(.secondary)
.padding()
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color.white)
.focusable()
.focused($focusedSection, equals: .content)
Divider()
// Details panel
VStack(alignment: .leading) {
Text("Details")
.font(.headline)
.padding()
Spacer()
}
.frame(width: 200)
.background(Color.gray.opacity(0.05))
.focusable()
.focused($focusedSection, equals: .details)
}
.onKeyPress(.tab, modifiers: .command) {
cycleFocusedSection()
return .handled
}
}
func cycleFocusedSection() {
switch focusedSection {
case .sidebar:
focusedSection = .content
case .content:
focusedSection = .details
case .details:
focusedSection = .sidebar
default:
focusedSection = .sidebar
}
}
}
Accessible Menus and Dropdowns
struct KeyboardAccessibleMenu: View {
@State private var isMenuOpen = false
@FocusState private var menuFocused: Bool
var body: some View {
VStack {
Menu {
Button(action: newDocument) {
Label("New", systemImage: "doc")
}
.keyboardShortcut("n", modifiers: .command)
Button(action: openDocument) {
Label("Open", systemImage: "folder")
}
.keyboardShortcut("o", modifiers: .command)
Divider()
Button(action: saveDocument) {
Label("Save", systemImage: "square.and.arrow.down")
}
.keyboardShortcut("s", modifiers: .command)
} label: {
Label("File", systemImage: "doc.text")
}
.focused($menuFocused)
}
.padding()
}
func newDocument() { print("New") }
func openDocument() { print("Open") }
func saveDocument() { print("Save") }
}
Best Practices for Keyboard Navigation
-
Logical Tab Order: Ensure focus moves in a logical, predictable order (usually left-to-right, top-to-bottom).
-
Visible Focus Indicators: Make it obvious which element has focus with clear visual styling.
-
Skip Links: In complex layouts, provide shortcuts to jump to main content sections.
-
Keyboard Shortcuts: Use standard keyboard shortcuts (⌘N for new, ⌘S for save, etc.) when applicable.
-
Support All Interactions: Everything possible with touch/mouse should be possible with keyboard.
-
Test Thoroughly: Navigate your entire app using only the keyboard to find issues.
-
Preserve Context: When opening/closing views, return focus to a logical location.
-
Handle Edge Cases: Define behavior when reaching the first or last focusable element.
-
Document Shortcuts: Provide a help screen or menu showing available keyboard shortcuts.
Testing Keyboard Navigation
To test keyboard navigation:
-
Connect an external keyboard to your iOS device or use the simulator
-
Use the Tab key to move between focusable elements
-
Use arrow keys for directional navigation
-
Press Enter or Space to activate buttons
-
Test all keyboard shortcuts
-
Verify focus indicators are visible
-
Ensure all functionality is accessible
Common keys for navigation:
-
Tab / Shift+Tab: Move forward/backward through focusable elements
-
Arrow keys: Navigate within a group or list
-
Enter / Space: Activate focused button or control
-
Escape: Close dialogs or cancel actions
-
Command+key: Execute shortcuts
Wrap up
Keyboard navigation matters most for users with external keyboards and assistive input devices. SwiftUI's @FocusState gives you the control to set logical tab order and handle keyboard shortcuts — test by unplugging your trackpad and navigating with Tab alone.
Resources:
Read more
- Swipe actions in Swift • 5 minutes reading time.
- Remove the background from images using Swift • 11 minutes reading time.
- Custom Tabbar with SwiftUI • 4 minutes reading time.
Share
Share Bluesky Mastodon Twitter LinkedIn Facebook