axiom-textkit-ref
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseTextKit 2 Reference
TextKit 2参考指南
Complete reference for TextKit 2 covering architecture, migration from TextKit 1, Writing Tools integration, and SwiftUI TextEditor with AttributedString through iOS 26.
本指南是TextKit 2的完整参考内容,涵盖架构、从TextKit 1的迁移、写作工具集成,以及iOS 26中结合AttributedString使用SwiftUI TextEditor的相关内容。
Architecture
架构
TextKit 2 uses MVC pattern with new classes optimized for correctness, safety, and performance.
TextKit 2采用MVC模式,搭配经过优化的新类,确保正确性、安全性和性能。
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
NSTextContentManager(抽象类)
- 从底层存储生成NSTextElement对象
- 跟踪文档内的元素范围
- 默认实现:NSTextContentStorage
NSTextContentStorage
- 使用NSTextStorage作为底层存储
- 自动将内容划分为NSTextParagraph元素
- 文本变更时自动生成更新后的元素
NSTextElement(抽象类)
- 代表内容的一部分(段落、附件、自定义类型)
- 具有不可变值语义
- 创建后属性无法修改
- 默认实现:NSTextParagraph
NSTextParagraph
- 代表单个段落
- 包含在文档中的范围
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:
- — array of NSTextLineFragment
textLineFragments - — layout bounds within container
layoutFragmentFrame - — actual drawing bounds (can exceed frame)
renderingSurfaceBounds
NSTextLineFragment
- Measurement info for single line of text
- Used for line counting and geometric queries
NSTextLayoutManager
- 替代TextKit 1中的NSLayoutManager
- 无字形API(完全抽象掉字形相关操作)
- 接收元素,在容器中进行布局并生成布局片段
- 始终使用非连续布局
NSTextLayoutFragment
- 一个或多个元素的不可变布局信息
- 关键属性:
- — NSTextLineFragment数组
textLineFragments - — 容器内的布局边界
layoutFragmentFrame - — 实际绘制边界(可超出布局帧)
renderingSurfaceBounds
NSTextLineFragment
- 单行文本的测量信息
- 用于行数统计和几何查询
View Layer
视图层
NSTextViewportLayoutController
- Source of truth for viewport layout
- Coordinates visible-only layout
- Calls delegate methods: ,
willLayout,configureRenderingSurfacedidLayout
NSTextContainer
- Provides geometric information for layout destination
- Can define exclusion paths (non-rectangular layout)
NSTextViewportLayoutController
- 视口布局的可信来源
- 协调仅可见区域的布局
- 调用代理方法:、
willLayout、configureRenderingSurfacedidLayout
NSTextContainer
- 为布局目标提供几何信息
- 可定义排除路径(非矩形布局)
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
NSTextLocation(协议)
- 代表文本中的单个位置
- 替代整数索引
- 支持结构化文档(如带有嵌套元素的DOM)
NSTextRange
- 包含起始和结束位置(结束位置不包含在内)
- 可表示嵌套结构
- 对于非线性文档,与NSRange不兼容
NSTextSelection
- 包含:粒度、关联性、可能的不连续范围
- 只读属性
- 不可变值语义
NSTextSelectionNavigation
- 对选区执行操作
- 返回新的NSTextSelection实例
- 正确处理双向文本
Core Design Principles
核心设计原则
1. Correctness — No Glyph APIs
1. 正确性 — 无字形API
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.
来自WWDC 2021:
"TextKit 2抽象掉字形处理,为多语言文本提供一致的体验。"
为什么去掉字形?
问题: 在卡纳达语和阿拉伯语等文字系统中:
- 一个字形可代表多个字符(连字)
- 一个字符可拆分为多个字形
- 字形在字形化过程中会重新排序
- 字符与字形之间没有正确的映射关系
示例(卡纳达语单词"October"):
- 第4个字符拆分为2个字形
- 在应用连字之前,字形会重新排序
- 第3个字形变为连接形式并移动到另一个字形下方
解决方案: 使用NSTextLocation、NSTextRange、NSTextSelection替代字形索引。
2. Safety — Value Semantics
2. 安全性 — 值语义
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.
不可变对象:
- NSTextElement
- NSTextLayoutFragment
- NSTextLineFragment
- NSTextSelection
优势:
- 无意外共享
- 突变不会产生副作用
- 更容易推理状态变化
模式:
要更改布局或选区,创建带有所需变更的新实例。
3. Performance — Viewport Layout
3. 性能 — 视口布局
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:
- — setup before layout
textViewportLayoutControllerWillLayout(_:) - — per fragment
textViewportLayoutController(_:configureRenderingSurfaceFor:) - — cleanup after layout
textViewportLayoutControllerDidLayout(_:)
始终非连续:
TextKit 2仅对可见内容+滚动过度区域执行布局。
TextKit 1:
- 可选非连续布局(布尔属性)
- 无法查看布局状态
- 无法控制哪些部分会被布局
TextKit 2:
- 始终使用非连续布局
- 视口定义可见区域
- 视口的布局信息保持一致
- 视口布局更新时发送通知
视口代理方法:
- — 布局前的准备
textViewportLayoutControllerWillLayout(_:) - — 针对每个片段的配置
textViewportLayoutController(_:configureRenderingSurfaceFor:) - — 布局后的清理
textViewportLayoutControllerDidLayout(_:)
Migration from TextKit 1
从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 |
| TextKit 1 | TextKit 2 |
|---|---|
| 字形 | 元素 |
| NSRange | NSTextLocation/NSTextRange |
| NSLayoutManager | NSTextLayoutManager |
| 字形API | 无字形API |
| 可选非连续布局 | 始终非连续布局 |
| 直接使用NSTextStorage | 通过NSTextContentManager使用 |
API Naming Heuristics
API命名规律
From WWDC 2022:
- in name → TextKit 1
.offset - in name → TextKit 2
.location
来自WWDC 2022:
- 名称中包含→ TextKit 1
.offset - 名称中包含→ TextKit 2
.location
NSRange ↔ NSTextRange Conversion
NSRange ↔ NSTextRange 转换
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)NSRange → NSTextRange:
swift
// UITextView/NSTextView
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:
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
字形API替代方案
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
}没有直接对应的字形API,必须使用更高层级的结构。
示例(TextKit 1 - 统计行数):
swift
// TextKit 1 - 遍历字形
var lineCount = 0
let glyphRange = layoutManager.glyphRange(for: textContainer)
for glyphIndex in glyphRange.location..<NSMaxRange(glyphRange) {
let lineRect = layoutManager.lineFragmentRect(
forGlyphAt: glyphIndex,
effectiveRange: nil
)
// 统计唯一的矩形...
}替代方案(TextKit 2 - 枚举片段):
swift
// TextKit 2 - 枚举布局片段
var lineCount = 0
textLayoutManager.enumerateTextLayoutFragments(
from: textLayoutManager.documentRange.location,
options: [.ensuresLayout]
) { fragment in
lineCount += fragment.textLineFragments.count
return true
}Compatibility Mode (UITextView/NSTextView)
兼容模式(UITextView/NSTextView)
Automatic Fallback to TextKit 1:
Happens when you access property.
.layoutManagerWarning (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 first (TextKit 2)
.textLayoutManager - Only access in else clause
.layoutManager - 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
自动回退到TextKit 1:
当你访问属性时会触发此行为。
.layoutManager警告(WWDC 2022):
"访问textView.layoutManager会触发TextKit 1回退"
回退发生后:
- 无法自动切换回TextKit 2
- 切换成本高昂
- 丢失UI状态(选区、滚动位置)
- 单向操作
防止回退:
- 优先检查(TextKit 2)
.textLayoutManager - 仅在else分支中访问
.layoutManager - 如果需要TextKit 1,在初始化时就选择退出TextKit 2
swift
// 优先检查TextKit 2
if let textLayoutManager = textView.textLayoutManager {
// TextKit 2代码
} else if let layoutManager = textView.layoutManager {
// TextKit 1回退(适配旧系统版本)
}调试回退:
- UIKit: 在设置断点
_UITextViewEnablingCompatibilityMode - AppKit: 订阅通知
willSwitchToNSLayoutManagerNotification
NSTextView Opt-In (macOS)
NSTextView 主动启用(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 availableNew Convenience Constructor:
swift
// iOS 16+ / macOS 13+
let textView = UITextView(usingTextLayoutManager: true)
let nsTextView = NSTextView(usingTextLayoutManager: true)创建TextKit 2 NSTextView:
swift
let textLayoutManager = NSTextLayoutManager()
let textContainer = NSTextContainer()
textLayoutManager.textContainer = textContainer
let textView = NSTextView(frame: .zero, textContainer: textContainer)
// 此时textView.textLayoutManager可用新的便捷构造方法:
swift
// iOS 16+ / macOS 13+
let textView = UITextView(usingTextLayoutManager: true)
let nsTextView = NSTextView(usingTextLayoutManager: true)Delegate Hooks
代理钩子
NSTextContentStorageDelegate
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
}无需修改存储即可自定义属性:
swift
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)
}过滤元素(显示/隐藏内容):
swift
func textContentManager(
_ textContentManager: NSTextContentManager,
shouldEnumerate textElement: NSTextElement,
options: NSTextContentManager.EnumerationOptions
) -> Bool {
// 返回false以隐藏元素
if hideComments && isComment(textElement) {
return false
}
return true
}NSTextLayoutManagerDelegate
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
)
}提供自定义布局片段:
swift
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
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
}视口布局生命周期:
swift
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)
自定义布局片段(气泡背景)
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)
}
}swift
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:
swift
textLayoutManager.addRenderingAttribute(
.foregroundColor,
value: UIColor.green,
for: ingredientRange
)
// Remove when no longer needed
textLayoutManager.removeRenderingAttribute(
.foregroundColor,
for: ingredientRange
)添加不修改文本存储的属性:
swift
textLayoutManager.addRenderingAttribute(
.foregroundColor,
value: UIColor.green,
for: ingredientRange
)
// 不再需要时移除
textLayoutManager.removeRenderingAttribute(
.foregroundColor,
for: ingredientRange
)Text Attachment with UIView
结合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
}
}swift
// iOS 15+
let attachment = NSTextAttachment()
attachment.image = UIImage(systemName: "star.fill")
// 提供用于交互的视图
class AttachmentViewProvider: NSTextAttachmentViewProvider {
override func loadView() {
super.loadView()
let button = UIButton(type: .system)
button.setTitle("点击我", for: .normal)
button.addTarget(self, action: #selector(didTap), for: .touchUpInside)
view = button
}
@objc func didTap() {
// 处理点击事件
}
}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.
swift
// 创建列表
let listItem = NSTextList(markerFormat: .disc, options: 0)
let paragraphStyle = NSMutableParagraphStyle()
paragraphStyle.textLists = [listItem]
attributedString.addAttribute(
.paragraphStyle,
value: paragraphStyle,
range: range
)NSTextList现在在UIKit(iOS 16+)中可用,此前仅支持AppKit。
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
}swift
// 获取点击位置对应的文本范围
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+)
写作工具(iOS 18+)
Basic Integration (TextKit 2 Required)
基础集成(需TextKit 2)
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来自WWDC 2024:
"UITextView或NSTextView必须使用TextKit 2才能支持完整的写作工具体验。如果使用TextKit 1,只能获得有限的体验,仅在面板中显示改写结果。"
原生文本视图默认支持:
swift
// UITextView, NSTextView, WKWebView
// 写作工具会自动显示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
}swift
func textViewWritingToolsWillBegin(_ textView: UITextView) {
// 暂停同步,防止编辑
isSyncing = false
}
func textViewWritingToolsDidEnd(_ textView: UITextView) {
// 恢复同步
isSyncing = true
}
// 检查是否处于激活状态
if textView.isWritingToolsActive {
// 不要持久化文本存储
}Controlling Behavior
控制行为
swift
// Opt out completely
textView.writingToolsBehavior = .none
// Panel-only experience (no in-line edits)
textView.writingToolsBehavior = .limited
// Full experience (default)
textView.writingToolsBehavior = .defaultswift
// 完全禁用
textView.writingToolsBehavior = .none
// 仅面板体验(无行内编辑)
textView.writingToolsBehavior = .limited
// 完整体验(默认)
textView.writingToolsBehavior = .defaultResult 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]swift
// 仅纯文本
textView.writingToolsResultOptions = [.plainText]
// 富文本
textView.writingToolsResultOptions = [.richText]
// 富文本+表格
textView.writingToolsResultOptions = [.richText, .table]
// 富文本+列表
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: and tags automatically ignored.
<blockquote><pre>swift
// UITextViewDelegate / NSTextViewDelegate
func textView(
_ textView: UITextView,
writingToolsIgnoredRangesIn enclosingRange: NSRange
) -> [NSRange] {
// 返回写作工具不应修改的范围
return codeBlockRanges + quoteRanges
}WKWebView: 和标签会被自动忽略。
<blockquote><pre>Writing Tools Coordinator (iOS 26+)
写作工具协调器(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 = coordinatorswift
// 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 = coordinatorCoordinator 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")
}
}提供上下文:
swift
func writingToolsCoordinator(
_ coordinator: NSWritingToolsCoordinator,
requestContexts scope: NSWritingToolsCoordinator.ContextScope
) async -> [NSWritingToolsCoordinator.Context] {
// 返回带属性字符串+选区范围
let context = NSWritingToolsCoordinator.Context(
attributedString: currentText,
range: currentSelection
)
return [context]
}应用变更:
swift
func writingToolsCoordinator(
_ coordinator: NSWritingToolsCoordinator,
replace context: NSWritingToolsCoordinator.Context,
range: NSRange,
with attributedString: NSAttributedString
) async {
// 更新文本存储
textStorage.replaceCharacters(in: range, with: attributedString)
}更新选区:
swift
func writingToolsCoordinator(
_ coordinator: NSWritingToolsCoordinator,
updateSelectedRange selectedRange: NSRange,
in context: NSWritingToolsCoordinator.Context
) async {
// 更新选区
self.selectedRange = selectedRange
}提供动画预览:
swift
// macOS
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
)
}
}
// iOS
func writingToolsCoordinator(
_ coordinator: UIWritingToolsCoordinator,
previewFor context: UIWritingToolsCoordinator.Context,
range: NSRange
) async -> UITargetedPreview {
// 返回单个预览
return UITargetedPreview(
view: previewView,
parameters: parameters
)
}校对标记:
swift
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+)
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
}语义化富文本结果选项:
swift
coordinator.writingToolsResultOptions = [.richText, .presentationIntent]与显示属性的区别:
显示属性(粗体、斜体):
- 具体的字体信息(字号、字体名称)
- 无语义含义
PresentationIntent(标题、代码块、强调):
- 语义化样式信息
- 应用会将其转换为内部样式
- 列表、表格、代码块使用presentation intent
- 下划线、下标、上标仍使用显示属性
示例:
swift
// 检查presentation intent
if attributedString.runs[\.presentationIntent].contains(where: { $0?.components.contains(.header(level: 1)) == true }) {
// 这是一个标题
}SwiftUI TextEditor + AttributedString (iOS 26+)
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
swift
struct RecipeEditor: View {
@State private var text: AttributedString = "食谱文本"
var body: some View {
TextEditor(text: $text)
}
}支持的属性:
- 粗体、斜体、下划线、删除线
- 自定义字体、字号
- 前景色和背景色
- 字距、字符间距、基线偏移
- Genmoji
- 行高、文本对齐、基础书写方向
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]
}
}swift
@State private var selection: AttributedTextSelection?
TextEditor(text: $text, selection: $selection)AttributedTextSelection:
swift
enum AttributedTextSelection {
case none
case single(NSRange)
case multiple(Set<NSRange>) // 用于双向文本
}获取选中文本:
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):
// 从RangeSet获取不连续子串
selectedText = text[selection]
}
}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)限制可编辑的属性:
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
}应用到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
基于自定义逻辑控制属性值:
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 // 食材始终为绿色
} else {
value = nil // 其他使用默认值
}
}
}系统行为:
- TextEditor会检查约束以确定变更是否有效
- 如果约束会回退变更,对应的控件会被禁用
- 约束会应用到粘贴的内容
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
}定义属性:
swift
struct IngredientAttribute: CodableAttributedStringKey {
typealias Value = UUID // 食材ID
static let name = "ingredient"
}
extension AttributeScopes.RecipeAttributeScope {
var ingredient: IngredientAttribute.Type { IngredientAttribute.self }
}属性行为:
swift
extension IngredientAttribute {
// 在食材后输入文本时不扩展属性
static let inheritedByAddedText = false
// 如果run中的文本变更则移除属性
static let invalidationConditions: [AttributedString.InvalidationCondition] = [
.textChanged
]
// 可选:限制在段落边界内
static let runBoundaries: AttributedString.RunBoundaries = .paragraph
}AttributedString Mutations
AttributedString 变更
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 textDon'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!安全的索引更新:
swift
// 在变更期间转换更新索引/选区
text.transform(updating: &selection) { mutableText in
// 查找范围
let ranges = mutableText.characters.ranges(of: "butter")
// 一次性为所有范围设置属性
for range in ranges {
mutableText[range].ingredient = ingredientID
}
}
// 此时selection已更新为匹配转换后的文本不要使用旧索引:
swift
// 错误示例 - 变更会使索引失效
let range = text.characters.range(of: "butter")!
text[range].foregroundColor = .green
text.append(" (unsalted)") // 此时range已失效!AttributedString Views
AttributedString 视图
Multiple views into same content:
- — grapheme clusters
characters - — Unicode scalars
unicodeScalars - — UTF-8 code units
utf8 - — UTF-16 code units
utf16
All views share same indices.
同一内容的多个视图:
- — 字形簇
characters - — Unicode标量
unicodeScalars - — UTF-8代码单元
utf8 - — UTF-16代码单元
utf16
所有视图共享相同的索引。
Known Limitations & Gotchas
已知限制与注意事项
Viewport Scroll Issues
视口滚动问题
From expert articles:
- Viewport can cause scroll position instability
- changes during scroll
usageBoundsForTextContainer - Apple's TextEdit exhibits same issues
- Trade-off for performance benefits
来自专家文章:
- 视口可能导致滚动位置不稳定
- 在滚动期间会变化
usageBoundsForTextContainer - Apple的TextEdit也存在相同问题
- 这是为了性能做出的权衡
TextKit 1 Compatibility
TextKit 1兼容性
- Accessing triggers fallback
.layoutManager - One-way operation (no automatic return)
- Loses UI state during switch
- Expensive to switch layout systems
- 访问会触发回退
.layoutManager - 单向操作(无法自动返回TextKit 2)
- 切换时丢失UI状态
- 布局系统切换成本高昂
AttributedString Index Invalidation
AttributedString索引失效
- Any mutation invalidates all indices
- Must use to keep indices valid
.transform(updating:) - Indices only work with originating AttributedString
- 任何变更都会使所有索引失效
- 必须使用保持索引有效
.transform(updating:) - 索引仅对生成它的AttributedString有效
Limited TextKit 1 Support
有限的TextKit 1支持
Unsupported in TextKit 2:
- NSTextTable (use NSTextList or custom layouts)
- Some legacy text attachments
- Direct glyph manipulation
TextKit 2不支持以下内容:
- NSTextTable(使用NSTextList或自定义布局替代)
- 部分旧版文本附件
- 直接操作字形
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, /uikit/writing-tools, /appkit/enhancing-your-custom-text-engine-with-writing-tools
WWDC:2021-10061, 2022-10090, 2023-10058, 2024-10168, 2025-265, 2025-280
文档:/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, /uikit/writing-tools, /appkit/enhancing-your-custom-text-engine-with-writing-tools