WWDC Quick Look 💓 By SwiftGGTeam
Elevate your app's text experience with TextKit

Elevate your app's text experience with TextKit

观看原视频

Highlight

UITextView 和 NSTextView 现在开放了 TextKit 的内部钩子,开发者可以在不放弃框架提供的编辑功能(选择、撤销、语音输入等)的前提下,添加行号、折叠区块等自定义行为。

核心内容

在 Apple 平台上做文本编辑,长期以来是个二选一的问题:用 UITextView/NSTextView,还是自己造轮子。

用框架的文本视图,开箱即用。文本输入、选择、无障碍、撤销重做、听写、行内预测——这些都免费送给你。但代价是定制能力有限。你想改变行号的绘制方式?想在文本旁边加自定义视图?很难。

另一种选择是用 TextKit 从零开始构建自定义文本视图。设置 NSTextLayoutManager、自己处理视口布局、自己渲染所有内容。控制权全在你手里,但那些框架送你的功能也都没了——自己实现一个生产级文本编辑器不是小工程。

TextKit 的四层架构解释了这个困境:存储层(文本数据)、布局层(分块)、视口层(追踪可见内容)、视图层(实际渲染)。前三层是跨框架共享的,但视图层是你自己的。

2027 年的更新打破了这道墙。UITextView 和 NSTextView 现在遵循 NSTextViewportLayoutControllerDelegate,你可以子类化它们并覆写视口布局的回调方法。这意味着你可以在保留框架所有功能的同时,注入自己的绘制逻辑。

详细内容

TextKit 架构回顾

TextKit 的渲染流程分为四层(00:17):

  1. 存储层:NSTextContentStorage 将 NSAttributedString 分解为 NSTextParagraph 对象
  2. 布局层:NSTextLayoutManager 测量字形并创建不可变的 NSTextLayoutFragment
  3. 视口层:NSTextViewportLayoutController 追踪可见区域,只渲染用户看得到的内容
  4. 视图层:实际渲染文本的 UIView/NSView 或 CALayer

这个架构的核心思想是”按需渲染”——视口布局过程只处理与当前滚动位置相交的布局片段。

新的 Rendering Surface API

在 2027 年之前,TextKit 没有统一的方式来引用渲染目标视图(09:47)。新增的两个协议解决了这个问题:

class MyView: UIView, NSTextViewportRenderingSurface {}

关键点

  • NSTextViewportRenderingSurface:表示视口内可绘制的视觉元素
  • NSTextViewportRenderingSurfaceKey:唯一标识一个渲染表面的键,NSTextLayoutFragment 可作为键使用

配合使用 NSMapTable 缓存:

var cache: NSMapTable<NSTextLayoutFragment, MyView>

这样你可以在视口布局过程中追踪每个布局片段对应的渲染视图。

在 SwiftUI 中使用 UITextView

SwiftUI 的 TextEditor 适合简单场景,但有时你需要 UITextView 的完整功能(12:39):

import SwiftUI

struct MyTextView: View {
    var body: some View { TextViewRepresentable() }
}

#if os(macOS)
struct TextViewRepresentable: NSViewRepresentable {
    func makeNSView(context: Context) -> NSTextView {
      NSTextView()
    }
    func updateNSView(_ nsView: NSTextView, context: Context) {}
}
#else
struct TextViewRepresentable: UIViewRepresentable {
    func makeUIView(context: Context) -> UITextView {
        UITextView()
    }
    func updateUIView(_ uiView: UITextView, context: Context) {}
}
#endif

关键点

  • macOS 使用 NSViewRepresentable,iOS/macCatalyst 使用 UIViewRepresentable
  • 在 make 方法中初始化文本视图
  • update 方法保持视图同步

添加行号功能

演讲的第一个例子是为 iPad 构建代码编辑器,添加行号(13:33):

import UIKit

class TextView: UITextView {}

class ContainerView: UIView {
    let textView = TextView()
    let lineNumberView = UIView()

    textView.font = UIFont.monospacedSystemFont
}

核心是覆写 TextView 的三个视口控制器委托方法(14:42):

class TextView: UITextView {
    // 设置阶段:重置状态,计算起始行号
    override func textViewportLayoutControllerWillLayout(_ textViewportLayoutController: NSTextViewportLayoutController) {
        super.textViewportLayoutControllerWillLayout(textViewportLayoutController)
        // ...
    }

    // 收集每个段落的位置信息
    override func textViewportLayoutController(_ textViewportLayoutController: NSTextViewportLayoutController, configureRenderingSurfaceFor textLayoutFragment: NSTextLayoutFragment) {
        super.textViewportLayoutController(textViewportLayoutController, configureRenderingSurfaceFor: textLayoutFragment)
        // ...
    }

    // 将累积的信息传给 ContainerView
    override func textViewportLayoutControllerDidLayout(_ textViewportLayoutController: NSTextViewportLayoutController) {
        super.textViewportLayoutControllerDidLayout(textViewportLayoutController)
        // ...
    }
}

计算起始行号使用 enumerateTextElements(15:59):

func startingLineNumber(for viewportRange: NSTextRange?) -> Int {
    guard let viewportRange,
          let storage = textLayoutManager?.textContentManager as? NSTextContentStorage else { return 0 }
    let startLocation = storage.documentRange.location
    var count = 1
    storage.enumerateTextElements(from: startLocation) { element in
        guard let range = element.elementRange else { return true }
        if range.location.compare(viewportRange.location) != .orderedAscending { return false }
        count += 1
        return true
    }
    return count
}

关键点

  • enumerateTextElements 遍历文本元素,返回 false 停止遍历
  • 通过比较位置判断是否到达视口起始位置
  • 实际实现中应加入缓存避免每次布局都重新计算

DidLayout 方法需要将坐标从文本容器转换到视口(17:02):

class TextView: UITextView {
    private var lines: [CGRect] = []
    private var startingLineNumber = 0
    var onDidLayout: ((Int, [CGRect]) -> Void)?

    override func textViewportLayoutControllerDidLayout(_ textViewportLayoutController: NSTextViewportLayoutController) {
        super.textViewportLayoutControllerDidLayout(controller)
        let origin = controller.viewportBounds.origin
        onDidLayout?(startingLineNumber, lines.map { $0.offsetBy(dx: 0, dy: -origin.y) })
    }
}

在 ContainerView 中绘制行号(17:16):

class ContainerView: UIView {
    let textView = TextView()
    let lineNumberView = UIView()

    func setup() {
        textView.onDidLayout = { startingLineNumber, lines in
            let attributes: [NSAttributedString.Key: Any] = [
                .font: UIFont.monospacedSystemFont(ofSize: 11, weight: .regular),
                .foregroundColor: UIColor.secondaryLabel
            ]
            for (i, frame) in lines.enumerated() {
                let number = "\(startingLineNumber + i)" as NSString
                number.draw(at: CGPoint(x: 8, y: frame.minY), withAttributes: attributes)
            }
        }
    }
}

关键点

  • onDidLayout 闭包将 TextView 的布局信息传递给 ContainerView
  • 使用 NSString.draw(withAttributes:) 直接绘制文本
  • frame.minY 对齐行号与对应行

实现可折叠区块

第二个例子是食谱应用,将多段落食谱折叠为仅标题(19:22):

class TextView: UITextView, NSTextContentStorageDelegate {
    var collapsedSections: Set<Int> = []

    override func textViewportLayoutControllerWillLayout(_ textViewportLayoutController: NSTextViewportLayoutController) {
        super.textViewportLayoutControllerWillLayout(textViewportLayoutController)
        // ...
    }

    override func textViewportLayoutController(_ textViewportLayoutController: NSTextViewportLayoutController, configureRenderingSurfaceFor textLayoutFragment: NSTextLayoutFragment) {
        super.textViewportLayoutController(textViewportLayoutController, configureRenderingSurfaceFor: textLayoutFragment)
        // ...
    }

    override func textViewportLayoutControllerDidLayout(_ textViewportLayoutController: NSTextViewportLayoutController) {
        super.textViewportLayoutControllerDidLayout(textViewportLayoutController)
        // ...
    }

    // 跳过被折叠段落的布局
    func textContentManager(shouldEnumerate textElement: NSTextElement, options: NSTextContentManager.EnumerationOptions) -> Bool {
        // ...
    }

    // 处理区块折叠切换
    func toggleSection(headerOffset: Int) {
        if collapsedSections.contains(headerOffset) {
            collapsedSections.remove(headerOffset)
        } else {
            collapsedSections.insert(headerOffset)
        }
        guard let textLayoutManager = textLayoutManager else { return }

        let textViewportLayoutController = textLayoutManager.textViewportLayoutController
        textViewportLayoutController.delegate?.textViewportLayoutControllerReceivedSetNeedsLayout?(textViewportLayoutController)
    }
}

关键点

  • NSTextContentStorageDelegate 的 shouldEnumerate 方法控制是否对某个文本元素执行布局
  • collapsedSections 用 Set 存储被折叠的段落偏移量
  • 切换折叠状态后调用 textViewportLayoutControllerReceivedSetNeedsLayout 触发重新布局

文本附件视图提供者复用

文本附件(如内联图片、贴纸)的渲染通过 NSTextAttachmentViewProvider 完成。由于这些对象不可变,编辑段落时会重建,导致动画等状态丢失。

2027 年新增的注册 API 允许指定复用策略(22:06):

import UIKit

class ViewController: UIViewController {
    var textView: UITextView

    func setupTextView() {
        textView = UITextView()
        textView.register(
            [.onEditingInlineParagraphs],
            forTextAttachmentViewProviderType: AnimatedAttachmentViewProvider.self
        )
    }
}

关键点

  • onEditingInlineParagraphs:编辑段落时保留视图提供者
  • onScrollingOutOfViewport:滚出视口时缓存,滚回时恢复
  • 两种策略可组合使用

核心启发

  1. 代码编辑器增强:基于行号示例,进一步实现语法高亮、代码折叠、错误提示。覆写 configureRenderingSurfaceFor 可识别特定文本范围并应用不同渲染样式。

  2. 富文本文档编辑器:可折叠区块的思路可用于长文档的章节管理。配合 NSTextContentStorageDelegate,实现大纲视图与内容同步、章节导航、渐进式加载。

  3. 协作式笔记应用:利用文本附件复用策略,构建支持实时协作的笔记应用。光标位置指示器、用户头像、内联评论都可以作为文本附件,复用策略保证编辑时这些元素不闪烁。

  4. 自定义文本特效:通过 Rendering Surface API,为特定文本范围添加背景动画、边框高亮、手势响应。适合创意写作应用或教育场景的文本互动。

  5. 多视图同步编辑:将多个 NSTextLayoutManager 连接到同一个 NSTextContentStorage。实现主编辑区与预览区实时同步,或左右对照阅读模式。

关联 Session

评论

GitHub Issues · utterances