INTENDED FOR✳Claude Code>_Codex↗Cursor◎ChatGPTInstall in a listed agent.
instructions.md
---
name: axiom-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, SwiftUI TextEditor with AttributedString, and the 27-cycle additions (per-edge block borders, hit-testing transformed text, grammar checking, viewport rendering surfaces).
## 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..<NSMaxRange(glyphRange) {
let lineRect = layoutManager.lineFragmentRect(
forGlyphAt: glyphIndex,
effectiveRange: nil
)
// Count unique rects...
}
```
**Replacement (TextKit 2 - enumerate fragments):**
```swift
// 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
```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.
#### Per-edge block borders `OS27`
Per-edge border color and width were **AppKit-only** before 27 — `setBorderColor:forEdge:` / `widthForLayer:edge:`, taking `NSRectEdge`, back to macOS 10.0 — while UIKit could set only **all four edges at once**. 27 adds a `CGRectEdge`-based `rectEdge:` variant of each on all platforms, watchOS included. On macOS these replace the old `NSRectEdge` family, which the 27 SDK marks to-be-deprecated (`API_TO_BE_DEPRECATED` with a named replacement, no compiler warning yet); on iOS/tvOS/visionOS/watchOS they are genuinely new capability:
```swift
block.setBorderColor(.gray, rectEdge: .minYEdge)
block.setWidth(1, type: .absolute, for: .border, rectEdge: .minYEdge)
let color = block.borderColor(for: .minYEdge)
let width = block.width(for: .border, rectEdge: .minYEdge)
let type = block.widthValueType(for: .border, rectEdge: .minYEdge)
```
The `for:` label is overloaded across this family: in the three width methods it takes an `NSTextBlock.Layer` (`.padding`, `.border`, `.margin`), so per-edge control extends to padding and margins — but in `borderColor(for:)` the `for:` label *is* the `CGRectEdge`. The all-edges methods are unchanged. This is model-level styling on `NSTextBlock` — it does not lift the TextKit 2 table-layout limitation noted under Known Limitations below.
### 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
}
```
#### Hit-testing transformed text `OS27`
A custom `NSTextSelectionDataSource` that draws text through a display transform (rotation, flip, path layout) can now undo it before hit-testing, via the **optional** `convertInteractionPoint(_:toContainerAt:)`:
```swift
func convertInteractionPoint(_ point: CGPoint,
toContainerAt containerLocation: any NSTextLocation) -> CGPoint {
point.applying(transform.inverted())
}
```
`NSTextSelectionNavigation` calls it before hit-testing; return `point` unchanged when no transform is active. Being optional, it leaves existing data sources unaffected. macOS/iOS/tvOS/visionOS 27, not watchOS.
## 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.allowedWritingToolsResultOptions = [.plainText]
// Rich text
textView.allowedWritingToolsResultOptions = [.richText]
// Rich text + lists (.list implies .richText)
textView.allowedWritingToolsResultOptions = [.list]
```
`.table` (implies `.richText`) is for `NSTextView` only: `UITextView` throws an exception when `allowedWritingToolsResultOptions` includes it.
### 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:** `<blockquote>` and `<pre>` tags automatically ignored.
## Writing Tools Coordinator (iOS 18.2+)
Advanced integration for custom text engines. `UIWritingToolsCoordinator` and `NSWritingToolsCoordinator` — along with their `Context`, `ContextScope`, `State`, and delegate types — are available on iOS 18.2+ / iPadOS 18.2+ / visionOS 2.4+ / macOS 15.2+. A few sub-features are gated at iOS 26.0+ and called out explicitly below (the `includesTextListMarkers` property and the `.presentationIntent` result option).
### Setup
```swift
// UIKit
let coordinator = UIWritingToolsCoordinator(delegate: self) // delegate is set at init (read-only)
textView.addInteraction(coordinator) // the coordinator is a UIInteraction
coordinator.preferredBehavior = .default
coordinator.preferredResultOptions = [.richText]
// AppKit
let coordinator = NSWritingToolsCoordinator(delegate: self)
customView.writingToolsCoordinator = coordinator // NSView has a settable writingToolsCoordinator
```
### Coordinator Delegate
Conform your engine object to **`UIWritingToolsCoordinator.Delegate`** (a nested protocol — `UIWritingToolsCoordinator.Delegate`, *not* `UIWritingToolsCoordinatorDelegate`). Eight methods are **required**, all `async`; the framework drives the whole exchange through them.
**Provide context** — return the text Writing Tools should evaluate:
```swift
func writingToolsCoordinator(
_ coordinator: UIWritingToolsCoordinator,
contextsFor scope: UIWritingToolsCoordinator.ContextScope
) async -> [UIWritingToolsCoordinator.Context] {
[UIWritingToolsCoordinator.Context(attributedString: currentText, range: currentSelection)]
}
```
**Apply changes** — replace `range`; return the text you actually stored (or `nil`):
```swift
func writingToolsCoordinator(
_ coordinator: UIWritingToolsCoordinator,
replace range: NSRange,
in context: UIWritingToolsCoordinator.Context,
proposedText replacementText: NSAttributedString,
reason: UIWritingToolsCoordinator.TextReplacementReason,
animationParameters: UIWritingToolsCoordinator.AnimationParameters?
) async -> NSAttributedString? {
textStorage.replaceCharacters(in: range, with: replacementText)
return replacementText
}
```
**Update selection** — `ranges` is an array of boxed `NSRange` values:
```swift
func writingToolsCoordinator(
_ coordinator: UIWritingToolsCoordinator,
select ranges: [NSValue],
in context: UIWritingToolsCoordinator.Context
) async {
selectedRanges = ranges
}
```
**Preview for an animation** — return a `UITargetedPreview?` for the range:
```swift
func writingToolsCoordinator(
_ coordinator: UIWritingToolsCoordinator,
previewFor textAnimation: UIWritingToolsCoordinator.TextAnimation,
range: NSRange,
context: UIWritingToolsCoordinator.Context
) async -> UITargetedPreview? {
UITargetedPreview(view: previewView, parameters: parameters)
}
```
**Proofreading underlines** — return bezier paths for the range:
```swift
func writingToolsCoordinator(
_ coordinator: UIWritingToolsCoordinator,
underlinePathsFor range: NSRange,
context: UIWritingToolsCoordinator.Context
) async -> [UIBezierPath] {
underlineRanges.map { bezierPath(for: $0) }
}
```
The remaining required methods follow the same shape: `boundingBezierPathsFor:context:` (decoration bounds) and the animation bracket `prepareFor:for:in:` / `finish:for:in:` (each wraps one `TextAnimation`). Four methods are **optional**: `singleContainerSubrangesOf:in:` and `decorationContainerViewFor:in:` support multi-container layouts, `willChangeTo:` reports coordinator state transitions, and `rangeInContextWithIdentifierFor:` (a point-based lookup) is deprecated and unused on iOS 18.4+.
> **AppKit parallel**: on macOS conform to `NSWritingToolsCoordinator.Delegate` (same method names); previews return `[NSTextPreview]`, and you attach the coordinator via the settable `NSView.writingToolsCoordinator` instead of `addInteraction(_:)`.
### PresentationIntent (iOS 26+)
**Semantic rich text result option:**
```swift
coordinator.preferredResultOptions = [.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
}
```
## Grammar Checking `OS27`
`UITextChecker` gains programmatic **grammar** checking to sit beside its long-standing spell checking. iOS/tvOS/visionOS/Mac Catalyst 27; the class itself has never been available on watchOS. `UITextChecker` is main-actor isolated.
```swift
@MainActor
func proofread(_ text: String) async {
let checker = UITextChecker()
let whole = NSRange(text.startIndex..., in: text)
let results = await checker.requestGrammarChecking(
of: text, range: whole, waitForAllResults: true)
for result in results where result.resultType == .grammar {
let sentence = (text as NSString).substring(with: result.range)
// An absent NSGrammarRange means the issue spans the whole sentence
let issue = NSRange(location: 0, length: (sentence as NSString).length)
checker.ignoreGrammarRange(issue, inSentence: sentence)
}
}
```
| API | Notes |
|---|---|
| `requestGrammarChecking(of:range:waitForAllResults:)` | `async` returning `[NSTextCheckingResult]`; a completion-handler overload exists too |
| `waitForAllResults` | `true` waits for all grammar analysis; `false` returns only the results available quickly |
| result types | `.grammar` or `.correction`, sorted by range origin, then range end, then result type |
| `ignoreGrammarRange(_:inSentence:)` | suppresses one issue — `sentence` is the substring at the result's `range`, and the range is an offset **within that sentence**, not into the document |
#### The grammarDetails keys are macOS-only
`result.grammarDetails` is `[[String: Any]]?`, and Apple's own iOS header comment directs you to its `NSGrammarRange` entry — but `NSGrammarRange`, `NSGrammarUserDescription` and `NSGrammarCorrections` are declared in macOS's `NSSpellServer.h` as `API_UNAVAILABLE(ios, watchos, tvos)`. On iOS the constant does not exist (`error: cannot find 'NSGrammarRange' in scope`); the identical line compiles on macOS. Don't design an iOS feature around reading those detail dictionaries by name — `ignoreGrammarRange(_:inSentence:)` takes a plain `NSRange` offset within the sentence. Foundation defines an absent `NSGrammarRange` as the whole sentence, which is the documented fallback when you can't read the detail.
## 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:** A `struct` (iOS 26.0+), not an enum. Selection is expressed with `AttributedString.Index` and `RangeSet`, never `NSRange`. Its nested `Indices` enum, obtained via the `indices(in:)` method, distinguishes a caret from one or more ranges:
```swift
public struct AttributedTextSelection: Equatable, Sendable { // iOS 26.0+
@frozen public enum Indices: Equatable, Sendable {
case insertionPoint(AttributedString.Index)
case ranges(RangeSet<AttributedString.Index>)
}
}
// Inits:
AttributedTextSelection() // empty
AttributedTextSelection(insertionPoint: index) // caret
AttributedTextSelection(ranges: rangeSet) // discontiguous
AttributedTextSelection(range: text.range(of: "dog")!) // single range
```
**Get selected text:**
```swift
if let selection {
// indices(in:) is a method, not a property
switch selection.indices(in: text) {
case .insertionPoint:
// Caret only — nothing selected
break
case .ranges:
// text[selection] yields a DiscontiguousAttributedSubstring,
// covering both single-range and multi-range (bidirectional) selections
let selectedText: DiscontiguousAttributedSubstring = text[selection]
_ = selectedText
}
}
```
### 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<RecipeFormattingDefinition>] = [
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.
## Viewport Rendering Surfaces & Attachment Reuse `OS27`
Before 27 you faced a hard choice: use a framework text view (`UITextView`/`NSTextView`/`TextEditor` — lots free, little rendering control) **or** build a custom view on a raw `NSTextLayoutManager` + viewport layout (total control, but you re-implement input, selection, accessibility, undo, dictation). At 27 you can customize **rendering and viewport from inside a framework text view** by subclassing it.
### Subclassable viewport delegate on framework text views
A `UITextView` subclass can now override the three `NSTextViewportLayoutControllerDelegate` hooks directly and drive relayout:
```swift
final class CodeTextView: UITextView {
override func textViewportLayoutControllerWillLayout(_ c: NSTextViewportLayoutController) { … }
override func textViewportLayoutController(_ c: NSTextViewportLayoutController,
configureRenderingSurfaceFor fragment: NSTextLayoutFragment) { … } // attach a surface per fragment
override func textViewportLayoutControllerDidLayout(_ c: NSTextViewportLayoutController) { … }
func relayout() {
textLayoutManager?.textViewportLayoutController.delegate?
.textViewportLayoutControllerReceivedSetNeedsLayout?(/* controller */)
}
}
```
#### Rendering surfaces
`NSTextViewportRenderingSurface` (new at 27) lets your `UIView`/layer render text fragments — exposing rendering customization previously locked inside framework text views. Cache surfaces per fragment keyed by `NSTextLayoutFragment` (which already conforms to the older `NSTextViewportRenderingSurfaceKey` protocol, iOS 18) using an `NSMapTable<NSTextLayoutFragment, MyView>`.
#### Attachment view-provider reuse
Inline interactive/animated attachments (Messages-style inline photos, stickers, animations) can recycle their views instead of rebuilding:
```swift
textView.registerTextAttachmentViewProviderReusePolicy(
[.onEditingInlineParagraphs],
forTextAttachmentViewProviderType: AnimatedAttachmentViewProvider.self)
// Swift: textView.register([.onEditingInlineParagraphs], forTextAttachmentViewProviderType: …)
```
(`registerTextAttachmentViewProviderReusePolicy(_:forTextAttachmentViewProviderType:)` is `iOS27`/`macOS27`/`tvOS27`/`visionOS27` — NSTextView gets it too — not watchOS.)
#### Skip-layout / collapsible content
Exclude collapsed paragraphs (e.g. collapsible recipe sections) from layout via `NSTextContentStorageDelegate`:
```swift
func textContentManager(_ m: NSTextContentManager, shouldEnumerate element: NSTextElement,
options: NSTextContentManager.EnumerationOptions) -> Bool { isVisible(element) }
```
## 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, 2026-370
**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, /uikit/uitextchecker, /uikit/nstextblock, /uikit/nstextselectiondatasource
---
name: axiom-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, SwiftUI TextEditor with AttributedString, and the 27-cycle additions (per-edge block borders, hit-testing transformed text, grammar checking, viewport rendering surfaces).
## 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..<NSMaxRange(glyphRange) {
let lineRect = layoutManager.lineFragmentRect(
forGlyphAt: glyphIndex,
effectiveRange: nil
)
// Count unique rects...
}
```
**Replacement (TextKit 2 - enumerate fragments):**
```swift
// 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
```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.
#### Per-edge block borders `OS27`
Per-edge border color and width were **AppKit-only** before 27 — `setBorderColor:forEdge:` / `widthForLayer:edge:`, taking `NSRectEdge`, back to macOS 10.0 — while UIKit could set only **all four edges at once**. 27 adds a `CGRectEdge`-based `rectEdge:` variant of each on all platforms, watchOS included. On macOS these replace the old `NSRectEdge` family, which the 27 SDK marks to-be-deprecated (`API_TO_BE_DEPRECATED` with a named replacement, no compiler warning yet); on iOS/tvOS/visionOS/watchOS they are genuinely new capability:
```swift
block.setBorderColor(.gray, rectEdge: .minYEdge)
block.setWidth(1, type: .absolute, for: .border, rectEdge: .minYEdge)
let color = block.borderColor(for: .minYEdge)
let width = block.width(for: .border, rectEdge: .minYEdge)
let type = block.widthValueType(for: .border, rectEdge: .minYEdge)
```
The `for:` label is overloaded across this family: in the three width methods it takes an `NSTextBlock.Layer` (`.padding`, `.border`, `.margin`), so per-edge control extends to padding and margins — but in `borderColor(for:)` the `for:` label *is* the `CGRectEdge`. The all-edges methods are unchanged. This is model-level styling on `NSTextBlock` — it does not lift the TextKit 2 table-layout limitation noted under Known Limitations below.
### 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
}
```
#### Hit-testing transformed text `OS27`
A custom `NSTextSelectionDataSource` that draws text through a display transform (rotation, flip, path layout) can now undo it before hit-testing, via the **optional** `convertInteractionPoint(_:toContainerAt:)`:
```swift
func convertInteractionPoint(_ point: CGPoint,
toContainerAt containerLocation: any NSTextLocation) -> CGPoint {
point.applying(transform.inverted())
}
```
`NSTextSelectionNavigation` calls it before hit-testing; return `point` unchanged when no transform is active. Being optional, it leaves existing data sources unaffected. macOS/iOS/tvOS/visionOS 27, not watchOS.
## 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.allowedWritingToolsResultOptions = [.plainText]
// Rich text
textView.allowedWritingToolsResultOptions = [.richText]
// Rich text + lists (.list implies .richText)
textView.allowedWritingToolsResultOptions = [.list]
```
`.table` (implies `.richText`) is for `NSTextView` only: `UITextView` throws an exception when `allowedWritingToolsResultOptions` includes it.
### 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:** `<blockquote>` and `<pre>` tags automatically ignored.
## Writing Tools Coordinator (iOS 18.2+)
Advanced integration for custom text engines. `UIWritingToolsCoordinator` and `NSWritingToolsCoordinator` — along with their `Context`, `ContextScope`, `State`, and delegate types — are available on iOS 18.2+ / iPadOS 18.2+ / visionOS 2.4+ / macOS 15.2+. A few sub-features are gated at iOS 26.0+ and called out explicitly below (the `includesTextListMarkers` property and the `.presentationIntent` result option).
### Setup
```swift
// UIKit
let coordinator = UIWritingToolsCoordinator(delegate: self) // delegate is set at init (read-only)
textView.addInteraction(coordinator) // the coordinator is a UIInteraction
coordinator.preferredBehavior = .default
coordinator.preferredResultOptions = [.richText]
// AppKit
let coordinator = NSWritingToolsCoordinator(delegate: self)
customView.writingToolsCoordinator = coordinator // NSView has a settable writingToolsCoordinator
```
### Coordinator Delegate
Conform your engine object to **`UIWritingToolsCoordinator.Delegate`** (a nested protocol — `UIWritingToolsCoordinator.Delegate`, *not* `UIWritingToolsCoordinatorDelegate`). Eight methods are **required**, all `async`; the framework drives the whole exchange through them.
**Provide context** — return the text Writing Tools should evaluate:
```swift
func writingToolsCoordinator(
_ coordinator: UIWritingToolsCoordinator,
contextsFor scope: UIWritingToolsCoordinator.ContextScope
) async -> [UIWritingToolsCoordinator.Context] {
[UIWritingToolsCoordinator.Context(attributedString: currentText, range: currentSelection)]
}
```
**Apply changes** — replace `range`; return the text you actually stored (or `nil`):
```swift
func writingToolsCoordinator(
_ coordinator: UIWritingToolsCoordinator,
replace range: NSRange,
in context: UIWritingToolsCoordinator.Context,
proposedText replacementText: NSAttributedString,
reason: UIWritingToolsCoordinator.TextReplacementReason,
animationParameters: UIWritingToolsCoordinator.AnimationParameters?
) async -> NSAttributedString? {
textStorage.replaceCharacters(in: range, with: replacementText)
return replacementText
}
```
**Update selection** — `ranges` is an array of boxed `NSRange` values:
```swift
func writingToolsCoordinator(
_ coordinator: UIWritingToolsCoordinator,
select ranges: [NSValue],
in context: UIWritingToolsCoordinator.Context
) async {
selectedRanges = ranges
}
```
**Preview for an animation** — return a `UITargetedPreview?` for the range:
```swift
func writingToolsCoordinator(
_ coordinator: UIWritingToolsCoordinator,
previewFor textAnimation: UIWritingToolsCoordinator.TextAnimation,
range: NSRange,
context: UIWritingToolsCoordinator.Context
) async -> UITargetedPreview? {
UITargetedPreview(view: previewView, parameters: parameters)
}
```
**Proofreading underlines** — return bezier paths for the range:
```swift
func writingToolsCoordinator(
_ coordinator: UIWritingToolsCoordinator,
underlinePathsFor range: NSRange,
context: UIWritingToolsCoordinator.Context
) async -> [UIBezierPath] {
underlineRanges.map { bezierPath(for: $0) }
}
```
The remaining required methods follow the same shape: `boundingBezierPathsFor:context:` (decoration bounds) and the animation bracket `prepareFor:for:in:` / `finish:for:in:` (each wraps one `TextAnimation`). Four methods are **optional**: `singleContainerSubrangesOf:in:` and `decorationContainerViewFor:in:` support multi-container layouts, `willChangeTo:` reports coordinator state transitions, and `rangeInContextWithIdentifierFor:` (a point-based lookup) is deprecated and unused on iOS 18.4+.
> **AppKit parallel**: on macOS conform to `NSWritingToolsCoordinator.Delegate` (same method names); previews return `[NSTextPreview]`, and you attach the coordinator via the settable `NSView.writingToolsCoordinator` instead of `addInteraction(_:)`.
### PresentationIntent (iOS 26+)
**Semantic rich text result option:**
```swift
coordinator.preferredResultOptions = [.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
}
```
## Grammar Checking `OS27`
`UITextChecker` gains programmatic **grammar** checking to sit beside its long-standing spell checking. iOS/tvOS/visionOS/Mac Catalyst 27; the class itself has never been available on watchOS. `UITextChecker` is main-actor isolated.
```swift
@MainActor
func proofread(_ text: String) async {
let checker = UITextChecker()
let whole = NSRange(text.startIndex..., in: text)
let results = await checker.requestGrammarChecking(
of: text, range: whole, waitForAllResults: true)
for result in results where result.resultType == .grammar {
let sentence = (text as NSString).substring(with: result.range)
// An absent NSGrammarRange means the issue spans the whole sentence
let issue = NSRange(location: 0, length: (sentence as NSString).length)
checker.ignoreGrammarRange(issue, inSentence: sentence)
}
}
```
| API | Notes |
|---|---|
| `requestGrammarChecking(of:range:waitForAllResults:)` | `async` returning `[NSTextCheckingResult]`; a completion-handler overload exists too |
| `waitForAllResults` | `true` waits for all grammar analysis; `false` returns only the results available quickly |
| result types | `.grammar` or `.correction`, sorted by range origin, then range end, then result type |
| `ignoreGrammarRange(_:inSentence:)` | suppresses one issue — `sentence` is the substring at the result's `range`, and the range is an offset **within that sentence**, not into the document |
#### The grammarDetails keys are macOS-only
`result.grammarDetails` is `[[String: Any]]?`, and Apple's own iOS header comment directs you to its `NSGrammarRange` entry — but `NSGrammarRange`, `NSGrammarUserDescription` and `NSGrammarCorrections` are declared in macOS's `NSSpellServer.h` as `API_UNAVAILABLE(ios, watchos, tvos)`. On iOS the constant does not exist (`error: cannot find 'NSGrammarRange' in scope`); the identical line compiles on macOS. Don't design an iOS feature around reading those detail dictionaries by name — `ignoreGrammarRange(_:inSentence:)` takes a plain `NSRange` offset within the sentence. Foundation defines an absent `NSGrammarRange` as the whole sentence, which is the documented fallback when you can't read the detail.
## 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:** A `struct` (iOS 26.0+), not an enum. Selection is expressed with `AttributedString.Index` and `RangeSet`, never `NSRange`. Its nested `Indices` enum, obtained via the `indices(in:)` method, distinguishes a caret from one or more ranges:
```swift
public struct AttributedTextSelection: Equatable, Sendable { // iOS 26.0+
@frozen public enum Indices: Equatable, Sendable {
case insertionPoint(AttributedString.Index)
case ranges(RangeSet<AttributedString.Index>)
}
}
// Inits:
AttributedTextSelection() // empty
AttributedTextSelection(insertionPoint: index) // caret
AttributedTextSelection(ranges: rangeSet) // discontiguous
AttributedTextSelection(range: text.range(of: "dog")!) // single range
```
**Get selected text:**
```swift
if let selection {
// indices(in:) is a method, not a property
switch selection.indices(in: text) {
case .insertionPoint:
// Caret only — nothing selected
break
case .ranges:
// text[selection] yields a DiscontiguousAttributedSubstring,
// covering both single-range and multi-range (bidirectional) selections
let selectedText: DiscontiguousAttributedSubstring = text[selection]
_ = selectedText
}
}
```
### 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<RecipeFormattingDefinition>] = [
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.
## Viewport Rendering Surfaces & Attachment Reuse `OS27`
Before 27 you faced a hard choice: use a framework text view (`UITextView`/`NSTextView`/`TextEditor` — lots free, little rendering control) **or** build a custom view on a raw `NSTextLayoutManager` + viewport layout (total control, but you re-implement input, selection, accessibility, undo, dictation). At 27 you can customize **rendering and viewport from inside a framework text view** by subclassing it.
### Subclassable viewport delegate on framework text views
A `UITextView` subclass can now override the three `NSTextViewportLayoutControllerDelegate` hooks directly and drive relayout:
```swift
final class CodeTextView: UITextView {
override func textViewportLayoutControllerWillLayout(_ c: NSTextViewportLayoutController) { … }
override func textViewportLayoutController(_ c: NSTextViewportLayoutController,
configureRenderingSurfaceFor fragment: NSTextLayoutFragment) { … } // attach a surface per fragment
override func textViewportLayoutControllerDidLayout(_ c: NSTextViewportLayoutController) { … }
func relayout() {
textLayoutManager?.textViewportLayoutController.delegate?
.textViewportLayoutControllerReceivedSetNeedsLayout?(/* controller */)
}
}
```
#### Rendering surfaces
`NSTextViewportRenderingSurface` (new at 27) lets your `UIView`/layer render text fragments — exposing rendering customization previously locked inside framework text views. Cache surfaces per fragment keyed by `NSTextLayoutFragment` (which already conforms to the older `NSTextViewportRenderingSurfaceKey` protocol, iOS 18) using an `NSMapTable<NSTextLayoutFragment, MyView>`.
#### Attachment view-provider reuse
Inline interactive/animated attachments (Messages-style inline photos, stickers, animations) can recycle their views instead of rebuilding:
```swift
textView.registerTextAttachmentViewProviderReusePolicy(
[.onEditingInlineParagraphs],
forTextAttachmentViewProviderType: AnimatedAttachmentViewProvider.self)
// Swift: textView.register([.onEditingInlineParagraphs], forTextAttachmentViewProviderType: …)
```
(`registerTextAttachmentViewProviderReusePolicy(_:forTextAttachmentViewProviderType:)` is `iOS27`/`macOS27`/`tvOS27`/`visionOS27` — NSTextView gets it too — not watchOS.)
#### Skip-layout / collapsible content
Exclude collapsed paragraphs (e.g. collapsible recipe sections) from layout via `NSTextContentStorageDelegate`:
```swift
func textContentManager(_ m: NSTextContentManager, shouldEnumerate element: NSTextElement,
options: NSTextContentManager.EnumerationOptions) -> Bool { isVisible(element) }
```
## 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, 2026-370
**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, /uikit/uitextchecker, /uikit/nstextblock, /uikit/nstextselectiondatasource
COMPATIBILITY & REFERENCES
Platform guidance
Imported from the listed AgentSkill.sh category. Complete supplied package files and notices are retained. Required external tools and credentials are not installed. No bundled code was executed during import. Individual agent task behavior has not been tested.
Resource notes updated 2026-10-27 · Original instructions by CharlesWiltgen
Creates comprehensive frontend UI/UX designs including user interfaces, design systems, component libraries, responsive layouts, and accessibility implementations. Produces wireframes, mockups, design specifications, and implementation guidelines. Use when designing user interfaces, creating desi...
Expert in building scalable design systems with Atomic Design, design tokens, and theming infrastructure. Use when creating component libraries, implementing dark mode, or establishing typography and color systems. Covers multi-brand support, Storybook-driven development, and accessibility-first ...
Use when implementing Liquid Glass effects, reviewing UI for Liquid Glass adoption, debugging visual artifacts, optimizing performance, or requesting expert review of Liquid Glass implementation - provides comprehensive design principles, API patterns, and troubleshooting guidance from WWDC 2025....