Add scan flow MVP and local Axiom skill workspace
This snapshot establishes the camera-to-result recognition flow and related tests while checking in the project skill/docs assets required for the configured local tooling.
This commit is contained in:
951
.claude/skills/axiom-textkit-ref/SKILL.md
Normal file
951
.claude/skills/axiom-textkit-ref/SKILL.md
Normal file
@@ -0,0 +1,951 @@
|
||||
---
|
||||
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..<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.
|
||||
|
||||
### 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:** `<blockquote>` and `<pre>` 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<NSRange>) // 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<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.
|
||||
|
||||
## 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
|
||||
Reference in New Issue
Block a user