```html SwiftUI: How to Implement Keyboard Extension (iOS 17+, 2026)
Soarias ← All SwiftUI guides

How to Implement a Keyboard Extension in SwiftUI

iOS 17+ Xcode 16+ Advanced APIs: Custom Keyboard Updated: May 11, 2026
TL;DR

Custom keyboard extensions on iOS are UIKit targets — you subclass UIInputViewController and embed your SwiftUI layout via UIHostingController. Text insertion goes through textDocumentProxy, which is the bridge between your key presses and any host app's text field.

// KeyboardViewController.swift (Extension target)
import UIKit
import SwiftUI

class KeyboardViewController: UIInputViewController {

    override func viewDidLoad() {
        super.viewDidLoad()
        let keyboardView = KeyboardView { [weak self] action in
            switch action {
            case .insert(let text): self?.textDocumentProxy.insertText(text)
            case .delete:           self?.textDocumentProxy.deleteBackward()
            case .nextKeyboard:     self?.advanceToNextInputMode()
            }
        }
        let host = UIHostingController(rootView: keyboardView)
        addChild(host)
        view.addSubview(host.view)
        host.view.translatesAutoresizingMaskIntoConstraints = false
        NSLayoutConstraint.activate([
            host.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
            host.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
            host.view.topAnchor.constraint(equalTo: view.topAnchor),
            host.view.bottomAnchor.constraint(equalTo: view.bottomAnchor)
        ])
        host.didMove(toParent: self)
    }
}

Full implementation

The keyboard extension target ships its own Info.plist declaring NSExtension with the com.apple.keyboard-service point. The actual UI lives in a UIInputViewController subclass, but the key design win is keeping all layout logic in a pure SwiftUI view that receives an onAction callback — this keeps the extension testable and the view previable in Xcode. The textDocumentProxy is the only UIKit API you really need to touch after setup.

// MARK: - KeyboardAction.swift (shared between extension & app)
import Foundation

enum KeyboardAction {
    case insert(String)
    case delete
    case nextKeyboard
    case space
    case `return`
}

// MARK: - KeyboardView.swift (SwiftUI layout)
import SwiftUI

struct KeyboardView: View {
    let onAction: (KeyboardAction) -> Void

    private let rows: [[String]] = [
        ["Q","W","E","R","T","Y","U","I","O","P"],
        ["A","S","D","F","G","H","J","K","L"],
        ["Z","X","C","V","B","N","M"]
    ]

    var body: some View {
        VStack(spacing: 8) {
            ForEach(rows, id: \.self) { row in
                HStack(spacing: 5) {
                    ForEach(row, id: \.self) { letter in
                        KeyCap(label: letter) {
                            onAction(.insert(letter.lowercased()))
                        }
                    }
                }
            }
            HStack(spacing: 5) {
                KeyCap(label: "🌐", flex: 1) { onAction(.nextKeyboard) }
                KeyCap(label: "space", flex: 4) { onAction(.space) }
                KeyCap(label: "⌫", flex: 1) { onAction(.delete) }
                KeyCap(label: "return", flex: 2) { onAction(.return) }
            }
        }
        .padding(.horizontal, 6)
        .padding(.vertical, 8)
        .background(Color(uiColor: .systemGroupedBackground))
    }
}

// MARK: - KeyCap.swift
struct KeyCap: View {
    let label: String
    var flex: Int = 1
    let action: () -> Void

    var body: some View {
        Button(action: action) {
            Text(label)
                .font(.system(size: 16, weight: .regular))
                .frame(maxWidth: .infinity, minHeight: 42)
                .background(Color(uiColor: .systemBackground))
                .clipShape(RoundedRectangle(cornerRadius: 5))
                .shadow(color: .black.opacity(0.25), radius: 0, x: 0, y: 1)
        }
        .buttonStyle(.plain)
        .frame(maxWidth: CGFloat(flex) * 44)
        .accessibilityLabel(label)
        .accessibilityAddTraits(.isButton)
    }
}

// MARK: - KeyboardViewController.swift (Extension target)
import UIKit
import SwiftUI

class KeyboardViewController: UIInputViewController {

    private var hostingController: UIHostingController?

    override func viewDidLoad() {
        super.viewDidLoad()
        embedKeyboardView()
    }

    private func embedKeyboardView() {
        let keyboardView = KeyboardView { [weak self] action in
            guard let self else { return }
            switch action {
            case .insert(let text):
                self.textDocumentProxy.insertText(text)
            case .delete:
                self.textDocumentProxy.deleteBackward()
            case .space:
                self.textDocumentProxy.insertText(" ")
            case .return:
                self.textDocumentProxy.insertText("\n")
            case .nextKeyboard:
                self.advanceToNextInputMode()
            }
        }
        let host = UIHostingController(rootView: keyboardView)
        hostingController = host
        addChild(host)
        view.addSubview(host.view)
        host.view.translatesAutoresizingMaskIntoConstraints = false
        NSLayoutConstraint.activate([
            host.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
            host.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
            host.view.topAnchor.constraint(equalTo: view.topAnchor),
            host.view.bottomAnchor.constraint(equalTo: view.bottomAnchor)
        ])
        host.didMove(toParent: self)
    }
}

// MARK: - Preview (host app target, not extension)
#Preview {
    KeyboardView { action in
        print("Action:", action)
    }
    .frame(height: 260)
}

How it works

  1. Extension target entry point. KeyboardViewController is declared as the NSExtensionPrincipalClass in the extension's Info.plist. iOS instantiates this class whenever your keyboard is activated, calling viewDidLoad exactly like a normal view controller.
  2. SwiftUI-in-UIKit embedding. UIHostingController(rootView:) wraps the pure-SwiftUI KeyboardView. It's added as a child view controller and its view is pinned edge-to-edge with Auto Layout constraints, so the SwiftUI layout engine fully controls the keyboard's frame.
  3. Action callback pattern. KeyboardView takes an onAction: (KeyboardAction) -> Void closure. This keeps the view free of UIKit imports and testable in isolation. The closure is captured weakly in KeyboardViewController to prevent a retain cycle.
  4. textDocumentProxy. self.textDocumentProxy.insertText(_:) and deleteBackward() are the only two calls needed for basic input. The proxy conforms to UITextDocumentProxy and communicates with whatever text field is focused in the host app — your code never touches that app directly.
  5. Next Keyboard button. advanceToNextInputMode() switches to the next keyboard in the user's list. App Review requires this button to be visible at all times (guideline 4.2.2), so it's always present in the bottom row labelled with the globe emoji.

Variants

Full-access keyboard (network + pasteboard)

By default keyboard extensions run sandboxed with no network access. To enable full access, set RequestsOpenAccess to YES in the extension's Info.plist. The user must then grant "Allow Full Access" in Settings → General → Keyboard. You can check the current state at runtime:

// Inside KeyboardViewController
override func viewDidLoad() {
    super.viewDidLoad()
    if hasFullAccess {
        // Safe to use URLSession, UIPasteboard, etc.
        print("Full access granted")
    } else {
        // Prompt the user or degrade gracefully
        print("Running in sandboxed mode")
    }
    embedKeyboardView()
}

// Observe changes at runtime (user can toggle in Settings)
override func viewWillAppear(_ animated: Bool) {
    super.viewWillAppear(animated)
    updateAccessBanner(hasFullAccess)
}

Autocomplete / suggestion bar

Add a horizontal ScrollView above your key rows containing suggestion chips. Read the current word from textDocumentProxy.documentContextBeforeInput, split on whitespace to get the partial word, and feed it to an on-device NLLanguageRecognizer or a simple frequency dictionary. Tap a chip to call textDocumentProxy.insertText(suggestion) after deleting the partial word with a loop of deleteBackward() calls equal to the partial word's character count.

Common pitfalls

Prompt this with Claude Code

When using Soarias or Claude Code directly to implement this:

Implement a keyboard extension in SwiftUI for iOS 17+.
Use Custom Keyboard (UIInputViewController + UIHostingController).
Embed a SwiftUI KeyboardView with an onAction callback enum.
Insert text via textDocumentProxy.insertText(_:) and deleteBackward().
Include a globe/next-keyboard button that calls advanceToNextInputMode().
Make it accessible (VoiceOver labels on every key cap).
Add a #Preview with realistic sample data in the host app target.

In the Soarias Build phase, paste this prompt into the implementation step after your screens are locked — Claude Code will scaffold the extension target, wire up the Info.plist extension point, and keep the SwiftUI layer decoupled from UIKit so your layout is immediately previewable without running a simulator.

Related

FAQ

Does this work on iOS 16?

The UIInputViewController + UIHostingController pattern works on iOS 13 and later, so yes, the architecture is backwards-compatible. However, this guide targets iOS 17+ because @Observable and the #Preview macro used in the code above require iOS 17. If you need iOS 16 support, replace @Observable with @ObservableObject/@StateObject and use PreviewProvider instead.

Can the keyboard extension share data with the main app?

Yes — use an App Group (add the same App Group entitlement to both the main target and the keyboard extension target). You can then share UserDefaults(suiteName:) and a shared container directory. This is how you'd sync a custom dictionary, theme settings, or learned words between the keyboard and a companion settings screen in the main app. Note that CoreData and SwiftData stores also work inside App Groups as long as the store URL points to the shared container.

What's the UIKit equivalent?

In pure UIKit you'd override viewDidLoad in UIInputViewController and build your key grid from UIButton instances arranged with UIStackView or manual NSLayoutConstraint. You'd handle taps via addTarget(_:action:for:) and call the same textDocumentProxy APIs. The SwiftUI approach shown above is strictly better: the layout is previable in Xcode, the view is unit-testable, and SwiftUI's Grid or HStack/VStack composing is far less code than the UIKit equivalent.

Last reviewed: 2026-05-11 by the Soarias team.

```