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:
textViewportLayoutControllerWillLayout(_:) β setup before layout
textViewportLayoutController(_:configureRenderingSurfaceFor:) β per fragment
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:
let nsRange = NSRange(location: 0, length: 10)
let startLocation = textContentManager.location(
textContentManager.documentRange.location,
offsetBy: nsRange.location
)!
let endLocation = textContentManager.location(
startLocation,
offsetBy: nsRange.length
)!
let textRange = NSTextRange(location: startLocation, end: endLocation)
NSTextRange β NSRange:
let startOffset = textContentManager.offset(
from: textContentManager.documentRange.location,
to: textRange.location
)
let length = textContentManager.offset(
from: textRange.location,
to: textRange.endLocation
)
let nsRange = NSRange(location: startOffset, length: length)
Glyph API Replacements
NO direct glyph API equivalents. Must use higher-level structures.
Example (TextKit 1 - counting lines):
var lineCount = 0
let glyphRange = layoutManager.glyphRange(for: textContainer)
for glyphIndex in glyphRange.location..<NSMaxRange(glyphRange) {
let lineRect = layoutManager.lineFragmentRect(
forGlyphAt: glyphIndex,
effectiveRange: nil
)
}
Replacement (TextKit 2 - enumerate 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:
- Check
.textLayoutManager first (TextKit 2)
- Only access
.layoutManager in else clause
- Opt out at initialization if TK1 required
if let textLayoutManager = textView.textLayoutManager {
} else if let layoutManager = textView.layoutManager {
}
Debug Fallback:
- UIKit: Breakpoint on
_UITextViewEnablingCompatibilityMode
- AppKit: Subscribe to
willSwitchToNSLayoutManagerNotification
NSTextView Opt-In (macOS)
Create TextKit 2 NSTextView:
let textLayoutManager = NSTextLayoutManager()
let textContainer = NSTextContainer()
textLayoutManager.textContainer = textContainer
let textView = NSTextView(frame: .zero, textContainer: textContainer)
New Convenience Constructor:
let textView = UITextView(usingTextLayoutManager: true)
let nsTextView = NSTextView(usingTextLayoutManager: true)
Delegate Hooks
NSTextContentStorageDelegate
Customize attributes without modifying storage:
func textContentStorage(
_ textContentStorage: NSTextContentStorage,
textParagraphWith range: NSRange
) -> NSTextParagraph? {
var attributedString = textContentStorage.attributedString!
.attributedSubstring(from: range)
if isComment(range) {
attributedString.addAttribute(
.foregroundColor,
value: UIColor.systemIndigo,
range: NSRange(location: 0, length: attributedString.length)
)
}
return NSTextParagraph(attributedString: attributedString)
}
Filter elements (hide/show content):
func textContentManager(
_ textContentManager: NSTextContentManager,
shouldEnumerate textElement: NSTextElement,
options: NSTextContentManager.EnumerationOptions
) -> Bool {
if hideComments && isComment(textElement)