How to Implement a Keyboard Extension in SwiftUI
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
-
Extension target entry point.
KeyboardViewControlleris declared as theNSExtensionPrincipalClassin the extension'sInfo.plist. iOS instantiates this class whenever your keyboard is activated, callingviewDidLoadexactly like a normal view controller. -
SwiftUI-in-UIKit embedding.
UIHostingController(rootView:)wraps the pure-SwiftUIKeyboardView. 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. -
Action callback pattern.
KeyboardViewtakes anonAction: (KeyboardAction) -> Voidclosure. This keeps the view free of UIKit imports and testable in isolation. The closure is captured weakly inKeyboardViewControllerto prevent a retain cycle. -
textDocumentProxy.
self.textDocumentProxy.insertText(_:)anddeleteBackward()are the only two calls needed for basic input. The proxy conforms toUITextDocumentProxyand communicates with whatever text field is focused in the host app — your code never touches that app directly. -
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
-
iOS version: Custom keyboard extensions have been available since iOS 8, but
UIHostingControllerrequires iOS 13+. On iOS 17, SwiftUI animations and@Observablework inside extensions without any workarounds — but always target iOS 17 minimum to avoid bridging headaches with the olderObservableObjectprotocol. -
Height management: iOS infers keyboard height from
view.frame, not SwiftUI's ideal size. If your SwiftUI view's height is wrong, the keyboard will either collapse or overlap content. UseviewWillLayoutSubviewsto explicitly setview.frame.size.height, or anchor a fixed-height constraint on the hosting controller's view rather than relying on SwiftUI'sfixedSize(). -
Memory limit: Extension processes have a strict memory ceiling (roughly 120 MB). Avoid loading large model files, image assets, or font sets. Keep your SwiftUI view hierarchy shallow and defer any resource loading until after
viewDidAppear. Exceeding the limit causes the extension to silently crash and the system keyboard to reappear. -
Accessibility: VoiceOver cannot read SwiftUI
Textlabels inside a keyboard extension unless you explicitly set.accessibilityLabel(_:)on each key — the automatic label inference that works in a normal app is partially suppressed in the extension sandbox. Always add labels manually as shown in theKeyCapcomponent above. -
App Review / Globe key: Missing the "Next Keyboard" globe button is the #1 reason custom keyboards get rejected. It must be present, tappable, and call
advanceToNextInputMode()— placing it off-screen or with zero opacity will be caught during review.
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.