Skip to content

Text Input Reference

Use when implementing or debugging UITextInput, UIKeyInput, or NSTextInputClient — marked text, selection UI, custom input.

Reference Skills

Use when implementing or debugging UITextInput, UIKeyInput, or NSTextInputClient — marked text, selection UI, custom input.

Family: Editor Features And Interaction

Use this skill when the main question is how custom text input should satisfy UIKit or AppKit input contracts.

  • You are implementing UIKeyInput, UITextInput, or NSTextInputClient.
  • You need marked-text, selection, or geometry rules for custom editors.
  • The problem is lower-level input behavior, not general view selection.
  • Simple Latin-only input -> UIKeyInput
  • Full IME, selection, autocorrection, or geometry -> UITextInput
  • macOS custom input view -> NSTextInputClient
UIResponder
└── UIKeyInput (minimal: insert, delete, hasText)
└── UITextInput (full: positions, ranges, marked text, geometry)

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
// 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)

Section titled “Required Protocol Methods (Grouped by Purpose)”
// 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)
// 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
func textRange(from: UITextPosition, to: UITextPosition) -> UITextRange?
// Current selection (cursor = zero-length range)
var selectedTextRange: UITextRange? { get set }

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:

  1. User starts CJK input → setMarkedText("拼", selectedRange: NSRange(1, 0))
  2. User continues → setMarkedText("拼音", selectedRange: NSRange(2, 0))
  3. User confirms → unmarkText() (marked text becomes regular text)
  4. User cancels → setMarkedText(nil, selectedRange: NSRange(0, 0))
// 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]
// 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?
var beginningOfDocument: UITextPosition { get }
var endOfDocument: UITextPosition { get }
// For word/sentence/paragraph boundaries
var tokenizer: UITextInputTokenizer { get }
// Default: return UITextInputStringTokenizer(textInput: self)
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.

The macOS equivalent for custom text input.

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]
}

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()

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()
}
}

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()

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

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)

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)
NeedUIKitAppKit
Minimal text inputUIKeyInputNSTextInputClient
Full text inputUITextInputNSTextInputClient
System cursorUITextSelectionDisplayInteractionNSTextInsertionIndicator
MagnifierUITextLoupeSessionN/A (system-provided)
Selection gesturesUITextInteractionN/A (NSTextView built-in)
Edit menuUIEditMenuInteractionNSMenu (right-click)
Input contextN/A (automatic)NSTextInputContext

Scribble / Apple Pencil Handwriting (iPadOS 14+)

Section titled “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.

These are common bugs specific to multistage input (Chinese, Japanese, Korean). They do not affect Latin-only input.

  1. UIKeyInput alone cannot support CJK. Multi-stage input methods require setMarkedText/unmarkText. If your view only adopts UIKeyInput, CJK keyboards will not work.

  2. 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 while markedTextRange != nil.

    func textDidChange() {
    guard markedTextRange == nil else { return } // Skip during composition
    binding.wrappedValue = text
    }
  3. selectedRange in setMarkedText is relative to marked text, not the document. Add markedTextRange.location to get the document-relative cursor position.

  4. setMarkedText can fire twice for a single composition event on iOS. The first call may report an inconsistent state. Guard against re-entrant processing.

  5. deleteBackward() counts grapheme clusters, but adjustTextPosition(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.

  6. NSTextInputClient (macOS) has an extra replacementRange parameter in setMarkedText(_:selectedRange:replacementRange:). Some Japanese IMEs use this for reconverting committed text. If you pass NSRange(location: NSNotFound, length: 0), the system uses the current selection.

  1. Not calling inputDelegate methods — System desyncs. Autocorrect and marked text break.
  2. Wrong caretRect / firstRect — System UI (keyboard, autocorrect, selection handles) positioned incorrectly.
  3. Ignoring marked text — CJK input stops working. Always implement setMarkedText/unmarkText.
  4. Forgetting canBecomeFirstResponder — View never receives keyboard input.
  5. Using UITextInput without UITextSelectionDisplayInteraction — No cursor or selection handles shown.
  6. Not invalidating character coordinates (macOS) — After layout changes, call NSTextInputContext.current?.invalidateCharacterCoordinates().

This page documents the apple-text-input-ref reference skill. Use it when the subsystem is already known and you need mechanics, behavior, or API detail.

  • apple-text-representable: Use when wrapping UITextView or NSTextView in SwiftUI — binding, focus, sizing, cursor preservation, or update loops.
  • apple-text-writing-tools: Use when integrating Writing Tools — writingToolsBehavior, UIWritingToolsCoordinator, protected ranges, or inline vs panel mode.
  • apple-text-textkit-diag: Use when debugging broken text — stale layout, editing crashes, fallback, Writing Tools issues, or rendering artifacts.
  • skills/apple-text-input-ref/scribble-patterns.md
Full SKILL.md source
SKILL.md
---
name: apple-text-input-ref
description: Use when implementing or debugging UITextInput, UIKeyInput, or NSTextInputClient — marked text, selection UI, custom input
license: MIT
---
# 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`, or `NSTextInputClient`.
- 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:
```swift
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
```swift
// 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
```swift
// 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
```swift
// 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
```swift
func textRange(from: UITextPosition, to: UITextPosition) -> UITextRange?
```
#### Selection
```swift
// 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).
```swift
// 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:**
1. User starts CJK input → `setMarkedText("拼", selectedRange: NSRange(1, 0))`
2. User continues → `setMarkedText("拼音", selectedRange: NSRange(2, 0))`
3. User confirms → `unmarkText()` (marked text becomes regular text)
4. User cancels → `setMarkedText(nil, selectedRange: NSRange(0, 0))`
#### Geometry (For System UI)
```swift
// 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
```swift
// 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
```swift
var beginningOfDocument: UITextPosition { get }
var endOfDocument: UITextPosition { get }
```
#### Tokenizer
```swift
// For word/sentence/paragraph boundaries
var tokenizer: UITextInputTokenizer { get }
// Default: return UITextInputStringTokenizer(textInput: self)
```
### Notifying the System of Changes
```swift
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
```swift
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:
```swift
// 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:
```swift
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:
```swift
// 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:
```swift
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):
```swift
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:
```swift
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](scribble-patterns.md).
## CJK / IME Gotchas
These are common bugs specific to multistage input (Chinese, Japanese, Korean). They do not affect Latin-only input.
1. **UIKeyInput alone cannot support CJK.** Multi-stage input methods require `setMarkedText`/`unmarkText`. If your view only adopts `UIKeyInput`, CJK keyboards will not work.
2. **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 while `markedTextRange != nil`.
```swift
func textDidChange() {
guard markedTextRange == nil else { return } // Skip during composition
binding.wrappedValue = text
}
```
3. **`selectedRange` in `setMarkedText` is relative to marked text, not the document.** Add `markedTextRange.location` to get the document-relative cursor position.
4. **`setMarkedText` can fire twice for a single composition event on iOS.** The first call may report an inconsistent state. Guard against re-entrant processing.
5. **`deleteBackward()` counts grapheme clusters, but `adjustTextPosition(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.
6. **NSTextInputClient (macOS) has an extra `replacementRange` parameter** in `setMarkedText(_:selectedRange:replacementRange:)`. Some Japanese IMEs use this for reconverting committed text. If you pass `NSRange(location: NSNotFound, length: 0)`, the system uses the current selection.
## Common Pitfalls
1. **Not calling `inputDelegate` methods** — System desyncs. Autocorrect and marked text break.
2. **Wrong `caretRect` / `firstRect`** — System UI (keyboard, autocorrect, selection handles) positioned incorrectly.
3. **Ignoring marked text** — CJK input stops working. Always implement `setMarkedText`/`unmarkText`.
4. **Forgetting `canBecomeFirstResponder`** — View never receives keyboard input.
5. **Using UITextInput without UITextSelectionDisplayInteraction** — No cursor or selection handles shown.
6. **Not invalidating character coordinates (macOS)** — After layout changes, call `NSTextInputContext.current?.invalidateCharacterCoordinates()`.
## Related Skills
- Use `/skill apple-text-representable` when custom input lives inside a SwiftUI wrapper.
- Use `/skill apple-text-writing-tools` when text input requirements intersect with Apple Intelligence editing.
- Use `/skill apple-text-textkit-diag` when the problem is symptom-first rather than protocol-first.