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, 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:

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:

  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)

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

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

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.

  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.

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.