Elevate your app’s text experience with TextKit
SwiftUI & UI Frameworks 高级 23m

使用 TextKit 提升 App 的文本体验

Elevate your app’s text experience with TextKit

2026年6月10日

在 Apple 官方观看视频

一句话判断

✅ 终于不用为了画个行号或折叠代码块,就去手写一个连光标和撤销栈都残缺的文本编辑器了。

这场 Session 讲了什么

以前做富文本或代码编辑器,大家面临一个二选一的死局:用 UITextViewNSTextView 能白嫖系统级的输入、选区、听写和 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 给出的正解是实现 NSTextContentStorageDelegatetextContentManager(_: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)时状态管理会清晰很多。
TextKit UITextView NSTextViewportLayoutController TextEditor NSTextLayoutFragment