| 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:
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) {
return false
}
return true
}
NSTextLayoutManagerDelegate
Provide custom layout fragments:
func textLayoutManager(
_ textLayoutManager: NSTextLayoutManager,
textLayoutFragmentFor location: NSTextLocation,
in textElement: NSTextElement
) -> NSTextLayoutFragment {
if isComment(textElement) {
return BubbleLayoutFragment(
textElement: textElement,
range: textElement.elementRange
)
}
return NSTextLayoutFragment(
textElement: textElement,
range: textElement.elementRange
)
}
NSTextViewportLayoutController.Delegate
Viewport layout lifecycle:
func textViewportLayoutControllerWillLayout(_ controller: NSTextViewportLayoutController) {
}
func textViewportLayoutController(
_ controller: NSTextViewportLayoutController,
configureRenderingSurfaceFor textLayoutFragment: NSTextLayoutFragment
) {
let layer = getOrCreateLayer(for: textLayoutFragment)
layer.frame = textLayoutFragment.layoutFragmentFrame
}
func textViewportLayoutControllerDidLayout(_ controller: NSTextViewportLayoutController) {
}
Practical Patterns
Custom Layout Fragment (Bubble Backgrounds)
class BubbleLayoutFragment: NSTextLayoutFragment {
override func draw(at point: CGPoint, in context: CGContext) {
context.setFillColor(UIColor.systemIndigo.cgColor)
let bubblePath = UIBezierPath(
roundedRect: layoutFragmentFrame,
cornerRadius: 8
)
context.addPath(bubblePath.cgPath)
context.fillPath()
super.draw(at: point, in: context)
}
}
Rendering Attributes (Temporary Styling)
Add attributes that don't modify text storage:
textLayoutManager.addRenderingAttribute(
.foregroundColor,
value: UIColor.green,
for: ingredientRange
)
textLayoutManager.removeRenderingAttribute(
.foregroundColor,
for: ingredientRange
)
Text Attachment with UIView
let attachment = NSTextAttachment()
attachment.image = UIImage(systemName: "star.fill")
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() {
}
}
Lists and Tables
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
let location = textLayoutManager.location(
interactingAt: point,
inContainerAt: textContainer.location
)
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:
Lifecycle Delegate Methods
func textViewWritingToolsWillBegin(_ textView: UITextView) {
isSyncing = false
}
func textViewWritingToolsDidEnd(_ textView: UITextView) {
isSyncing = true
}
if textView.isWritingToolsActive {
}
Controlling Behavior
textView.writingToolsBehavior = .none
textView.writingToolsBehavior = .limited
textView.writingToolsBehavior = .default
Result Options
textView.writingToolsResultOptions = [.plainText]
textView.writingToolsResultOptions = [.richText]
textView.writingToolsResultOptions = [.richText, .table]
textView.writingToolsResultOptions = [.richText, .list]
Protected Ranges
func textView(
_ textView: UITextView,
writingToolsIgnoredRangesIn enclosingRange: NSRange
) -> [NSRange] {
return codeBlockRanges + quoteRanges
}
WKWebView: <blockquote> and <pre> tags automatically ignored.
Writing Tools Coordinator (iOS 26+)
Advanced integration for custom text engines.
Setup
let coordinator = UIWritingToolsCoordinator()
coordinator.delegate = self
textView.addInteraction(coordinator)
coordinator.writingToolsBehavior = .default
coordinator.writingToolsResultOptions = [.richText]
let coordinator = NSWritingToolsCoordinator()
coordinator.delegate = self
customView.writingToolsCoordinator = coordinator
Coordinator Delegate
Provide context:
func writingToolsCoordinator(
_ coordinator: NSWritingToolsCoordinator,
requestContexts scope: NSWritingToolsCoordinator.ContextScope
) async -> [NSWritingToolsCoordinator.Context] {
let context = NSWritingToolsCoordinator.Context(
attributedString: currentText,
range: currentSelection
)
return [context]
}
Apply changes:
func writingToolsCoordinator(
_ coordinator: NSWritingToolsCoordinator,
replace context: NSWritingToolsCoordinator.Context,
range: NSRange,
with attributedString: NSAttributedString
) async {
textStorage.replaceCharacters(in: range, with: attributedString)
}
Update selection:
func writingToolsCoordinator(
_ coordinator: NSWritingToolsCoordinator,
updateSelectedRange selectedRange: NSRange,
in context: NSWritingToolsCoordinator.Context
) async {
self.selectedRange = selectedRange
}
Provide previews for animation:
func writingToolsCoordinator(
_ coordinator: NSWritingToolsCoordinator,
previewsFor context: NSWritingToolsCoordinator.Context,
range: NSRange
) async -> [NSTextPreview] {
return textLines.map { line in
NSTextPreview(
image: renderImage(for: line),
frame: line.frame
)
}
}
func writingToolsCoordinator(
_ coordinator: UIWritingToolsCoordinator,
previewFor context: UIWritingToolsCoordinator.Context,
range: NSRange
) async -> UITargetedPreview {
return UITargetedPreview(
view: previewView,
parameters: parameters
)
}
Proofreading marks:
func writingToolsCoordinator(
_ coordinator: NSWritingToolsCoordinator,
underlinesFor context: NSWritingToolsCoordinator.Context,
range: NSRange
) async -> [NSValue] {
return ranges.map { range in
let path = bezierPath(for: range)
return NSValue(bytes: &path, objCType: "CGPath")
}
}
PresentationIntent (iOS 26+)
Semantic rich text result option:
coordinator.writingToolsResultOptions = [.richText, .presentationIntent]
Difference from display attributes:
Display attributes (bold, italic):
- Concrete font info (point sizes, font names)
- No semantic meaning
PresentationIntent (header, code block, emphasis):
- Semantic style info
- App converts to internal styles
- Lists, tables, code blocks use presentation intent
- Underline, subscript, superscript still use display attributes
Example:
if attributedString.runs[\.presentationIntent].contains(where: { $0?.components.contains(.header(level: 1)) == true }) {
}
SwiftUI TextEditor + AttributedString (iOS 26+)
Basic Usage
struct RecipeEditor: View {
@State private var text: AttributedString = "Recipe text"
var body: some View {
TextEditor(text: $text)
}
}
Supported attributes:
- Bold, italic, underline, strikethrough
- Custom fonts, point size
- Foreground and background colors
- Kerning, tracking, baseline offset
- Genmoji
- Line height, text alignment, base writing direction
TextAlignment and WritingDirection
var text = AttributedString("Centered paragraph")
text.alignment = .center
var bidiText = AttributedString("Hello عربي")
bidiText.writingDirection = .rightToLeft
LineHeight Control
var multiline = AttributedString("Paragraph\nwith multiple\nlines.")
multiline.lineHeight = .exact(points: 32)
multiline.lineHeight = .multiple(factor: 2.5)
multiline.lineHeight = .loose
Selection Binding
@State private var selection: AttributedTextSelection?
TextEditor(text: $text, selection: $selection)
AttributedTextSelection:
enum AttributedTextSelection {
case none
case single(NSRange)
case multiple(Set<NSRange>)
}
Get selected text:
if let selection {
let selectedText: AttributedSubstring
switch selection.indices {
case .none:
selectedText = text[...]
case .single(let range):
selectedText = text[range]
case .multiple(let ranges):
selectedText = text[selection]
}
}
Programmatic Selection Replacement
var text = AttributedString("Here is my dog")
var selection = AttributedTextSelection(range: text.range(of: "dog")!)
text.replaceSelection(&selection, withCharacters: "cat")
let replacement = AttributedString("horse")
text.replaceSelection(&selection, with: replacement)
DiscontiguousAttributedSubstring
Work with non-contiguous selections using RangeSet:
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]
substring.backgroundColor = .yellow
let combined = AttributedString(substring)
Text Selection Affinity
Control selection affinity for the view hierarchy:
TextEditor(text: $text, selection: $selection)
.textSelectionAffinity(.upstream)
Use .upstream when selection should resolve toward the beginning of text at line boundaries.
Custom Formatting Definition
Constrain which attributes are editable:
struct RecipeFormattingDefinition: AttributedTextFormattingDefinition {
typealias FormatScope = RecipeAttributeScope
static let constraints: [any AttributedTextValueConstraint<RecipeFormattingDefinition>] = [
IngredientsAreGreen()
]
}
struct RecipeAttributeScope: AttributedScope {
var ingredient: IngredientAttribute
var foregroundColor: ForegroundColorAttribute
var genmoji: GenmojiAttribute
}
Apply to TextEditor:
TextEditor(text: $text)
.attributedTextFormattingDefinition(RecipeFormattingDefinition.self)
Value Constraints
Control attribute values based on custom logic:
struct IngredientsAreGreen: AttributedTextValueConstraint {
typealias Definition = RecipeFormattingDefinition
typealias AttributeKey = ForegroundColorAttribute
func constrain(
_ value: inout Color?,
in scope: RecipeFormattingDefinition.FormatScope
) {
if scope.ingredient != nil {
value = .green
} else {
value = nil
}
}
}
System behavior:
- TextEditor probes constraints to determine if changes are valid
- If constraint would revert change, control is disabled
- Constraints applied to pasted content
Custom Attributes
Define attribute:
struct IngredientAttribute: CodableAttributedStringKey {
typealias Value = UUID
static let name = "ingredient"
}
extension AttributeScopes.RecipeAttributeScope {
var ingredient: IngredientAttribute.Type { IngredientAttribute.self }
}
Attribute behavior:
extension IngredientAttribute {
static let inheritedByAddedText = false
static let invalidationConditions: [AttributedString.InvalidationCondition] = [
.textChanged
]
static let runBoundaries: AttributedString.RunBoundaries = .paragraph
}
AttributedString Mutations
Safe index updates:
text.transform(updating: &selection) { mutableText in
let ranges = mutableText.characters.ranges(of: "butter")
for range in ranges {
mutableText[range].ingredient = ingredientID
}
}
Don't use old indices:
let range = text.characters.range(of: "butter")!
text[range].foregroundColor = .green
text.append(" (unsalted)")
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