axiom-textkit-ref

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

TextKit 2 Reference

TextKit 2参考指南

Complete reference for TextKit 2 covering architecture, migration from TextKit 1, Writing Tools integration, and SwiftUI TextEditor with AttributedString through iOS 26.
本指南是TextKit 2的完整参考内容,涵盖架构、从TextKit 1的迁移、写作工具集成,以及iOS 26中结合AttributedString使用SwiftUI TextEditor的相关内容。

Architecture

架构

TextKit 2 uses MVC pattern with new classes optimized for correctness, safety, and performance.
TextKit 2采用MVC模式,搭配经过优化的新类,确保正确性、安全性和性能。

Model Layer

模型层

NSTextContentManager (abstract)
  • Generates NSTextElement objects from backing store
  • Tracks element ranges within document
  • Default implementation: NSTextContentStorage
NSTextContentStorage
  • Uses NSTextStorage as backing store
  • Automatically divides content into NSTextParagraph elements
  • Generates updated elements when text changes
NSTextElement (abstract)
  • Represents portion of content (paragraph, attachment, custom type)
  • Immutable value semantics
  • Properties cannot change after creation
  • Default implementation: NSTextParagraph
NSTextParagraph
  • Represents single paragraph
  • Contains range within document
NSTextContentManager(抽象类)
  • 从底层存储生成NSTextElement对象
  • 跟踪文档内的元素范围
  • 默认实现:NSTextContentStorage
NSTextContentStorage
  • 使用NSTextStorage作为底层存储
  • 自动将内容划分为NSTextParagraph元素
  • 文本变更时自动生成更新后的元素
NSTextElement(抽象类)
  • 代表内容的一部分(段落、附件、自定义类型)
  • 具有不可变值语义
  • 创建后属性无法修改
  • 默认实现:NSTextParagraph
NSTextParagraph
  • 代表单个段落
  • 包含在文档中的范围

Controller Layer

控制器层

NSTextLayoutManager
  • Replaces TextKit 1's NSLayoutManager
  • NO glyph APIs (abstracts away glyphs entirely)
  • Takes elements, lays out into container, generates layout fragments
  • Always uses noncontiguous layout
NSTextLayoutFragment
  • Immutable layout information for one or more elements
  • Key properties:
    • textLineFragments
      — array of NSTextLineFragment
    • layoutFragmentFrame
      — layout bounds within container
    • renderingSurfaceBounds
      — actual drawing bounds (can exceed frame)
NSTextLineFragment
  • Measurement info for single line of text
  • Used for line counting and geometric queries
NSTextLayoutManager
  • 替代TextKit 1中的NSLayoutManager
  • 无字形API(完全抽象掉字形相关操作)
  • 接收元素,在容器中进行布局并生成布局片段
  • 始终使用非连续布局
NSTextLayoutFragment
  • 一个或多个元素的不可变布局信息
  • 关键属性:
    • textLineFragments
      — NSTextLineFragment数组
    • layoutFragmentFrame
      — 容器内的布局边界
    • renderingSurfaceBounds
      — 实际绘制边界(可超出布局帧)
NSTextLineFragment
  • 单行文本的测量信息
  • 用于行数统计和几何查询

View Layer

视图层

NSTextViewportLayoutController
  • Source of truth for viewport layout
  • Coordinates visible-only layout
  • Calls delegate methods:
    willLayout
    ,
    configureRenderingSurface
    ,
    didLayout
NSTextContainer
  • Provides geometric information for layout destination
  • Can define exclusion paths (non-rectangular layout)
NSTextViewportLayoutController
  • 视口布局的可信来源
  • 协调仅可见区域的布局
  • 调用代理方法:
    willLayout
    configureRenderingSurface
    didLayout
NSTextContainer
  • 为布局目标提供几何信息
  • 可定义排除路径(非矩形布局)

Object-Based Ranges

基于对象的范围

NSTextLocation (protocol)
  • Represents single location in text
  • Replaces integer indices
  • Supports structured documents (e.g., DOM with nested elements)
NSTextRange
  • Start and end locations (end is excluded)
  • Can represent nested structure
  • Incompatible with NSRange for non-linear documents
NSTextSelection
  • Contains: granularity, affinity, possibly disjoint ranges
  • Read-only properties
  • Immutable value semantics
NSTextSelectionNavigation
  • Performs actions on selections
  • Returns new NSTextSelection instances
  • Handles bidirectional text correctly
NSTextLocation(协议)
  • 代表文本中的单个位置
  • 替代整数索引
  • 支持结构化文档(如带有嵌套元素的DOM)
NSTextRange
  • 包含起始和结束位置(结束位置不包含在内)
  • 可表示嵌套结构
  • 对于非线性文档,与NSRange不兼容
NSTextSelection
  • 包含:粒度、关联性、可能的不连续范围
  • 只读属性
  • 不可变值语义
NSTextSelectionNavigation
  • 对选区执行操作
  • 返回新的NSTextSelection实例
  • 正确处理双向文本

Core Design Principles

核心设计原则

1. Correctness — No Glyph APIs

1. 正确性 — 无字形API

From WWDC 2021:
"TextKit 2 abstracts away glyph handling to provide a consistent experience for international text."
Why no glyphs?
Problem: In scripts like Kannada and Arabic:
  • One glyph can represent multiple characters (ligatures)
  • One character can split into multiple glyphs
  • Glyphs reorder during shaping
  • No correct character→glyph mapping
Example (Kannada word "October"):
  • Character 4 splits into 2 glyphs
  • Glyphs reorder before ligature application
  • Glyph 3 becomes conjoining form and moves below another glyph
Solution: Use NSTextLocation, NSTextRange, NSTextSelection instead of glyph indices.
来自WWDC 2021:
"TextKit 2抽象掉字形处理,为多语言文本提供一致的体验。"
为什么去掉字形?
问题: 在卡纳达语和阿拉伯语等文字系统中:
  • 一个字形可代表多个字符(连字)
  • 一个字符可拆分为多个字形
  • 字形在字形化过程中会重新排序
  • 字符与字形之间没有正确的映射关系
示例(卡纳达语单词"October"):
  • 第4个字符拆分为2个字形
  • 在应用连字之前,字形会重新排序
  • 第3个字形变为连接形式并移动到另一个字形下方
解决方案: 使用NSTextLocation、NSTextRange、NSTextSelection替代字形索引。

2. Safety — Value Semantics

2. 安全性 — 值语义

Immutable objects:
  • NSTextElement
  • NSTextLayoutFragment
  • NSTextLineFragment
  • NSTextSelection
Benefits:
  • No unintended sharing
  • No side effects from mutations
  • Easier to reason about state
Pattern: To change layout/selection, create new instances with desired changes.
不可变对象:
  • NSTextElement
  • NSTextLayoutFragment
  • NSTextLineFragment
  • NSTextSelection
优势:
  • 无意外共享
  • 突变不会产生副作用
  • 更容易推理状态变化
模式: 要更改布局或选区,创建带有所需变更的新实例。

3. Performance — Viewport Layout

3. 性能 — 视口布局

Always Noncontiguous: TextKit 2 performs layout only for visible content + overscroll region.
TextKit 1:
  • Optional noncontiguous layout (boolean property)
  • No visibility into layout state
  • Can't control which parts get laid out
TextKit 2:
  • Always noncontiguous
  • Viewport defines visible area
  • Consistent layout info for viewport
  • Notifications for viewport layout updates
Viewport Delegate Methods:
  1. textViewportLayoutControllerWillLayout(_:)
    — setup before layout
  2. textViewportLayoutController(_:configureRenderingSurfaceFor:)
    — per fragment
  3. textViewportLayoutControllerDidLayout(_:)
    — cleanup after layout
始终非连续: TextKit 2仅对可见内容+滚动过度区域执行布局。
TextKit 1:
  • 可选非连续布局(布尔属性)
  • 无法查看布局状态
  • 无法控制哪些部分会被布局
TextKit 2:
  • 始终使用非连续布局
  • 视口定义可见区域
  • 视口的布局信息保持一致
  • 视口布局更新时发送通知
视口代理方法:
  1. textViewportLayoutControllerWillLayout(_:)
    — 布局前的准备
  2. textViewportLayoutController(_:configureRenderingSurfaceFor:)
    — 针对每个片段的配置
  3. textViewportLayoutControllerDidLayout(_:)
    — 布局后的清理

Migration from TextKit 1

从TextKit 1迁移

Key Paradigm Shift

核心范式转变

TextKit 1TextKit 2
GlyphsElements
NSRangeNSTextLocation/NSTextRange
NSLayoutManagerNSTextLayoutManager
Glyph APIsNO glyph APIs
Optional noncontiguousAlways noncontiguous
NSTextStorage directlyVia NSTextContentManager
TextKit 1TextKit 2
字形元素
NSRangeNSTextLocation/NSTextRange
NSLayoutManagerNSTextLayoutManager
字形API无字形API
可选非连续布局始终非连续布局
直接使用NSTextStorage通过NSTextContentManager使用

API Naming Heuristics

API命名规律

From WWDC 2022:
  • .offset
    in name → TextKit 1
  • .location
    in name → TextKit 2
来自WWDC 2022:
  • 名称中包含
    .offset
    → TextKit 1
  • 名称中包含
    .location
    → TextKit 2

NSRange ↔ NSTextRange Conversion

NSRange ↔ NSTextRange 转换

NSRange → NSTextRange:
swift
// 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:
swift
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)
NSRange → NSTextRange:
swift
// UITextView/NSTextView
let nsRange = NSRange(location: 0, length: 10)

// 通过内容管理器转换
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:
swift
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)

Glyph API Replacements

字形API替代方案

NO direct glyph API equivalents. Must use higher-level structures.
Example (TextKit 1 - counting lines):
swift
// 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...
}
Replacement (TextKit 2 - enumerate fragments):
swift
// TextKit 2 - enumerate layout fragments
var lineCount = 0
textLayoutManager.enumerateTextLayoutFragments(
    from: textLayoutManager.documentRange.location,
    options: [.ensuresLayout]
) { fragment in
    lineCount += fragment.textLineFragments.count
    return true
}
没有直接对应的字形API,必须使用更高层级的结构。
示例(TextKit 1 - 统计行数):
swift
// TextKit 1 - 遍历字形
var lineCount = 0
let glyphRange = layoutManager.glyphRange(for: textContainer)
for glyphIndex in glyphRange.location..<NSMaxRange(glyphRange) {
    let lineRect = layoutManager.lineFragmentRect(
        forGlyphAt: glyphIndex,
        effectiveRange: nil
    )
    // 统计唯一的矩形...
}
替代方案(TextKit 2 - 枚举片段):
swift
// TextKit 2 - 枚举布局片段
var lineCount = 0
textLayoutManager.enumerateTextLayoutFragments(
    from: textLayoutManager.documentRange.location,
    options: [.ensuresLayout]
) { fragment in
    lineCount += fragment.textLineFragments.count
    return true
}

Compatibility Mode (UITextView/NSTextView)

兼容模式(UITextView/NSTextView)

Automatic Fallback to TextKit 1: Happens when you access
.layoutManager
property.
Warning (WWDC 2022):
"Accessing textView.layoutManager triggers TK1 fallback"
Once fallback occurs:
  • No automatic way back to TextKit 2
  • Expensive to switch
  • Lose UI state (selection, scroll position)
  • One-way operation
Prevent Fallback:
  1. Check
    .textLayoutManager
    first (TextKit 2)
  2. Only access
    .layoutManager
    in else clause
  3. Opt out at initialization if TK1 required
swift
// Check TextKit 2 first
if let textLayoutManager = textView.textLayoutManager {
    // TextKit 2 code
} else if let layoutManager = textView.layoutManager {
    // TextKit 1 fallback (old OS versions)
}
Debug Fallback:
  • UIKit: Breakpoint on
    _UITextViewEnablingCompatibilityMode
  • AppKit: Subscribe to
    willSwitchToNSLayoutManagerNotification
自动回退到TextKit 1: 当你访问
.layoutManager
属性时会触发此行为。
警告(WWDC 2022):
"访问textView.layoutManager会触发TextKit 1回退"
回退发生后:
  • 无法自动切换回TextKit 2
  • 切换成本高昂
  • 丢失UI状态(选区、滚动位置)
  • 单向操作
防止回退:
  1. 优先检查
    .textLayoutManager
    (TextKit 2)
  2. 仅在else分支中访问
    .layoutManager
  3. 如果需要TextKit 1,在初始化时就选择退出TextKit 2
swift
// 优先检查TextKit 2
if let textLayoutManager = textView.textLayoutManager {
    // TextKit 2代码
} else if let layoutManager = textView.layoutManager {
    // TextKit 1回退(适配旧系统版本)
}
调试回退:
  • UIKit:
    _UITextViewEnablingCompatibilityMode
    设置断点
  • AppKit: 订阅
    willSwitchToNSLayoutManagerNotification
    通知

NSTextView Opt-In (macOS)

NSTextView 主动启用(macOS)

Create TextKit 2 NSTextView:
swift
let textLayoutManager = NSTextLayoutManager()
let textContainer = NSTextContainer()
textLayoutManager.textContainer = textContainer

let textView = NSTextView(frame: .zero, textContainer: textContainer)
// textView.textLayoutManager now available
New Convenience Constructor:
swift
// iOS 16+ / macOS 13+
let textView = UITextView(usingTextLayoutManager: true)
let nsTextView = NSTextView(usingTextLayoutManager: true)
创建TextKit 2 NSTextView:
swift
let textLayoutManager = NSTextLayoutManager()
let textContainer = NSTextContainer()
textLayoutManager.textContainer = textContainer

let textView = NSTextView(frame: .zero, textContainer: textContainer)
// 此时textView.textLayoutManager可用
新的便捷构造方法:
swift
// iOS 16+ / macOS 13+
let textView = UITextView(usingTextLayoutManager: true)
let nsTextView = NSTextView(usingTextLayoutManager: true)

Delegate Hooks

代理钩子

NSTextContentStorageDelegate

NSTextContentStorageDelegate

Customize attributes without modifying storage:
swift
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)
}
Filter elements (hide/show content):
swift
func textContentManager(
    _ textContentManager: NSTextContentManager,
    shouldEnumerate textElement: NSTextElement,
    options: NSTextContentManager.EnumerationOptions
) -> Bool {
    // Return false to hide element
    if hideComments && isComment(textElement) {
        return false
    }
    return true
}
无需修改存储即可自定义属性:
swift
func textContentStorage(
    _ textContentStorage: NSTextContentStorage,
    textParagraphWith range: NSRange
) -> NSTextParagraph? {
    // 修改用于显示的属性
    var attributedString = textContentStorage.attributedString!
        .attributedSubstring(from: range)

    // 添加自定义属性
    if isComment(range) {
        attributedString.addAttribute(
            .foregroundColor,
            value: UIColor.systemIndigo,
            range: NSRange(location: 0, length: attributedString.length)
        )
    }

    return NSTextParagraph(attributedString: attributedString)
}
过滤元素(显示/隐藏内容):
swift
func textContentManager(
    _ textContentManager: NSTextContentManager,
    shouldEnumerate textElement: NSTextElement,
    options: NSTextContentManager.EnumerationOptions
) -> Bool {
    // 返回false以隐藏元素
    if hideComments && isComment(textElement) {
        return false
    }
    return true
}

NSTextLayoutManagerDelegate

NSTextLayoutManagerDelegate

Provide custom layout fragments:
swift
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
    )
}
提供自定义布局片段:
swift
func textLayoutManager(
    _ textLayoutManager: NSTextLayoutManager,
    textLayoutFragmentFor location: NSTextLocation,
    in textElement: NSTextElement
) -> NSTextLayoutFragment {
    // 为特殊样式返回自定义片段
    if isComment(textElement) {
        return BubbleLayoutFragment(
            textElement: textElement,
            range: textElement.elementRange
        )
    }
    return NSTextLayoutFragment(
        textElement: textElement,
        range: textElement.elementRange
    )
}

NSTextViewportLayoutController.Delegate

NSTextViewportLayoutController.Delegate

Viewport layout lifecycle:
swift
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
}
视口布局生命周期:
swift
func textViewportLayoutControllerWillLayout(_ controller: NSTextViewportLayoutController) {
    // 准备布局:清除子图层,开始动画
}

func textViewportLayoutController(
    _ controller: NSTextViewportLayoutController,
    configureRenderingSurfaceFor textLayoutFragment: NSTextLayoutFragment
) {
    // 更新每个可见片段的几何信息
    let layer = getOrCreateLayer(for: textLayoutFragment)
    layer.frame = textLayoutFragment.layoutFragmentFrame
    // 如有需要,动画过渡到新位置
}

func textViewportLayoutControllerDidLayout(_ controller: NSTextViewportLayoutController) {
    // 完成布局:提交动画,更新滚动指示器
}

Practical Patterns

实用模式

Custom Layout Fragment (Bubble Backgrounds)

自定义布局片段(气泡背景)

swift
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)
    }
}
swift
class BubbleLayoutFragment: NSTextLayoutFragment {
    override func draw(at point: CGPoint, in context: CGContext) {
        // 绘制自定义背景
        context.setFillColor(UIColor.systemIndigo.cgColor)
        let bubblePath = UIBezierPath(
            roundedRect: layoutFragmentFrame,
            cornerRadius: 8
        )
        context.addPath(bubblePath.cgPath)
        context.fillPath()

        // 在上方绘制文本
        super.draw(at: point, in: context)
    }
}

Rendering Attributes (Temporary Styling)

渲染属性(临时样式)

Add attributes that don't modify text storage:
swift
textLayoutManager.addRenderingAttribute(
    .foregroundColor,
    value: UIColor.green,
    for: ingredientRange
)

// Remove when no longer needed
textLayoutManager.removeRenderingAttribute(
    .foregroundColor,
    for: ingredientRange
)
添加不修改文本存储的属性:
swift
textLayoutManager.addRenderingAttribute(
    .foregroundColor,
    value: UIColor.green,
    for: ingredientRange
)

// 不再需要时移除
textLayoutManager.removeRenderingAttribute(
    .foregroundColor,
    for: ingredientRange
)

Text Attachment with UIView

结合UIView的文本附件

swift
// 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
    }
}
swift
// iOS 15+
let attachment = NSTextAttachment()
attachment.image = UIImage(systemName: "star.fill")

// 提供用于交互的视图
class AttachmentViewProvider: NSTextAttachmentViewProvider {
    override func loadView() {
        super.loadView()
        let button = UIButton(type: .system)
        button.setTitle("点击我", for: .normal)
        button.addTarget(self, action: #selector(didTap), for: .touchUpInside)
        view = button
    }

    @objc func didTap() {
        // 处理点击事件
    }
}

Lists and Tables

列表与表格

swift
// Create list
let listItem = NSTextList(markerFormat: .disc, options: 0)
let paragraphStyle = NSMutableParagraphStyle()
paragraphStyle.textLists = [listItem]

attributedString.addAttribute(
    .paragraphStyle,
    value: paragraphStyle,
    range: range
)
NSTextList available in UIKit (iOS 16+), previously AppKit-only.
swift
// 创建列表
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。

Hit Testing & Selection Geometry

点击测试与选区几何

swift
// 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
}
swift
// 获取点击位置对应的文本范围
let location = textLayoutManager.location(
    interactingAt: point,
    inContainerAt: textContainer.location
)

// 获取范围的边界矩形
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+)

写作工具(iOS 18+)

Basic Integration (TextKit 2 Required)

基础集成(需TextKit 2)

From WWDC 2024:
"UITextView or NSTextView has to use TextKit 2 to support the full Writing Tools experience. If using TextKit 1, you will get a limited experience that just shows rewritten results in a panel."
Free for native text views:
swift
// UITextView, NSTextView, WKWebView
// Writing Tools appears automatically
来自WWDC 2024:
"UITextView或NSTextView必须使用TextKit 2才能支持完整的写作工具体验。如果使用TextKit 1,只能获得有限的体验,仅在面板中显示改写结果。"
原生文本视图默认支持:
swift
// UITextView, NSTextView, WKWebView
// 写作工具会自动显示

Lifecycle Delegate Methods

生命周期代理方法

swift
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
}
swift
func textViewWritingToolsWillBegin(_ textView: UITextView) {
    // 暂停同步,防止编辑
    isSyncing = false
}

func textViewWritingToolsDidEnd(_ textView: UITextView) {
    // 恢复同步
    isSyncing = true
}

// 检查是否处于激活状态
if textView.isWritingToolsActive {
    // 不要持久化文本存储
}

Controlling Behavior

控制行为

swift
// Opt out completely
textView.writingToolsBehavior = .none

// Panel-only experience (no in-line edits)
textView.writingToolsBehavior = .limited

// Full experience (default)
textView.writingToolsBehavior = .default
swift
// 完全禁用
textView.writingToolsBehavior = .none

// 仅面板体验(无行内编辑)
textView.writingToolsBehavior = .limited

// 完整体验(默认)
textView.writingToolsBehavior = .default

Result Options

结果选项

swift
// 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]
swift
// 仅纯文本
textView.writingToolsResultOptions = [.plainText]

// 富文本
textView.writingToolsResultOptions = [.richText]

// 富文本+表格
textView.writingToolsResultOptions = [.richText, .table]

// 富文本+列表
textView.writingToolsResultOptions = [.richText, .list]

Protected Ranges

受保护范围

swift
// UITextViewDelegate / NSTextViewDelegate
func textView(
    _ textView: UITextView,
    writingToolsIgnoredRangesIn enclosingRange: NSRange
) -> [NSRange] {
    // Return ranges that Writing Tools should not modify
    return codeBlockRanges + quoteRanges
}
WKWebView:
<blockquote>
and
<pre>
tags automatically ignored.
swift
// UITextViewDelegate / NSTextViewDelegate
func textView(
    _ textView: UITextView,
    writingToolsIgnoredRangesIn enclosingRange: NSRange
) -> [NSRange] {
    // 返回写作工具不应修改的范围
    return codeBlockRanges + quoteRanges
}
WKWebView:
<blockquote>
<pre>
标签会被自动忽略。

Writing Tools Coordinator (iOS 26+)

写作工具协调器(iOS 26+)

Advanced integration for custom text engines.
为自定义文本引擎提供高级集成能力。

Setup

设置

swift
// 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
swift
// 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 Delegate

协调器代理

Provide context:
swift
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]
}
Apply changes:
swift
func writingToolsCoordinator(
    _ coordinator: NSWritingToolsCoordinator,
    replace context: NSWritingToolsCoordinator.Context,
    range: NSRange,
    with attributedString: NSAttributedString
) async {
    // Update text storage
    textStorage.replaceCharacters(in: range, with: attributedString)
}
Update selection:
swift
func writingToolsCoordinator(
    _ coordinator: NSWritingToolsCoordinator,
    updateSelectedRange selectedRange: NSRange,
    in context: NSWritingToolsCoordinator.Context
) async {
    // Update selection
    self.selectedRange = selectedRange
}
Provide previews for animation:
swift
// 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
    )
}
Proofreading marks:
swift
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")
    }
}
提供上下文:
swift
func writingToolsCoordinator(
    _ coordinator: NSWritingToolsCoordinator,
    requestContexts scope: NSWritingToolsCoordinator.ContextScope
) async -> [NSWritingToolsCoordinator.Context] {
    // 返回带属性字符串+选区范围
    let context = NSWritingToolsCoordinator.Context(
        attributedString: currentText,
        range: currentSelection
    )
    return [context]
}
应用变更:
swift
func writingToolsCoordinator(
    _ coordinator: NSWritingToolsCoordinator,
    replace context: NSWritingToolsCoordinator.Context,
    range: NSRange,
    with attributedString: NSAttributedString
) async {
    // 更新文本存储
    textStorage.replaceCharacters(in: range, with: attributedString)
}
更新选区:
swift
func writingToolsCoordinator(
    _ coordinator: NSWritingToolsCoordinator,
    updateSelectedRange selectedRange: NSRange,
    in context: NSWritingToolsCoordinator.Context
) async {
    // 更新选区
    self.selectedRange = selectedRange
}
提供动画预览:
swift
// macOS
func writingToolsCoordinator(
    _ coordinator: NSWritingToolsCoordinator,
    previewsFor context: NSWritingToolsCoordinator.Context,
    range: NSRange
) async -> [NSTextPreview] {
    // 返回每行的预览以实现平滑动画
    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 UITargetedPreview(
        view: previewView,
        parameters: parameters
    )
}
校对标记:
swift
func writingToolsCoordinator(
    _ coordinator: NSWritingToolsCoordinator,
    underlinesFor context: NSWritingToolsCoordinator.Context,
    range: NSRange
) async -> [NSValue] {
    // 返回下划线的贝塞尔路径
    return ranges.map { range in
        let path = bezierPath(for: range)
        return NSValue(bytes: &path, objCType: "CGPath")
    }
}

PresentationIntent (iOS 26+)

PresentationIntent(iOS 26+)

Semantic rich text result option:
swift
coordinator.writingToolsResultOptions = [.richText, .presentationIntent]
Difference from display attributes:
Display attributes (bold, italic):
  • Concrete font info (point sizes, font names)
  • No semantic meaning
PresentationIntent (header, code block, emphasis):
  • Semantic style info
  • App converts to internal styles
  • Lists, tables, code blocks use presentation intent
  • Underline, subscript, superscript still use display attributes
Example:
swift
// Check for presentation intent
if attributedString.runs[\.presentationIntent].contains(where: { $0?.components.contains(.header(level: 1)) == true }) {
    // This is a heading
}
语义化富文本结果选项:
swift
coordinator.writingToolsResultOptions = [.richText, .presentationIntent]
与显示属性的区别:
显示属性(粗体、斜体):
  • 具体的字体信息(字号、字体名称)
  • 无语义含义
PresentationIntent(标题、代码块、强调):
  • 语义化样式信息
  • 应用会将其转换为内部样式
  • 列表、表格、代码块使用presentation intent
  • 下划线、下标、上标仍使用显示属性
示例:
swift
// 检查presentation intent
if attributedString.runs[\.presentationIntent].contains(where: { $0?.components.contains(.header(level: 1)) == true }) {
    // 这是一个标题
}

SwiftUI TextEditor + AttributedString (iOS 26+)

SwiftUI TextEditor + AttributedString(iOS 26+)

Basic Usage

基础用法

swift
struct RecipeEditor: View {
    @State private var text: AttributedString = "Recipe text"

    var body: some View {
        TextEditor(text: $text)
    }
}
Supported attributes:
  • Bold, italic, underline, strikethrough
  • Custom fonts, point size
  • Foreground and background colors
  • Kerning, tracking, baseline offset
  • Genmoji
  • Line height, text alignment, base writing direction
swift
struct RecipeEditor: View {
    @State private var text: AttributedString = "食谱文本"

    var body: some View {
        TextEditor(text: $text)
    }
}
支持的属性:
  • 粗体、斜体、下划线、删除线
  • 自定义字体、字号
  • 前景色和背景色
  • 字距、字符间距、基线偏移
  • Genmoji
  • 行高、文本对齐、基础书写方向

Selection Binding

选区绑定

swift
@State private var selection: AttributedTextSelection?

TextEditor(text: $text, selection: $selection)
AttributedTextSelection:
swift
enum AttributedTextSelection {
    case none
    case single(NSRange)
    case multiple(Set<NSRange>) // For bidirectional text
}
Get selected text:
swift
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]
    }
}
swift
@State private var selection: AttributedTextSelection?

TextEditor(text: $text, selection: $selection)
AttributedTextSelection:
swift
enum AttributedTextSelection {
    case none
    case single(NSRange)
    case multiple(Set<NSRange>) // 用于双向文本
}
获取选中文本:
swift
if let selection {
    let selectedText: AttributedSubstring
    switch selection.indices {
    case .none:
        selectedText = text[...]
    case .single(let range):
        selectedText = text[range]
    case .multiple(let ranges):
        // 从RangeSet获取不连续子串
        selectedText = text[selection]
    }
}

Custom Formatting Definition

自定义格式定义

Constrain which attributes are editable:
swift
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
}
Apply to TextEditor:
swift
TextEditor(text: $text)
    .attributedTextFormattingDefinition(RecipeFormattingDefinition.self)
限制可编辑的属性:
swift
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:
swift
TextEditor(text: $text)
    .attributedTextFormattingDefinition(RecipeFormattingDefinition.self)

Value Constraints

值约束

Control attribute values based on custom logic:
swift
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
        }
    }
}
System behavior:
  • TextEditor probes constraints to determine if changes are valid
  • If constraint would revert change, control is disabled
  • Constraints applied to pasted content
基于自定义逻辑控制属性值:
swift
struct IngredientsAreGreen: AttributedTextValueConstraint {
    typealias Definition = RecipeFormattingDefinition
    typealias AttributeKey = ForegroundColorAttribute

    func constrain(
        _ value: inout Color?,
        in scope: RecipeFormattingDefinition.FormatScope
    ) {
        if scope.ingredient != nil {
            value = .green // 食材始终为绿色
        } else {
            value = nil // 其他使用默认值
        }
    }
}
系统行为:
  • TextEditor会检查约束以确定变更是否有效
  • 如果约束会回退变更,对应的控件会被禁用
  • 约束会应用到粘贴的内容

Custom Attributes

自定义属性

Define attribute:
swift
struct IngredientAttribute: CodableAttributedStringKey {
    typealias Value = UUID // Ingredient ID

    static let name = "ingredient"
}

extension AttributeScopes.RecipeAttributeScope {
    var ingredient: IngredientAttribute.Type { IngredientAttribute.self }
}
Attribute behavior:
swift
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
}
定义属性:
swift
struct IngredientAttribute: CodableAttributedStringKey {
    typealias Value = UUID // 食材ID

    static let name = "ingredient"
}

extension AttributeScopes.RecipeAttributeScope {
    var ingredient: IngredientAttribute.Type { IngredientAttribute.self }
}
属性行为:
swift
extension IngredientAttribute {
    // 在食材后输入文本时不扩展属性
    static let inheritedByAddedText = false

    // 如果run中的文本变更则移除属性
    static let invalidationConditions: [AttributedString.InvalidationCondition] = [
        .textChanged
    ]

    // 可选:限制在段落边界内
    static let runBoundaries: AttributedString.RunBoundaries = .paragraph
}

AttributedString Mutations

AttributedString 变更

Safe index updates:
swift
// 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
Don't use old indices:
swift
// BAD - indices invalidated by mutation
let range = text.characters.range(of: "butter")!
text[range].foregroundColor = .green
text.append(" (unsalted)") // range is now invalid!
安全的索引更新:
swift
// 在变更期间转换更新索引/选区
text.transform(updating: &selection) { mutableText in
    // 查找范围
    let ranges = mutableText.characters.ranges(of: "butter")

    // 一次性为所有范围设置属性
    for range in ranges {
        mutableText[range].ingredient = ingredientID
    }
}

// 此时selection已更新为匹配转换后的文本
不要使用旧索引:
swift
// 错误示例 - 变更会使索引失效
let range = text.characters.range(of: "butter")!
text[range].foregroundColor = .green
text.append(" (unsalted)") // 此时range已失效!

AttributedString Views

AttributedString 视图

Multiple views into same content:
  • characters
    — grapheme clusters
  • unicodeScalars
    — Unicode scalars
  • utf8
    — UTF-8 code units
  • utf16
    — UTF-16 code units
All views share same indices.
同一内容的多个视图:
  • characters
    — 字形簇
  • unicodeScalars
    — Unicode标量
  • utf8
    — UTF-8代码单元
  • utf16
    — UTF-16代码单元
所有视图共享相同的索引。

Known Limitations & Gotchas

已知限制与注意事项

Viewport Scroll Issues

视口滚动问题

From expert articles:
  • Viewport can cause scroll position instability
  • usageBoundsForTextContainer
    changes during scroll
  • Apple's TextEdit exhibits same issues
  • Trade-off for performance benefits
来自专家文章:
  • 视口可能导致滚动位置不稳定
  • usageBoundsForTextContainer
    在滚动期间会变化
  • Apple的TextEdit也存在相同问题
  • 这是为了性能做出的权衡

TextKit 1 Compatibility

TextKit 1兼容性

  • Accessing
    .layoutManager
    triggers fallback
  • One-way operation (no automatic return)
  • Loses UI state during switch
  • Expensive to switch layout systems
  • 访问
    .layoutManager
    会触发回退
  • 单向操作(无法自动返回TextKit 2)
  • 切换时丢失UI状态
  • 布局系统切换成本高昂

AttributedString Index Invalidation

AttributedString索引失效

  • Any mutation invalidates all indices
  • Must use
    .transform(updating:)
    to keep indices valid
  • Indices only work with originating AttributedString
  • 任何变更都会使所有索引失效
  • 必须使用
    .transform(updating:)
    保持索引有效
  • 索引仅对生成它的AttributedString有效

Limited TextKit 1 Support

有限的TextKit 1支持

Unsupported in TextKit 2:
  • NSTextTable (use NSTextList or custom layouts)
  • Some legacy text attachments
  • Direct glyph manipulation
TextKit 2不支持以下内容:
  • NSTextTable(使用NSTextList或自定义布局替代)
  • 部分旧版文本附件
  • 直接操作字形

Resources

资源

WWDC: 2021-10061, 2022-10090, 2023-10058, 2024-10168, 2025-265, 2025-280
Docs: /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, /uikit/writing-tools, /appkit/enhancing-your-custom-text-engine-with-writing-tools
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, /uikit/writing-tools, /appkit/enhancing-your-custom-text-engine-with-writing-tools