关于 axiom-textkit-ref
面向 Swift 开发者的 TextKit 2 参考技能,涵盖架构、从 TextKit 1 迁移、Writing Tools 集成及 SwiftUI TextEditor。也说明视口布局、选择范围和自定义布局片段等实践。
主要功能
- 01TextKit 2 架构与核心类型
- 02TextKit 1 迁移指南
- 03视口布局与文本范围
- 04Writing Tools 集成
- 05自定义布局和渲染
适用场景
- 01将文本视图迁移到 TextKit 2
- 02处理可见区域内的文本布局
- 03集成 Writing Tools 编辑体验
- 04实现自定义文本背景与附件
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ charleswiltgen/ axiom --skill axiom-textkit-ref安装后在 Claude Code 中输入/axiom-textkit-ref确认已加载。
详细介绍
TextKit 2 参考
TextKit 2 的完整参考,涵盖架构、从 TextKit 1 迁移、Writing Tools 集成,以及通过 iOS 26 使用 AttributedString 的 SwiftUI TextEditor。
架构
TextKit 2 使用 MVC 模式,并采用针对正确性、安全性和性能优化的新类。
模型层
NSTextContentManager(抽象类)
- 从底层存储生成 NSTextElement 对象
- 跟踪文档中元素的范围
- 默认实现:NSTextContentStorage
NSTextContentStorage
- 使用 NSTextStorage 作为底层存储
- 自动将内容划分为 NSTextParagraph 元素
- 文本更改时生成更新后的元素
NSTextElement(抽象类)
- 表示部分内容(段落、附件、自定义类型)
- 具有不可变的值语义
- 创建后属性无法更改
- 默认实现:NSTextParagraph
NSTextParagraph
- 表示单个段落
- 包含其在文档中的范围
控制器层
NSTextLayoutManager
- 替代 TextKit 1 的 NSLayoutManager
- 没有字形 API(完全抽象掉字形)
- 接收元素,将其排入容器,并生成布局片段
- 始终使用非连续布局
NSTextLayoutFragment
- 一个或多个元素的不可变布局信息
- 关键属性:
textLineFragments— NSTextLineFragment 数组layoutFragmentFrame— 容器中的布局边界renderingSurfaceBounds— 实际绘制边界(可能超出 frame)
NSTextLineFragment
- 单行文本的测量信息
- 用于行数统计和几何查询
视图层
NSTextViewportLayoutController
- 视口布局的事实来源
- 协调仅针对可见区域的布局
- 调用委托方法:
willLayout、configureRenderingSurface、didLayout
NSTextContainer
- 为布局目标提供几何信息
- 可以定义排除路径(非矩形布局)
基于对象的范围
NSTextLocation(协议)
- 表示文本中的单个位置
- 替代整数索引
- 支持结构化文档(例如包含嵌套元素的 DOM)
NSTextRange
- 起始和结束位置(不包含结束位置)
- 可以表示嵌套结构
- 对于非线性文档,与 NSRange 不兼容
NSTextSelection
- 包含:粒度、亲和性以及可能不连续的范围
- 只读属性
- 具有不可变的值语义
NSTextSelectionNavigation
- 对选区执行操作
- 返回新的 NSTextSelection 实例
- 正确处理双向文本
核心设计原则
1. 正确性 — 无字形 API
摘自 WWDC 2021:
“TextKit 2 抽象掉字形处理,以提供一致的国际文本体验。”
为什么没有字形?
问题: 在卡纳达语和阿拉伯语等文字中:
- 一个字形可能表示多个字符(连字)
- 一个字符可能拆分为多个字形
- 字形会在整形过程中重新排序
- 无法建立正确的字符→字形映射
示例(卡纳达语单词“October”):
- 第 4 个字符拆分为 2 个字形
- 字形在应用连字之前重新排序
- 第 3 个字形变为连写形式,并移到另一个字形下方
解决方案: 使用 NSTextLocation、NSTextRange、NSTextSelection,而不是字形索引。
2. 安全性 — 值语义
不可变对象:
- NSTextElement
- NSTextLayoutFragment
- NSTextLineFragment
- NSTextSelection
优点:
- 不会发生意外共享
- 修改不会产生副作用
- 更容易理解状态
模式: 要更改布局或选区,请创建包含所需更改的新实例。
3. 性能 — 视口布局
始终为非连续布局: TextKit 2 仅对可见内容和过滚区域进行布局。
TextKit 1:
- 可选的非连续布局(布尔属性)
- 无法查看布局状态
- 无法控制要布局的部分
TextKit 2:
- 始终为非连续布局
- 视口定义可见区域
- 提供一致的视口布局信息
- 视口布局更新时发送通知
视口代理方法:
textViewportLayoutControllerWillLayout(_:)— 布局前进行设置textViewportLayoutController(_:configureRenderingSurfaceFor:)— 针对每个片段textViewportLayoutControllerDidLayout(_:)— 布局后进行清理
从 TextKit 1 迁移
核心范式转变
| TextKit 1 | TextKit 2 |
|---|---|
| 字形 | 元素 |
| NSRange | NSTextLocation/NSTextRange |
| NSLayoutManager | NSTextLayoutManager |
| 字形 API | 没有字形 API |
| 可选的非连续布局 | 始终为非连续布局 |
| 直接使用 NSTextStorage | 通过 NSTextContentManager |
API 命名规律
来自 WWDC 2022:
- 名称中包含
.offset→ TextKit 1 - 名称中包含
.location→ TextKit 2
NSRange ↔ NSTextRange 转换
NSRange → NSTextRange:
// 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:
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)
字形 API 的替代方案
没有直接对应的字形 API。 必须使用更高层级的结构。
示例(TextKit 1 — 统计行数):
// 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...
}
替代方案(TextKit 2 — 枚举片段):
// TextKit 2 - enumerate layout fragments
var lineCount = 0
textLayoutManager.enumerateTextLayoutFragments(
from: textLayoutManager.documentRange.location,
options: [.ensuresLayout]
) { fragment in
lineCount += fragment.textLineFragments.count
return true
}
兼容模式(UITextView/NSTextView)
自动回退到 TextKit 1:
访问 .layoutManager 属性时会触发。
警告(WWDC 2022):
“访问 textView.layoutManager 会触发 TK1 回退”
回退发生后:
- 无法自动恢复到 TextKit 2
- 切换代价高
- UI 状态会丢失(选区、滚动位置)
- 这是单向操作
防止回退:
- 先检查
.textLayoutManager(TextKit 2) - 仅在 else 分支中访问
.layoutManager - 如果需要使用 TK1,则在初始化时选择退出
// Check TextKit 2 first
if let textLayoutManager = textView.textLayoutManager {
// TextKit 2 code
} else if let layoutManager = textView.layoutManager {
// TextKit 1 fallback (old OS versions)
}
调试回退:
- UIKit: 在
_UITextViewEnablingCompatibilityMode上设置断点 - AppKit: 订阅
willSwitchToNSLayoutManagerNotification
选择使用 NSTextView(macOS)
创建 TextKit 2 NSTextView:
let textLayoutManager = NSTextLayoutManager()
let textContainer = NSTextContainer()
textLayoutManager.textContainer = textContainer
let textView = NSTextView(frame: .zero, textContainer: textContainer)
// textView.textLayoutManager now available
新增便捷构造器:
// iOS 16+ / macOS 13+
let textView = UITextView(usingTextLayoutManager: true)
let nsTextView = NSTextView(usingTextLayoutManager: true)
代理钩子
NSTextContentStorageDelegate
自定义属性而不修改存储内容:
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)
}
筛选元素(隐藏/显示内容):
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
提供自定义布局片段:
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
视口布局生命周期:
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
}
实用模式
自定义布局片段(气泡背景)
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)
}
}
渲染属性(临时样式)
添加不会修改文本存储的属性:
textLayoutManager.addRenderingAttribute(
.foregroundColor,
value: UIColor.green,
for: ingredientRange
)
// Remove when no longer needed
textLayoutManager.removeRenderingAttribute(
.foregroundColor,
for: ingredientRange
)
使用 UIView 的文本附件
// 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
}
}
列表和表格
// Create list
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。
命中测试与选区几何信息
// 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+)
基本集成(需要 TextKit 2)
来自 WWDC 2024:
“UITextView 或 NSTextView 必须使用 TextKit 2,才能完整支持 Writing Tools 体验。如果使用 TextKit 1,体验将受限,只会在面板中显示改写结果。”
原生文本视图可免费使用:
// UITextView, NSTextView, WKWebView
// Writing Tools appears automatically
生命周期代理方法
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
}
控制行为
// Opt out completely
textView.writingToolsBehavior = .none
// Panel-only experience (no in-line edits)
textView.writingToolsBehavior = .limited
// Full experience (default)
textView.writingToolsBehavior = .default
结果选项
// 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]
受保护范围
// UITextViewDelegate / NSTextViewDelegate
func textView(
_ textView: UITextView,
writingToolsIgnoredRangesIn enclosingRange: NSRange
) -> [NSRange] {
// Return ranges that Writing Tools should not modify
return codeBlockRanges + quoteRanges
}
WKWebView: <blockquote> 和 <pre> 标签会自动忽略。
Writing Tools Coordinator(iOS 26+)
面向自定义文本引擎的高级集成。
设置
// 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 代理
提供上下文:
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]
}
应用更改:
func writingToolsCoordinator(
_ coordinator: NSWritingToolsCoordinator,
replace context: NSWritingToolsCoordinator.Context,
range: NSRange,
with attributedString: NSAttributedString
) async {
// Update text storage
textStorage.replaceCharacters(in: range, with: attributedString)
}
更新选区:
func writingToolsCoordinator(
_ coordinator: NSWritingToolsCoordinator,
updateSelectedRange selectedRange: NSRange,
in context: NSWritingToolsCoordinator.Context
) async {
// Update selection
self.selectedRange = selectedRange
}
提供动画预览:
// 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
)
}
校对标记:
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+)
语义化富文本结果选项:
coordinator.writingToolsResultOptions = [.richText, .presentationIntent]
与显示属性的区别:
显示属性(粗体、斜体):
- 具体的字体信息(字号、字体名称)
- 没有语义含义
PresentationIntent(标题、代码块、强调):
- 语义样式信息
- 应用将其转换为内部样式
- 列表、表格、代码块使用 PresentationIntent
- 下划线、下标、上标仍使用显示属性
示例:
// 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+)
基本用法
struct RecipeEditor: View {
@State private var text: AttributedString = "Recipe text"
var body: some View {
TextEditor(text: $text)
}
}
支持的属性:
- 粗体、斜体、下划线、删除线
- 自定义字体、字号
- 前景色和背景色
- 字距、字间距、基线偏移
- Genmoji
- 行高、文本对齐、基本书写方向
TextAlignment 和 WritingDirection
// 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
行高控制
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
选区绑定
@State private var selection: AttributedTextSelection?
TextEditor(text: $text, selection: $selection)
AttributedTextSelection:
enum AttributedTextSelection {
case none
case single(NSRange)
case multiple(Set<NSRange>) // For bidirectional 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):
// Discontiguous substring from RangeSet
selectedText = text[selection]
}
}
以编程方式替换选区
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
使用 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] // DiscontiguousAttributedSubstring
substring.backgroundColor = .yellow
// Convert back to AttributedString
let combined = AttributedString(substring)
文本选区亲和性
控制视图层级的选区亲和性:
TextEditor(text: $text, selection: $selection)
.textSelectionAffinity(.upstream) // .upstream or .downstream
当选区在行边界处应趋向文本开头时,使用 .upstream。
自定义格式定义
限制可编辑的属性:
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:
TextEditor(text: $text)
.attributedTextFormattingDefinition(RecipeFormattingDefinition.self)
值约束
根据自定义逻辑控制属性值:
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
}
}
}
系统行为:
- TextEditor 会探测约束,以确定更改是否有效
- 如果约束会撤销更改,则控件会被禁用
- 约束也会应用于粘贴的内容
自定义属性
定义属性:
struct IngredientAttribute: CodableAttributedStringKey {
typealias Value = UUID // Ingredient ID
static let name = "ingredient"
}
extension AttributeScopes.RecipeAttributeScope {
var ingredient: IngredientAttribute.Type { IngredientAttribute.self }
}
属性行为:
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 变更
安全更新索引:
// 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
不要使用旧索引:
// BAD - indices invalidated by mutation
let range = text.characters.range(of: "butter")!
text[range].foregroundColor = .green
text.append(" (unsalted)") // range is now invalid!
AttributedString 视图
指向同一内容的多个视图:
characters— 字素簇unicodeScalars— Unicode 标量utf8— UTF-8 代码单元utf16— UTF-16 代码单元
所有视图共用相同的索引。
已知限制与注意事项
视口滚动问题
根据专家文章:
- 视口可能导致滚动位置不稳定
- 滚动期间
usageBoundsForTextContainer会变化 - Apple 的 TextEdit 也存在相同问题
- 这是性能优势带来的权衡
TextKit 1 兼容性
- 访问
.layoutManager会触发回退 - 此操作不可逆(不会自动切回)
- 切换时会丢失 UI 状态
- 切换布局系统的成本很高
AttributedString 索引失效
- 任何变更都会使所有索引失效
- 必须使用
.transform(updating:)来保持索引有效 - 索引仅适用于其来源的 AttributedString
TextKit 1 支持有限
TextKit 2 不支持:
- NSTextTable(使用 NSTextList 或自定义布局)
- 某些旧版文本附件
- 直接操作字形
资源
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、/foundation/attributedstring/textalignment、/foundation/attributedstring/lineheight、/foundation/discontiguousattributedsubstring、/uikit/writing-tools、/appkit/enhancing-your-custom-text-engine-with-writing-tools