mcpField
A
axiom-textkit-ref

axiom-textkit-ref

github.com/charleswiltgen开发1.2k173 次安装今天更新
代码仓库

关于 axiom-textkit-ref

面向 Swift 开发者的 TextKit 2 参考技能,涵盖架构、从 TextKit 1 迁移、Writing Tools 集成及 SwiftUI TextEditor。也说明视口布局、选择范围和自定义布局片段等实践。

主要功能

  1. 01TextKit 2 架构与核心类型
  2. 02TextKit 1 迁移指南
  3. 03视口布局与文本范围
  4. 04Writing Tools 集成
  5. 05自定义布局和渲染

适用场景

  1. 01将文本视图迁移到 TextKit 2
  2. 02处理可见区域内的文本布局
  3. 03集成 Writing Tools 编辑体验
  4. 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:

  • 始终为非连续布局
  • 视口定义可见区域
  • 提供一致的视口布局信息
  • 视口布局更新时发送通知

视口代理方法:

  1. textViewportLayoutControllerWillLayout(_:) — 布局前进行设置
  2. textViewportLayoutController(_:configureRenderingSurfaceFor:) — 针对每个片段
  3. textViewportLayoutControllerDidLayout(_:) — 布局后进行清理

从 TextKit 1 迁移

核心范式转变
TextKit 1TextKit 2
字形元素
NSRangeNSTextLocation/NSTextRange
NSLayoutManagerNSTextLayoutManager
字形 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 状态会丢失(选区、滚动位置)
  • 这是单向操作

防止回退:

  1. 先检查 .textLayoutManager(TextKit 2)
  2. 仅在 else 分支中访问 .layoutManager
  3. 如果需要使用 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

评论

支持 Markdown