apple-text-input-ref
Use when implementing or debugging UITextInput, UIKeyInput, or NSTextInputClient — marked text, selection UI, custom input
Text Input Reference
Use this skill when the main question is how custom text input should satisfy UIKit or AppKit input contracts.
When to Use
- You are implementing
UIKeyInput,UITextInput, orNSTextInputClient. - You need marked-text, selection, or geometry rules for custom editors.
- The problem is lower-level input behavior, not general view selection.
Quick Decision
- Simple Latin-only input ->
UIKeyInput - Full IME, selection, autocorrection, or geometry ->
UITextInput - macOS custom input view ->
NSTextInputClient
Core Guidance
Protocol Hierarchy (UIKit)
UIResponder
└── UIKeyInput (minimal: insert, delete, hasText)
└── UITextInput (full: positions, ranges, marked text, geometry)
UIKeyInput (Minimal Input)
Three methods for basic text entry:
class SimpleInputView: UIView, UIKeyInput {
var text = ""
var hasText: Bool { !text.isEmpty }
func insertText(_ text: String) {
self.text += text
setNeedsDisplay()
}
func deleteBackward() {
guard !text.isEmpty else { return }
text.removeLast()
setNeedsDisplay()
}
override var canBecomeFirstResponder: Bool { true }
}
Sufficient for: Simple single-stage input (Latin keyboard). Does NOT support:
- CJK multistage input (marked text)
- Autocorrection / spell checking
- Selection / cursor positioning
- Copy/paste
UITextInput (Full Input)
Required Custom Types
// Custom position
class MyTextPosition: UITextPosition {
let offset: Int
init(_ offset: Int) { self.offset = offset }
}
// Custom range
class MyTextRange: UITextRange {
let start: MyTextPosition
let end: MyTextPosition
override var start: UITextPosition { _start }
override var end: UITextPosition { _end }
override var isEmpty: Bool { _start.offset == _end.offset }
init(start: Int, end: Int) {
_start = MyTextPosition(start)
_end = MyTextPosition(end)
}
}
Required Protocol Methods (Grouped by Purpose)
Text Access
// Read text in range
func text(in range: UITextRange) -> String?
// Replace text (main edit entry point from system)
func replace(_ range: UITextRange, withText text: String)
Position Arithmetic
// Offset from a position
func position(from position: UITextPosition, offset: Int) -> UITextPosition?
// Offset in a direction
func position(from position: UITextPosition, in direction: UITextLayoutDirection,
offset: Int) -> UITextPosition?
// Distance between positions
func offset(from: UITextPosition, to: UITextPosition) -> Int
// Compare positions
func compare(_ position: UITextPosition, to other: UITextPosition) -> ComparisonResult
Range Creation
func textRange(from: UITextPosition, to: UITextPosition) -> UITextRange?
Selection
// Current selection (cursor = zero-length range)
var selectedTextRange: UITextRange? { get set }
Marked Text (CJK/IME)
Marked text is provisionally inserted text during multistage input. Visually distinct (underlined).
// Current marked text range (nil = no marked text)
var markedTextRange: UITextRange? { get }
// Style dictionary for marked text
var markedTextStyle: [NSAttributedString.Key: Any]? { get set }
// Set marked text with internal selection
func setMarkedText(_ markedText: String?, selectedRange: NSRange)
// Commit marked text
func unmarkText()
Lifecycle:
- User starts CJK input →
setMarkedText("拼", selectedRange: NSRange(1, 0)) - User continues →
setMarkedText("拼音", selectedRange: NSRange(2, 0)) - User confirms →
unmarkText()(marked text becomes regular text) - User cancels →
setMarkedText(nil, selectedRange: NSRange(0, 0))
Geometry (For System UI)
// Rect for caret at position
func caretRect(for position: UITextPosition) -> CGRect
// Rect covering a text range (for selection highlight)
func firstRect(for range: UITextRange) -> CGRect
// All selection rects for a range (multi-line)
func selectionRects(for range: UITextRange) -> [UITextSelectionRect]
Hit Testing
// Position nearest to point
func closestPosition(to point: CGPoint) -> UITextPosition?
// Position nearest to point within range
func closestPosition(to point: CGPoint, within range: UITextRange) -> UITextPosition?
// Character range at point
func characterRange(at point: CGPoint) -> UITextRange?
Document Bounds
var beginningOfDocument: UITextPosition { get }
var endOfDocument: UITextPosition { get }
Tokenizer
// For word/sentence/paragraph boundaries
var tokenizer: UITextInputTokenizer { get }
// Default: return UITextInputStringTokenizer(textInput: self)
Notifying the System of Changes
var inputDelegate: UITextInputDelegate? { get set }
// MUST call these when modifying text/selection externally
inputDelegate?.textWillChange(self)
// ... modify text ...
inputDelegate?.textDidChange(self)
inputDelegate?.selectionWillChange(self)
// ... modify selection ...
inputDelegate?.selectionDidChange(self)
Failure to call these causes the input system to desync — autocorrect stops working, marked text corrupts, keyboard misbehaves.
NSTextInputClient (AppKit)
The macOS equivalent for custom text input.
Key Methods
protocol NSTextInputClient {
// Insert confirmed text
func insertText(_ string: Any, replacementRange: NSRange)
// Set/update marked text
func setMarkedText(_ string: Any, selectedRange: NSRange, replacementRange: NSRange)
// Commit marked text
func unmarkText()
// Query selection
func selectedRange() -> NSRange
// Query marked text
func markedRange() -> NSRange
// Check if has marked text
func hasMarkedText() -> Bool
// Geometry
func firstRect(forCharacterRange range: NSRange, actualRange: NSRangePointer?) -> NSRect
// Hit testing
func characterIndex(for point: NSPoint) -> Int
// Content access
func attributedSubstring(forProposedRange range: NSRange,
actualRange: NSRangePointer?) -> NSAttributedString?
func attributedString() -> NSAttributedString
// Valid attributes
func validAttributesForMarkedText() -> [NSAttributedString.Key]
}
NSTextInputContext
Manages the input context (keyboard layout, input method) for a view:
// Get current input context
let context = NSTextInputContext.current
// Invalidate character coordinates (after layout change)
context?.invalidateCharacterCoordinates()
// Discard marked text
context?.discardMarkedText()
iOS Selection UI (iOS 17+)
UITextSelectionDisplayInteraction
Provides system selection UI (cursor, handles, highlights) for custom text views:
class CustomTextView: UIView, UITextInput {
lazy var selectionDisplay = UITextSelectionDisplayInteraction(textInput: self, isAccessibilityElement: false)
override init(frame: CGRect) {
super.init(frame: frame)
addInteraction(selectionDisplay)
}
// Call when selection changes
func updateSelection() {
selectionDisplay.setNeedsSelectionUpdate()
}
}
UITextLoupeSession
Magnifier loupe for precise cursor positioning:
// Begin loupe at point
let session = UITextLoupeSession.begin(at: point, from: selectionWidget, in: self)
// Move during drag
session.move(to: newPoint)
// End
session.invalidate()
macOS Cursor (macOS Sonoma+)
NSTextInsertionIndicator
System text cursor for custom AppKit views:
let indicator = NSTextInsertionIndicator(frame: .zero)
documentView.addSubview(indicator)
// Required: set up effects view (for animation effects)
indicator.effectsViewInserter = { effectView in
self.documentView.addSubview(effectView, positioned: .above, relativeTo: nil)
}
// Show/hide
indicator.displayMode = .automatic // or .hidden
Text Interactions
UITextInteraction (iOS 13+)
Adds system text interactions to a view (selection gestures, menu):
let interaction = UITextInteraction(for: .editable) // or .nonEditable
interaction.textInput = self // Must conform to UITextInput
view.addInteraction(interaction)
UIEditMenuInteraction (iOS 16+)
The modern replacement for UIMenuController:
let editMenu = UIEditMenuInteraction(delegate: self)
view.addInteraction(editMenu)
// Show menu
let config = UIEditMenuConfiguration(identifier: nil, sourcePoint: point)
editMenu.presentEditMenu(with: config)
Quick Reference
| Need | UIKit | AppKit |
|---|---|---|
| Minimal text input | UIKeyInput | NSTextInputClient |
| Full text input | UITextInput | NSTextInputClient |
| System cursor | UITextSelectionDisplayInteraction | NSTextInsertionIndicator |
| Magnifier | UITextLoupeSession | N/A (system-provided) |
| Selection gestures | UITextInteraction | N/A (NSTextView built-in) |
| Edit menu | UIEditMenuInteraction | NSMenu (right-click) |
| Input context | N/A (automatic) | NSTextInputContext |
Scribble / Apple Pencil Handwriting (iPadOS 14+)
UITextView and UITextField get Scribble for free. Custom UITextInput views also get automatic support if the implementation is complete.
For customization (UIScribbleInteraction) and non-text-input views that should accept handwriting (UIIndirectScribbleInteraction), see scribble-patterns.md.
CJK / IME Gotchas
These are common bugs specific to multistage input (Chinese, Japanese, Korean). They do not affect Latin-only input.
-
UIKeyInput alone cannot support CJK. Multi-stage input methods require
setMarkedText/unmarkText. If your view only adoptsUIKeyInput, CJK keyboards will not work. -
Two-way binding corruption with reactive frameworks. Reactive bindings (RxSwift, Combine, SwiftUI
@Binding) that read and write text on every change corrupt CJK composition. Japanese input produces garbled output. Fix: suppress text observation callbacks whilemarkedTextRange != nil.func textDidChange() { guard markedTextRange == nil else { return } // Skip during composition binding.wrappedValue = text } -
selectedRangeinsetMarkedTextis relative to marked text, not the document. AddmarkedTextRange.locationto get the document-relative cursor position. -
setMarkedTextcan fire twice for a single composition event on iOS. The first call may report an inconsistent state. Guard against re-entrant processing. -
deleteBackward()counts grapheme clusters, butadjustTextPosition(byCharacterOffset:)counts UTF-16 code units. Mixing these in CJK text (where one character can be multiple UTF-16 units) produces off-by-one cursor jumps. -
NSTextInputClient (macOS) has an extra
replacementRangeparameter insetMarkedText(_:selectedRange:replacementRange:). Some Japanese IMEs use this for reconverting committed text. If you passNSRange(location: NSNotFound, length: 0), the system uses the current selection.
Common Pitfalls
- Not calling
inputDelegatemethods — System desyncs. Autocorrect and marked text break. - Wrong
caretRect/firstRect— System UI (keyboard, autocorrect, selection handles) positioned incorrectly. - Ignoring marked text — CJK input stops working. Always implement
setMarkedText/unmarkText. - Forgetting
canBecomeFirstResponder— View never receives keyboard input. - Using UITextInput without UITextSelectionDisplayInteraction — No cursor or selection handles shown.
- Not invalidating character coordinates (macOS) — After layout changes, call
NSTextInputContext.current?.invalidateCharacterCoordinates().
Related Skills
- Use
/skill apple-text-representablewhen custom input lives inside a SwiftUI wrapper. - Use
/skill apple-text-writing-toolswhen text input requirements intersect with Apple Intelligence editing. - Use
/skill apple-text-textkit-diagwhen the problem is symptom-first rather than protocol-first.