使用 TextKit 提升 App 的文本体验
Elevate your app’s text experience with TextKit
2026年6月10日
一句话判断
✅ 终于不用为了画个行号或折叠代码块,就去手写一个连光标和撤销栈都残缺的文本编辑器了。
这场 Session 讲了什么
以前做富文本或代码编辑器,大家面临一个二选一的死局:用 UITextView 或 NSTextView 能白嫖系统级的输入、选区、听写和 Undo/Redo,但想定制渲染(比如加个行号、搞个代码块背景)极其痛苦;用 TextKit 2 从头撸自定义视图,控制力拉满,但你要自己实现所有编辑交互,分分钟被各种边缘 Case 教做人。
Apple 这次把视口布局控制器代理(NSTextViewportLayoutControllerDelegate)直接开放给了原生文本视图。这意味着你可以子类化 UITextView,拦截视口布局过程,往里面塞自定义的渲染表面(NSTextViewportRenderingSurface)。你既能保留框架自带的所有编辑能力,又能精准控制每一行文本在屏幕上的绘制方式。
这对做笔记软件、代码编辑器、Markdown 编辑器的团队是巨大利好。SwiftUI 的 TextEditor 底层也是这俩原生控件,套个 UIViewRepresentable 就能在 SwiftUI 里无缝用上这些高级定制,彻底打通了便利性与控制权的任督二脉。
值得深挖的点
渲染表面抽象与坐标系陷阱
Apple 引入了 NSTextViewportRenderingSurface 协议和配套的 Key,把“布局数据”和“实际渲染的 View/Layer”解耦了。以前 TextKit 只管生成不可变的布局片段(NSTextLayoutFragment),不管画在哪。现在你可以在 configureRenderingSurface 里拿到每个片段的 Frame,并把它映射到你自己写的行号 View 或背景 Layer 上。
这里的设计取舍很明显:Apple 不想让 TextKit 直接管理你的自定义 View 生命周期,而是给你一个回调,让你自己用 NSMapTable 或数组去缓存和复用。最大的坑在坐标系。TextKit 2 的 Layout Fragment 坐标系是相对于整个文档的,而你的自定义 View 是画在视口(Viewport)里的。在 didLayout 回调里,你必须用 viewportBounds.origin.y 去减掉 Fragment 的 Y 轴偏移,否则只要一滚动,你的行号或背景就会飞到屏幕外面去。
文本折叠与文档完整性
实现代码块折叠或大纲收起,以前的野路子是直接修改 NSAttributedString,把折叠的文本删掉。这会导致光标乱跳、Undo/Redo 栈崩溃,甚至触发奇怪的布局重算。
这次 Apple 给出的正解是实现 NSTextContentStorageDelegate 的 textContentManager(_:shouldEnumerate:) 方法。当 TextKit 准备布局某个段落时,你直接返回 false 跳过它。文本数据依然完好无损地躺在内存里,只是不参与布局和渲染。这种设计保住了文档的完整性,选区和撤销操作完全不受影响。你需要自己维护一个折叠状态的 Set,并在用户点击折叠按钮时,手动触发一次视口布局刷新。
代码片段
1. 拦截视口布局绘制行号
场景:子类化 UITextView,在布局完成后把行号和对应的 Y 轴坐标传给外部的容器 View 进行绘制。
class CodeEditorTextView: UITextView {
private var lineFrames: [CGRect] = []
var onDidLayout: ((Int, [CGRect]) -> Void)?
// 布局开始前清空缓存
override func textViewportLayoutControllerWillLayout(
_ controller: NSTextViewportLayoutController
) {
super.textViewportLayoutControllerWillLayout(controller)
lineFrames.removeAll()
}
// 捕获每个布局片段的 Frame
override func textViewportLayoutController(
_ controller: NSTextViewportLayoutController,
configureRenderingSurfaceFor textLayoutFragment: NSTextLayoutFragment
) {
super.textViewportLayoutController(controller, configureRenderingSurfaceFor: textLayoutFragment)
lineFrames.append(textLayoutFragment.layoutFragmentFrame)
}
// 布局完成后,转换坐标系并抛出回调
override func textViewportLayoutControllerDidLayout(
_ controller: NSTextViewportLayoutController
) {
super.textViewportLayoutControllerDidLayout(controller)
let originY = controller.viewportBounds.origin.y
// 必须减去视口原点 Y 值,转换为屏幕相对坐标
let adjustedFrames = lineFrames.map { $0.offsetBy(dx: 0, dy: -originY) }
let startLine = calculateStartingLineNumber() // 需自行实现行号计算逻辑
onDidLayout?(startLine, adjustedFrames)
}
}
坑:忘记减去 viewportBounds.origin.y,导致滚动时行号位置完全错乱。
2. 实现段落折叠(跳过布局)
场景:通过代理方法在布局阶段隐藏特定段落,而不修改底层文本数据。
class FoldableTextStorageDelegate: NSObject, NSTextContentStorageDelegate {
// 记录当前被折叠的段落起始位置
var collapsedLocations: Set<NSTextLocation> = []
func textContentManager(
_ textContentManager: NSTextContentManager,
shouldEnumerate textElement: NSTextElement,
in range: NSTextRange
) -> Bool {
// 如果是被折叠的段落,直接告诉 TextKit 跳过它的布局
if let location = textElement.elementRange?.location,
collapsedLocations.contains(location) {
return false
}
return true
}
}
坑:折叠状态改变后,必须手动调用 textLayoutManager?.invalidateLayout(for:) 或触发视口重绘,否则 UI 不会立刻更新。
3. 在 SwiftUI 中桥接自定义文本视图
场景:把带有高级定制能力的 UITextView 子类无缝接入 SwiftUI 视图树。
struct CodeEditorRepresentable: UIViewRepresentable {
@Binding var text: String
func makeUIView(context: Context) -> CodeEditorTextView {
let textView = CodeEditorTextView()
textView.font = .monospacedSystemFont(ofSize: 14, weight: .regular)
// 绑定行号绘制回调
textView.onDidLayout = { startLine, frames in
context.coordinator.updateLineNumbers(startLine: startLine, frames: frames)
}
return textView
}
func updateUIView(_ uiView: CodeEditorTextView, context: Context) {
if uiView.text != text {
uiView.text = text
}
}
func makeCoordinator() -> Coordinator { Coordinator() }
class Coordinator {
func updateLineNumbers(startLine: Int, frames: [CGRect]) {
// 更新 SwiftUI 侧的行号 View 状态
}
}
}
坑:在 updateUIView 中直接赋值 text 会重置光标位置,生产环境需要对比 NSTextRange 做增量更新。
最佳实践
- 已有项目的迁移策略:如果你之前为了定制渲染,硬着头皮用
NSTextLayoutManager手写了自定义文本视图,强烈建议评估切回UITextView子类。把自定义渲染逻辑平移到NSTextViewportLayoutControllerDelegate里,你能瞬间获得系统级的听写、内联预测和完美的无障碍支持,省下几个月的修 Bug 时间。 - 新项目的采用建议:无脑使用
UITextView/NSTextView配合UIViewRepresentable。把文本存储、布局交给系统,你只专注在configureRenderingSurface里画你的 UI 组件(如代码高亮背景、行号、折叠按钮)。 - 实战中容易踩的坑:计算视口起始行号时,官方示例用的是
enumerateTextElements从文档头遍历到视口顶部。对于几万行的长文档,每次滚动都从头遍历会卡死。务必自己加一个基于NSTextLocation的缓存或二分查找机制来优化行号计算。
还有什么值得关注
- Text Attachment 视图重用:新增了
register(_:forTextAttachmentViewProviderType:)API,允许在文本附件滚出视口或编辑时缓存 View Provider,彻底解决了长文档中插入大量图片/自定义组件时的内存飙升问题。 - SwiftUI 原生支持:虽然 Session 主讲 UIKit/AppKit,但明确提到 SwiftUI 的
TextEditor底层已完全对齐这套架构,意味着未来 SwiftUI 原生文本组件的扩展性将大幅提升。 - 渲染表面 Key 标识:
NSTextViewportRenderingSurfaceKey的引入让跨布局周期追踪特定 View 成为可能,做复杂的文本内嵌交互组件(如内嵌一个可点击的 Tag)时状态管理会清晰很多。