--- name: axiom-textkit-ref description: TextKit 2 complete reference (architecture, migration, Writing Tools, SwiftUI TextEditor) through iOS 26 license: MIT --- # TextKit 2 Reference Complete reference for TextKit 2 covering architecture, migration from TextKit 1, Writing Tools integration, and SwiftUI TextEditor with AttributedString through iOS 26. ## Architecture TextKit 2 uses MVC pattern with new classes optimized for correctness, safety, and performance. ### Model Layer **NSTextContentManager** (abstract) - Generates NSTextElement objects from backing store - Tracks element ranges within document - Default implementation: NSTextContentStorage **NSTextContentStorage** - Uses NSTextStorage as backing store - Automatically divides content into NSTextParagraph elements - Generates updated elements when text changes **NSTextElement** (abstract) - Represents portion of content (paragraph, attachment, custom type) - Immutable value semantics - Properties cannot change after creation - Default implementation: NSTextParagraph **NSTextParagraph** - Represents single paragraph - Contains range within document ### Controller Layer **NSTextLayoutManager** - Replaces TextKit 1's NSLayoutManager - **NO glyph APIs** (abstracts away glyphs entirely) - Takes elements, lays out into container, generates layout fragments - Always uses noncontiguous layout **NSTextLayoutFragment** - Immutable layout information for one or more elements - Key properties: - `textLineFragments` — array of NSTextLineFragment - `layoutFragmentFrame` — layout bounds within container - `renderingSurfaceBounds` — actual drawing bounds (can exceed frame) **NSTextLineFragment** - Measurement info for single line of text - Used for line counting and geometric queries ### View Layer **NSTextViewportLayoutController** - Source of truth for viewport layout - Coordinates visible-only layout - Calls delegate methods: `willLayout`, `configureRenderingSurface`, `didLayout` **NSTextContainer** - Provides geometric information for layout destination - Can define exclusion paths (non-rectangular layout) ### Object-Based Ranges **NSTextLocation** (protocol) - Represents single location in text - Replaces integer indices - Supports structured documents (e.g., DOM with nested elements) **NSTextRange** - Start and end locations (end is excluded) - Can represent nested structure - Incompatible with NSRange for non-linear documents **NSTextSelection** - Contains: granularity, affinity, possibly disjoint ranges - Read-only properties - Immutable value semantics **NSTextSelectionNavigation** - Performs actions on selections - Returns new NSTextSelection instances - Handles bidirectional text correctly ## Core Design Principles ### 1. Correctness — No Glyph APIs From WWDC 2021: > "TextKit 2 abstracts away glyph handling to provide a consistent experience for international text." **Why no glyphs?** **Problem:** In scripts like Kannada and Arabic: - One glyph can represent multiple characters (ligatures) - One character can split into multiple glyphs - Glyphs reorder during shaping - No correct character→glyph mapping **Example (Kannada word "October"):** - Character 4 splits into 2 glyphs - Glyphs reorder before ligature application - Glyph 3 becomes conjoining form and moves below another glyph **Solution:** Use NSTextLocation, NSTextRange, NSTextSelection instead of glyph indices. ### 2. Safety — Value Semantics **Immutable objects:** - NSTextElement - NSTextLayoutFragment - NSTextLineFragment - NSTextSelection **Benefits:** - No unintended sharing - No side effects from mutations - Easier to reason about state **Pattern:** To change layout/selection, create new instances with desired changes. ### 3. Performance — Viewport Layout **Always Noncontiguous:** TextKit 2 performs layout only for visible content + overscroll region. **TextKit 1:** - Optional noncontiguous layout (boolean property) - No visibility into layout state - Can't control which parts get laid out **TextKit 2:** - Always noncontiguous - Viewport defines visible area - Consistent layout info for viewport - Notifications for viewport layout updates **Viewport Delegate Methods:** 1. `textViewportLayoutControllerWillLayout(_:)` — setup before layout 2. `textViewportLayoutController(_:configureRenderingSurfaceFor:)` — per fragment 3. `textViewportLayoutControllerDidLayout(_:)` — cleanup after layout ## Migration from TextKit 1 ### Key Paradigm Shift | TextKit 1 | TextKit 2 | |-----------|-----------| | Glyphs | Elements | | NSRange | NSTextLocation/NSTextRange | | NSLayoutManager | NSTextLayoutManager | | Glyph APIs | NO glyph APIs | | Optional noncontiguous | Always noncontiguous | | NSTextStorage directly | Via NSTextContentManager | ### API Naming Heuristics From WWDC 2022: - `.offset` in name → TextKit 1 - `.location` in name → TextKit 2 ### NSRange ↔ NSTextRange Conversion **NSRange → NSTextRange:** ```swift // UITextView/NSTextView let nsRange = NSRange(location: 0, length: 10) // Via content manager let startLocation = textContentManager.location( textContentManager.documentRange.location, offsetBy: nsRange.location )! let endLocation = textContentManager.location( startLocation, offsetBy: nsRange.length )! let textRange = NSTextRange(location: startLocation, end: endLocation) ``` **NSTextRange → NSRange:** ```swift let startOffset = textContentManager.offset( from: textContentManager.documentRange.location, to: textRange.location ) let length = textContentManager.offset( from: textRange.location, to: textRange.endLocation ) let nsRange = NSRange(location: startOffset, length: length) ``` ### Glyph API Replacements **NO direct glyph API equivalents.** Must use higher-level structures. **Example (TextKit 1 - counting lines):** ```swift // TextKit 1 - iterate glyphs var lineCount = 0 let glyphRange = layoutManager.glyphRange(for: textContainer) for glyphIndex in glyphRange.location.. "Accessing textView.layoutManager triggers TK1 fallback" **Once fallback occurs:** - No automatic way back to TextKit 2 - Expensive to switch - Lose UI state (selection, scroll position) - **One-way operation** **Prevent Fallback:** 1. Check `.textLayoutManager` first (TextKit 2) 2. Only access `.layoutManager` in else clause 3. Opt out at initialization if TK1 required ```swift // Check TextKit 2 first if let textLayoutManager = textView.textLayoutManager { // TextKit 2 code } else if let layoutManager = textView.layoutManager { // TextKit 1 fallback (old OS versions) } ``` **Debug Fallback:** - **UIKit:** Breakpoint on `_UITextViewEnablingCompatibilityMode` - **AppKit:** Subscribe to `willSwitchToNSLayoutManagerNotification` ### NSTextView Opt-In (macOS) **Create TextKit 2 NSTextView:** ```swift let textLayoutManager = NSTextLayoutManager() let textContainer = NSTextContainer() textLayoutManager.textContainer = textContainer let textView = NSTextView(frame: .zero, textContainer: textContainer) // textView.textLayoutManager now available ``` **New Convenience Constructor:** ```swift // iOS 16+ / macOS 13+ let textView = UITextView(usingTextLayoutManager: true) let nsTextView = NSTextView(usingTextLayoutManager: true) ``` ## Delegate Hooks ### NSTextContentStorageDelegate **Customize attributes without modifying storage:** ```swift func textContentStorage( _ textContentStorage: NSTextContentStorage, textParagraphWith range: NSRange ) -> NSTextParagraph? { // Modify attributes for display var attributedString = textContentStorage.attributedString! .attributedSubstring(from: range) // Add custom attributes if isComment(range) { attributedString.addAttribute( .foregroundColor, value: UIColor.systemIndigo, range: NSRange(location: 0, length: attributedString.length) ) } return NSTextParagraph(attributedString: attributedString) } ``` **Filter elements (hide/show content):** ```swift func textContentManager( _ textContentManager: NSTextContentManager, shouldEnumerate textElement: NSTextElement, options: NSTextContentManager.EnumerationOptions ) -> Bool { // Return false to hide element if hideComments && isComment(textElement) { return false } return true } ``` ### NSTextLayoutManagerDelegate **Provide custom layout fragments:** ```swift func textLayoutManager( _ textLayoutManager: NSTextLayoutManager, textLayoutFragmentFor location: NSTextLocation, in textElement: NSTextElement ) -> NSTextLayoutFragment { // Return custom fragment for special styling if isComment(textElement) { return BubbleLayoutFragment( textElement: textElement, range: textElement.elementRange ) } return NSTextLayoutFragment( textElement: textElement, range: textElement.elementRange ) } ``` ### NSTextViewportLayoutController.Delegate **Viewport layout lifecycle:** ```swift func textViewportLayoutControllerWillLayout(_ controller: NSTextViewportLayoutController) { // Prepare for layout: clear sublayers, begin animation } func textViewportLayoutController( _ controller: NSTextViewportLayoutController, configureRenderingSurfaceFor textLayoutFragment: NSTextLayoutFragment ) { // Update geometry for each visible fragment let layer = getOrCreateLayer(for: textLayoutFragment) layer.frame = textLayoutFragment.layoutFragmentFrame // Animate to new position if needed } func textViewportLayoutControllerDidLayout(_ controller: NSTextViewportLayoutController) { // Finish: commit animations, update scroll indicators } ``` ## Practical Patterns ### Custom Layout Fragment (Bubble Backgrounds) ```swift class BubbleLayoutFragment: NSTextLayoutFragment { override func draw(at point: CGPoint, in context: CGContext) { // Draw custom background context.setFillColor(UIColor.systemIndigo.cgColor) let bubblePath = UIBezierPath( roundedRect: layoutFragmentFrame, cornerRadius: 8 ) context.addPath(bubblePath.cgPath) context.fillPath() // Draw text on top super.draw(at: point, in: context) } } ``` ### Rendering Attributes (Temporary Styling) **Add attributes that don't modify text storage:** ```swift textLayoutManager.addRenderingAttribute( .foregroundColor, value: UIColor.green, for: ingredientRange ) // Remove when no longer needed textLayoutManager.removeRenderingAttribute( .foregroundColor, for: ingredientRange ) ``` ### Text Attachment with UIView ```swift // iOS 15+ let attachment = NSTextAttachment() attachment.image = UIImage(systemName: "star.fill") // Provide view for interaction class AttachmentViewProvider: NSTextAttachmentViewProvider { override func loadView() { super.loadView() let button = UIButton(type: .system) button.setTitle("Tap me", for: .normal) button.addTarget(self, action: #selector(didTap), for: .touchUpInside) view = button } @objc func didTap() { // Handle tap } } ``` ### Lists and Tables ```swift // Create list let listItem = NSTextList(markerFormat: .disc, options: 0) let paragraphStyle = NSMutableParagraphStyle() paragraphStyle.textLists = [listItem] attributedString.addAttribute( .paragraphStyle, value: paragraphStyle, range: range ) ``` **NSTextList** available in UIKit (iOS 16+), previously AppKit-only. ### Hit Testing & Selection Geometry ```swift // Get text range at point let location = textLayoutManager.location( interactingAt: point, inContainerAt: textContainer.location ) // Get bounding rect for range var boundingRect = CGRect.zero textLayoutManager.enumerateTextSegments( in: textRange, type: .standard, options: [] ) { segmentRange, segmentRect, baselinePosition, textContainer in boundingRect = boundingRect.union(segmentRect) return true } ``` ## Writing Tools (iOS 18+) ### Basic Integration (TextKit 2 Required) From WWDC 2024: > "UITextView or NSTextView has to use TextKit 2 to support the full Writing Tools experience. If using TextKit 1, you will get a limited experience that just shows rewritten results in a panel." **Free for native text views:** ```swift // UITextView, NSTextView, WKWebView // Writing Tools appears automatically ``` ### Lifecycle Delegate Methods ```swift func textViewWritingToolsWillBegin(_ textView: UITextView) { // Pause syncing, prevent edits isSyncing = false } func textViewWritingToolsDidEnd(_ textView: UITextView) { // Resume syncing isSyncing = true } // Check if active if textView.isWritingToolsActive { // Don't persist text storage } ``` ### Controlling Behavior ```swift // Opt out completely textView.writingToolsBehavior = .none // Panel-only experience (no in-line edits) textView.writingToolsBehavior = .limited // Full experience (default) textView.writingToolsBehavior = .default ``` ### Result Options ```swift // Plain text only textView.writingToolsResultOptions = [.plainText] // Rich text textView.writingToolsResultOptions = [.richText] // Rich text + tables textView.writingToolsResultOptions = [.richText, .table] // Rich text + lists textView.writingToolsResultOptions = [.richText, .list] ``` ### Protected Ranges ```swift // UITextViewDelegate / NSTextViewDelegate func textView( _ textView: UITextView, writingToolsIgnoredRangesIn enclosingRange: NSRange ) -> [NSRange] { // Return ranges that Writing Tools should not modify return codeBlockRanges + quoteRanges } ``` **WKWebView:** `
` and `
` tags automatically ignored.

## Writing Tools Coordinator (iOS 26+)

Advanced integration for custom text engines.

### Setup

```swift
// UIKit
let coordinator = UIWritingToolsCoordinator()
coordinator.delegate = self
textView.addInteraction(coordinator)
coordinator.writingToolsBehavior = .default
coordinator.writingToolsResultOptions = [.richText]

// AppKit
let coordinator = NSWritingToolsCoordinator()
coordinator.delegate = self
customView.writingToolsCoordinator = coordinator
```

### Coordinator Delegate

**Provide context:**
```swift
func writingToolsCoordinator(
    _ coordinator: NSWritingToolsCoordinator,
    requestContexts scope: NSWritingToolsCoordinator.ContextScope
) async -> [NSWritingToolsCoordinator.Context] {
    // Return attributed string + selection range
    let context = NSWritingToolsCoordinator.Context(
        attributedString: currentText,
        range: currentSelection
    )
    return [context]
}
```

**Apply changes:**
```swift
func writingToolsCoordinator(
    _ coordinator: NSWritingToolsCoordinator,
    replace context: NSWritingToolsCoordinator.Context,
    range: NSRange,
    with attributedString: NSAttributedString
) async {
    // Update text storage
    textStorage.replaceCharacters(in: range, with: attributedString)
}
```

**Update selection:**
```swift
func writingToolsCoordinator(
    _ coordinator: NSWritingToolsCoordinator,
    updateSelectedRange selectedRange: NSRange,
    in context: NSWritingToolsCoordinator.Context
) async {
    // Update selection
    self.selectedRange = selectedRange
}
```

**Provide previews for animation:**
```swift
// macOS
func writingToolsCoordinator(
    _ coordinator: NSWritingToolsCoordinator,
    previewsFor context: NSWritingToolsCoordinator.Context,
    range: NSRange
) async -> [NSTextPreview] {
    // Return one preview per line for smooth animation
    return textLines.map { line in
        NSTextPreview(
            image: renderImage(for: line),
            frame: line.frame
        )
    }
}

// iOS
func writingToolsCoordinator(
    _ coordinator: UIWritingToolsCoordinator,
    previewFor context: UIWritingToolsCoordinator.Context,
    range: NSRange
) async -> UITargetedPreview {
    // Return single preview
    return UITargetedPreview(
        view: previewView,
        parameters: parameters
    )
}
```

**Proofreading marks:**
```swift
func writingToolsCoordinator(
    _ coordinator: NSWritingToolsCoordinator,
    underlinesFor context: NSWritingToolsCoordinator.Context,
    range: NSRange
) async -> [NSValue] {
    // Return bezier paths for underlines
    return ranges.map { range in
        let path = bezierPath(for: range)
        return NSValue(bytes: &path, objCType: "CGPath")
    }
}
```

### PresentationIntent (iOS 26+)

**Semantic rich text result option:**
```swift
coordinator.writingToolsResultOptions = [.richText, .presentationIntent]
```

**Difference from display attributes:**

**Display attributes** (bold, italic):
- Concrete font info (point sizes, font names)
- No semantic meaning

**PresentationIntent** (header, code block, emphasis):
- Semantic style info
- App converts to internal styles
- Lists, tables, code blocks use presentation intent
- Underline, subscript, superscript still use display attributes

**Example:**
```swift
// Check for presentation intent
if attributedString.runs[\.presentationIntent].contains(where: { $0?.components.contains(.header(level: 1)) == true }) {
    // This is a heading
}
```

## SwiftUI TextEditor + AttributedString (iOS 26+)

### Basic Usage

```swift
struct RecipeEditor: View {
    @State private var text: AttributedString = "Recipe text"

    var body: some View {
        TextEditor(text: $text)
    }
}
```

**Supported attributes:**
- Bold, italic, underline, strikethrough
- Custom fonts, point size
- Foreground and background colors
- Kerning, tracking, baseline offset
- Genmoji
- Line height, text alignment, base writing direction

### TextAlignment and WritingDirection

```swift
// Text alignment (AttributedString.TextAlignment)
var text = AttributedString("Centered paragraph")
text.alignment = .center  // .left, .right, .center

// Writing direction for bidirectional text
var bidiText = AttributedString("Hello عربي")
bidiText.writingDirection = .rightToLeft  // .leftToRight, .rightToLeft
```

### LineHeight Control

```swift
var multiline = AttributedString("Paragraph\nwith multiple\nlines.")
multiline.lineHeight = .exact(points: 32)       // Fixed height
multiline.lineHeight = .multiple(factor: 2.5)   // Multiplier
multiline.lineHeight = .loose                    // System loose spacing
```

### Selection Binding

```swift
@State private var selection: AttributedTextSelection?

TextEditor(text: $text, selection: $selection)
```

**AttributedTextSelection:**
```swift
enum AttributedTextSelection {
    case none
    case single(NSRange)
    case multiple(Set) // For bidirectional text
}
```

**Get selected text:**
```swift
if let selection {
    let selectedText: AttributedSubstring
    switch selection.indices {
    case .none:
        selectedText = text[...]
    case .single(let range):
        selectedText = text[range]
    case .multiple(let ranges):
        // Discontiguous substring from RangeSet
        selectedText = text[selection]
    }
}
```

### Programmatic Selection Replacement

```swift
var text = AttributedString("Here is my dog")
var selection = AttributedTextSelection(range: text.range(of: "dog")!)

// Replace with plain text
text.replaceSelection(&selection, withCharacters: "cat")

// Replace with attributed content
let replacement = AttributedString("horse")
text.replaceSelection(&selection, with: replacement)
```

### DiscontiguousAttributedSubstring

Work with non-contiguous selections using `RangeSet`:

```swift
let text = AttributedString("Select multiple parts of this text")
let range1 = text.range(of: "Select")!
let range2 = text.range(of: "text")!
let rangeSet = RangeSet([range1, range2])
var substring = text[rangeSet]  // DiscontiguousAttributedSubstring
substring.backgroundColor = .yellow

// Convert back to AttributedString
let combined = AttributedString(substring)
```

### Text Selection Affinity

Control selection affinity for the view hierarchy:

```swift
TextEditor(text: $text, selection: $selection)
    .textSelectionAffinity(.upstream)  // .upstream or .downstream
```

Use `.upstream` when selection should resolve toward the beginning of text at line boundaries.

### Custom Formatting Definition

**Constrain which attributes are editable:**

```swift
struct RecipeFormattingDefinition: AttributedTextFormattingDefinition {
    typealias FormatScope = RecipeAttributeScope

    static let constraints: [any AttributedTextValueConstraint] = [
        IngredientsAreGreen()
    ]
}

struct RecipeAttributeScope: AttributedScope {
    var ingredient: IngredientAttribute
    var foregroundColor: ForegroundColorAttribute
    var genmoji: GenmojiAttribute
}
```

**Apply to TextEditor:**
```swift
TextEditor(text: $text)
    .attributedTextFormattingDefinition(RecipeFormattingDefinition.self)
```

### Value Constraints

**Control attribute values based on custom logic:**

```swift
struct IngredientsAreGreen: AttributedTextValueConstraint {
    typealias Definition = RecipeFormattingDefinition
    typealias AttributeKey = ForegroundColorAttribute

    func constrain(
        _ value: inout Color?,
        in scope: RecipeFormattingDefinition.FormatScope
    ) {
        if scope.ingredient != nil {
            value = .green // Ingredients are always green
        } else {
            value = nil // Others use default
        }
    }
}
```

**System behavior:**
- TextEditor probes constraints to determine if changes are valid
- If constraint would revert change, control is disabled
- Constraints applied to pasted content

### Custom Attributes

**Define attribute:**
```swift
struct IngredientAttribute: CodableAttributedStringKey {
    typealias Value = UUID // Ingredient ID

    static let name = "ingredient"
}

extension AttributeScopes.RecipeAttributeScope {
    var ingredient: IngredientAttribute.Type { IngredientAttribute.self }
}
```

**Attribute behavior:**
```swift
extension IngredientAttribute {
    // Don't expand when typing after ingredient
    static let inheritedByAddedText = false

    // Remove if text in run changes
    static let invalidationConditions: [AttributedString.InvalidationCondition] = [
        .textChanged
    ]

    // Optional: constrain to paragraph boundaries
    static let runBoundaries: AttributedString.RunBoundaries = .paragraph
}
```

### AttributedString Mutations

**Safe index updates:**
```swift
// Transform updates indices/selection during mutation
text.transform(updating: &selection) { mutableText in
    // Find ranges
    let ranges = mutableText.characters.ranges(of: "butter")

    // Set attribute for all ranges at once
    for range in ranges {
        mutableText[range].ingredient = ingredientID
    }
}

// selection is now updated to match transformed text
```

**Don't use old indices:**
```swift
// BAD - indices invalidated by mutation
let range = text.characters.range(of: "butter")!
text[range].foregroundColor = .green
text.append(" (unsalted)") // range is now invalid!
```

### AttributedString Views

Multiple views into same content:
- `characters` — grapheme clusters
- `unicodeScalars` — Unicode scalars
- `utf8` — UTF-8 code units
- `utf16` — UTF-16 code units

All views share same indices.

## Known Limitations & Gotchas

### Viewport Scroll Issues

From expert articles:
- Viewport can cause scroll position instability
- `usageBoundsForTextContainer` changes during scroll
- Apple's TextEdit exhibits same issues
- Trade-off for performance benefits

### TextKit 1 Compatibility

- Accessing `.layoutManager` triggers fallback
- One-way operation (no automatic return)
- Loses UI state during switch
- Expensive to switch layout systems

### AttributedString Index Invalidation

- Any mutation invalidates all indices
- Must use `.transform(updating:)` to keep indices valid
- Indices only work with originating AttributedString

### Limited TextKit 1 Support

Unsupported in TextKit 2:
- NSTextTable (use NSTextList or custom layouts)
- Some legacy text attachments
- Direct glyph manipulation

## Resources

**WWDC**: 2021-10061, 2022-10090, 2023-10058, 2024-10168, 2025-265, 2025-280

**Docs**: /uikit/nstextlayoutmanager, /appkit/textkit/using_textkit_2_to_interact_with_text, /uikit/display-text-with-a-custom-layout, /swiftui/building-rich-swiftui-text-experiences, /foundation/attributedstring, /foundation/attributedstring/textalignment, /foundation/attributedstring/lineheight, /foundation/discontiguousattributedsubstring, /uikit/writing-tools, /appkit/enhancing-your-custom-text-engine-with-writing-tools