Claude Code Plugins

Community-maintained marketplace

Feedback
56
0

TextKit 2 complete reference (architecture, migration, Writing Tools, SwiftUI TextEditor) through iOS 26

Install Skill

1Download skill
2Enable skills in Claude

Open claude.ai/settings/capabilities and find the "Skills" section

3Upload to Claude

Click "Upload skill" and select the downloaded ZIP file

Note: Please verify skill by going through its instructions before using it.

SKILL.md

name textkit-ref
description TextKit 2 complete reference (architecture, migration, Writing Tools, SwiftUI TextEditor) through iOS 26

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:

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

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

// TextKit 1 - iterate glyphs
var lineCount = 0
let glyphRange = layoutManager.glyphRange(for: textContainer)
for glyphIndex in glyphRange.location..<NSMaxRange(glyphRange) {
    let lineRect = layoutManager.lineFragmentRect(
        forGlyphAt: glyphIndex,
        effectiveRange: nil
    )
    // Count unique rects...
}

Replacement (TextKit 2 - enumerate fragments):

// TextKit 2 - enumerate layout fragments
var lineCount = 0
textLayoutManager.enumerateTextLayoutFragments(
    from: textLayoutManager.documentRange.location,
    options: [.ensuresLayout]
) { fragment in
    lineCount += fragment.textLineFragments.count
    return true
}

Compatibility Mode (UITextView/NSTextView)

Automatic Fallback to TextKit 1: Happens when you access .layoutManager property.

Warning (WWDC 2022):

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

let textLayoutManager = NSTextLayoutManager()
let textContainer = NSTextContainer()
textLayoutManager.textContainer = textContainer

let textView = NSTextView(frame: .zero, textContainer: textContainer)
// textView.textLayoutManager now available

New Convenience Constructor:

// iOS 16+ / macOS 13+
let textView = UITextView(usingTextLayoutManager: true)
let nsTextView = NSTextView(usingTextLayoutManager: true)

Delegate Hooks

NSTextContentStorageDelegate

Customize attributes without modifying storage:

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

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:

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:

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)

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:

textLayoutManager.addRenderingAttribute(
    .foregroundColor,
    value: UIColor.green,
    for: ingredientRange
)

// Remove when no longer needed
textLayoutManager.removeRenderingAttribute(
    .foregroundColor,
    for: ingredientRange
)

Text Attachment with UIView

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

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

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

// UITextView, NSTextView, WKWebView
// Writing Tools appears automatically

Lifecycle Delegate Methods

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

// Opt out completely
textView.writingToolsBehavior = .none

// Panel-only experience (no in-line edits)
textView.writingToolsBehavior = .limited

// Full experience (default)
textView.writingToolsBehavior = .default

Result Options

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

// UITextViewDelegate / NSTextViewDelegate
func textView(
    _ textView: UITextView,
    writingToolsIgnoredRangesIn enclosingRange: NSRange
) -> [NSRange] {
    // Return ranges that Writing Tools should not modify
    return codeBlockRanges + quoteRanges
}

WKWebView: <blockquote> and <pre> tags automatically ignored.

Writing Tools Coordinator (iOS 26+)

Advanced integration for custom text engines.

Setup

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

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:

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:

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

Provide previews for animation:

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

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:

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:

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

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

Selection Binding

@State private var selection: AttributedTextSelection?

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

AttributedTextSelection:

enum AttributedTextSelection {
    case none
    case single(NSRange)
    case multiple(Set<NSRange>) // For bidirectional text
}

Get selected text:

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

Custom Formatting Definition

Constrain which attributes are editable:

struct RecipeFormattingDefinition: AttributedTextFormattingDefinition {
    typealias FormatScope = RecipeAttributeScope

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

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

Apply to TextEditor:

TextEditor(text: $text)
    .attributedTextFormattingDefinition(RecipeFormattingDefinition.self)

Value Constraints

Control attribute values based on custom logic:

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:

struct IngredientAttribute: CodableAttributedStringKey {
    typealias Value = UUID // Ingredient ID

    static let name = "ingredient"
}

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

Attribute behavior:

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:

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

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

Documentation

Expert Articles

Sample Code