Guides
Performance
The extension memory budget, keeping the key grid off the per-keystroke render path, sizing the input view, and measuring a process you cannot attach to.
A keyboard extension is the least forgiving process on the phone. It runs on a small fraction of an app’s memory, it is killed and relaunched constantly, and every frame it drops is a frame the user feels under their thumb. Two things follow: keep the render path short, and keep things out of the process entirely.
The memory budget
The package is split into five products for this reason. Link PressboardKitAutocomplete and
PressboardKitEmoji only if you use them — see Installation.
Beyond that:
- Do not pull a heavy framework into the extension. A JavaScript runtime, a large ML model or an analytics SDK will not fit next to a keyboard.
- Keep large data out of the process. The bundled frequency lists are loaded lazily per locale;
a custom
words:list you pass is held in memory for as long as the provider is. - Do not keep a controller alive across appearances. iOS builds a fresh input view controller
for every appearance, and it is easy to retain the previous one by accident — the leak then
compounds every time the keyboard is dismissed and shown again, until iOS kills the process
mid-sentence.
PressboardInputViewControllerreleases its SwiftUI host explicitly indeinitfor this reason. If you host the SwiftUI view yourself, do the same, and verify it by showing and dismissing the keyboard repeatedly rather than by typing — typing is not what grows.
The render path
Rendering the whole key grid costs far more than a keystroke can afford, so it must not happen on every keystroke. Two things keep it cheap: one in the engine, one in your host.
The engine half
The key grid is an Equatable view. SwiftUI skips re-rendering it unless layout, appearance
or behavior actually changes. A plain letter keystroke changes none of those — only your
suggestion bar changes — so the grid is skipped entirely: measured on a device, typing mid-word
produces zero grid re-renders.
One caveat: that equality compares layout, appearance and behavior, and ignores closures and
the style. If you pass a keyOverlay that depends on external value state, or swap the style
dynamically, change something in behavior or layout too — otherwise the fast path will show
stale keys until the next real change.
Your half
Keep the state that changes on every keystroke — the suggestion bar’s contents — in its own
ObservableObject, observed only by the toolbar. A keystroke then publishes only that object and
re-renders only the bar, without republishing the model that drives the keys.
@MainActor final class SuggestionsModel: ObservableObject {
@Published var suggestions: [Suggestion] = []
}
@MainActor final class KeyboardModel: ObservableObject {
let suggestionsModel = SuggestionsModel() // NOT @Published — its changes don't republish us
@Published private(set) var layout: KeyboardLayoutDefinition
// update suggestionsModel.suggestions per keystroke; update `layout` only on case/page change
}
private struct SuggestionsToolbar: View {
@ObservedObject var model: SuggestionsModel
let onPick: (Suggestion) -> Void
var body: some View { SuggestionBar(suggestions: model.suggestions, onPick: onPick) }
}
The two are complementary: the equatable grid protects you even if your model is coarse, and the
split-state pattern brings the per-keystroke re-render down to just the bar.
PressboardController already does this — its suggestionsModel is a separate object for exactly
this reason.
Whatever provider you use, keep suggestions(for:) cheap; it runs on every keystroke. The bundled
DictionarySuggestionProvider indexes its list rather than scanning it, so the call stays far
inside a frame.
Do not do the work twice
You will typically recompute suggestions and case both after your own edit and from the system’s
textDidChange, which fires for the same edit. Cache the last document context and return early
when it has not changed:
private var lastSyncedContext: String?
private func syncAfterInput() {
let before = textDocumentProxy.documentContextBeforeInput ?? ""
guard before != lastSyncedContext else { return } // same edit — already handled
lastSyncedContext = before
model.updateSuggestions(for: before)
model.updateCase(forTextBeforeCursor: before)
}
PressboardController exposes this as syncAfterInput(), with invalidateSyncCache() for the
cases where the document changed underneath you.
Sizing the input view
The extension controls its own height. Compute it from the style metrics and update it when the page changes — the emoji page is taller, and a bar adds its own height:
func desiredHeight() -> CGFloat {
let keyArea = NativeKeyboardStyle().metrics.keyAreaHeight(rowCount: 4)
if model.layout.keyboardType == .emojis {
return (behavior.emojiSearch ? EmojiSearchHeader.height : 0) + keyArea + 44
}
return (behavior.predictiveText ? SuggestionBar.height : 0) + keyArea
}
Activate a height constraint on the input view at priority 999 and update its constant when the mode changes. Add the key-pop headroom from Theming on top.
Measure in Release
The engine’s Swift-level work — suggestion lookup, string transforms, reference counting — is many times faster optimised than in a Debug build. Judge responsiveness on a Release build or you will be tuning the wrong thing. For reference, the engine’s own touch-to-insert latency measures at the floor of what the SwiftUI gesture path allows, so if typing feels slow in your integration, the cause is above the engine.
Measuring memory from inside
Measuring a keyboard extension’s memory from outside is unreliable. Instruments’ Allocations instrument cannot attach to a process it did not launch, and an extension cannot be launched by it. So the keyboard reports its own footprint instead.
behavior.memoryDiagnosticsInterval = 50 // log every 50 keystrokes; 0 is off
It costs one system call per interval and nothing else. Read the numbers back from the unified log:
log collect --device
log show --predicate 'subsystem == "PressboardKit"'
This is a diagnostic, not something to ship on. Perf.logFootprint(keystrokes:state:) and
Perf.physFootprint() are public if you want to call them yourself.
Input pipelines
multiTouchInputLayer routes key presses through a single shared multi-touch gesture rather than
each key’s own SwiftUI DragGesture. It exists because fast multi-touch typing could buffer,
reorder or drop overlapping touches on the per-key path — confirmed on a device. The multi-touch
layer processes raw touches ordered by their own timestamps, so overlapping presses commit in real
time order. It has full parity with the per-key path: key-down commit, backspace, drag-off cancel,
feedback, long-press callouts, the spacebar cursor drag and slide-to-type, and the two are
visually identical.
It is on in .pressboardDefault after a full round of on-device verification, and off in
.standard, so existing integrators keep the path they are already running. Set it explicitly
either way to pin the behaviour.
Next: the behaviour reference.