axiom-textkit-ref by charleswiltgen/axiom
npx skills add https://github.com/charleswiltgen/axiom --skill axiom-textkit-ref涵盖架构、从 TextKit 1 迁移、写作工具集成以及通过 iOS 26 使用 AttributedString 的 SwiftUI TextEditor 的完整参考。
TextKit 2 使用 MVC 模式,并引入了新的类,针对正确性、安全性和性能进行了优化。
NSTextContentManager (抽象)
NSTextContentStorage
NSTextElement (抽象)
NSTextParagraph
NSTextLayoutManager
NSTextLayoutFragment
广告位招租
在这里展示您的产品或服务
触达数万 AI 开发者,精准高效
textLineFragments — NSTextLineFragment 数组layoutFragmentFrame — 容器内的布局边界renderingSurfaceBounds — 实际绘制边界(可能超出框架)NSTextLineFragment
NSTextViewportLayoutController
willLayout、configureRenderingSurface、didLayoutNSTextContainer
NSTextLocation (协议)
NSTextRange
NSTextSelection
NSTextSelectionNavigation
来自 WWDC 2021:
"TextKit 2 抽象了字形处理,为国际化文本提供一致的体验。"
为什么没有字形?
问题: 在卡纳达语和阿拉伯语等文字中:
示例(卡纳达语单词 "October"):
解决方案: 使用 NSTextLocation、NSTextRange、NSTextSelection 代替字形索引。
不可变对象:
优点:
模式: 要更改布局/选择,创建具有所需更改的新实例。
始终非连续: TextKit 2 仅对可见内容 + 过度滚动区域执行布局。
TextKit 1:
TextKit 2:
视口委托方法:
textViewportLayoutControllerWillLayout(_:) — 布局前准备textViewportLayoutController(_:configureRenderingSurfaceFor:) — 每个片段textViewportLayoutControllerDidLayout(_:) — 布局后清理| TextKit 1 | TextKit 2 |
|---|---|
| 字形 | 元素 |
| NSRange | NSTextLocation/NSTextRange |
| NSLayoutManager | NSTextLayoutManager |
| 字形 API | 没有字形 API |
| 可选非连续 | 始终非连续 |
| 直接使用 NSTextStorage | 通过 NSTextContentManager |
来自 WWDC 2022:
.offset → TextKit 1.location → TextKit 2NSRange → NSTextRange:
// 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:
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 等效项。 必须使用更高级别的结构。
示例(TextKit 1 - 计数行数):
// 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 - 枚举片段):
// TextKit 2 - 枚举布局片段
var lineCount = 0
textLayoutManager.enumerateTextLayoutFragments(
from: textLayoutManager.documentRange.location,
options: [.ensuresLayout]
) { fragment in
lineCount += fragment.textLineFragments.count
return true
}
自动回退到 TextKit 1: 当访问 .layoutManager 属性时发生。
警告(WWDC 2022):
"访问 textView.layoutManager 会触发 TK1 回退"
一旦发生回退:
防止回退:
.textLayoutManager (TextKit 2).layoutManager// 首先检查 TextKit 2
if let textLayoutManager = textView.textLayoutManager {
// TextKit 2 代码
} else if let layoutManager = textView.layoutManager {
// TextKit 1 回退(旧操作系统版本)
}
调试回退:
_UITextViewEnablingCompatibilityMode 上设置断点willSwitchToNSLayoutManagerNotification创建 TextKit 2 NSTextView:
let textLayoutManager = NSTextLayoutManager()
let textContainer = NSTextContainer()
textLayoutManager.textContainer = textContainer
let textView = NSTextView(frame: .zero, textContainer: textContainer)
// textView.textLayoutManager 现在可用
新的便捷构造函数:
// iOS 16+ / macOS 13+
let textView = UITextView(usingTextLayoutManager: true)
let nsTextView = NSTextView(usingTextLayoutManager: true)
在不修改存储的情况下自定义属性:
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)
}
过滤元素(隐藏/显示内容):
func textContentManager(
_ textContentManager: NSTextContentManager,
shouldEnumerate textElement: NSTextElement,
options: NSTextContentManager.EnumerationOptions
) -> Bool {
// 返回 false 以隐藏元素
if hideComments && isComment(textElement) {
return false
}
return true
}
提供自定义布局片段:
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
)
}
视口布局生命周期:
func textViewportLayoutControllerWillLayout(_ controller: NSTextViewportLayoutController) {
// 准备布局:清除子图层,开始动画
}
func textViewportLayoutController(
_ controller: NSTextViewportLayoutController,
configureRenderingSurfaceFor textLayoutFragment: NSTextLayoutFragment
) {
// 为每个可见片段更新几何
let layer = getOrCreateLayer(for: textLayoutFragment)
layer.frame = textLayoutFragment.layoutFragmentFrame
// 如果需要,动画到新位置
}
func textViewportLayoutControllerDidLayout(_ controller: NSTextViewportLayoutController) {
// 完成:提交动画,更新滚动指示器
}
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)
}
}
添加不修改文本存储的属性:
textLayoutManager.addRenderingAttribute(
.foregroundColor,
value: UIColor.green,
for: ingredientRange
)
// 不再需要时移除
textLayoutManager.removeRenderingAttribute(
.foregroundColor,
for: ingredientRange
)
// 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() {
// 处理点击
}
}
// 创建列表
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。
// 获取点处的文本范围
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
}
来自 WWDC 2024:
"UITextView 或 NSTextView 必须使用 TextKit 2 才能支持完整的写作工具体验。如果使用 TextKit 1,您将获得有限的体验,仅在面板中显示重写结果。"
原生文本视图免费:
// UITextView, NSTextView, WKWebView
// 写作工具自动出现
func textViewWritingToolsWillBegin(_ textView: UITextView) {
// 暂停同步,防止编辑
isSyncing = false
}
func textViewWritingToolsDidEnd(_ textView: UITextView) {
// 恢复同步
isSyncing = true
}
// 检查是否活跃
if textView.isWritingToolsActive {
// 不要持久化文本存储
}
// 完全选择退出
textView.writingToolsBehavior = .none
// 仅面板体验(无内联编辑)
textView.writingToolsBehavior = .limited
// 完整体验(默认)
textView.writingToolsBehavior = .default
// 仅纯文本
textView.writingToolsResultOptions = [.plainText]
// 富文本
textView.writingToolsResultOptions = [.richText]
// 富文本 + 表格
textView.writingToolsResultOptions = [.richText, .table]
// 富文本 + 列表
textView.writingToolsResultOptions = [.richText, .list]
// UITextViewDelegate / NSTextViewDelegate
func textView(
_ textView: UITextView,
writingToolsIgnoredRangesIn enclosingRange: NSRange
) -> [NSRange] {
// 返回写作工具不应修改的范围
return codeBlockRanges + quoteRanges
}
WKWebView: <blockquote> 和 <pre> 标签自动被忽略。
用于自定义文本引擎的高级集成。
// 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
提供上下文:
func writingToolsCoordinator(
_ coordinator: NSWritingToolsCoordinator,
requestContexts scope: NSWritingToolsCoordinator.ContextScope
) async -> [NSWritingToolsCoordinator.Context] {
// 返回属性字符串 + 选择范围
let context = NSWritingToolsCoordinator.Context(
attributedString: currentText,
range: currentSelection
)
return [context]
}
应用更改:
func writingToolsCoordinator(
_ coordinator: NSWritingToolsCoordinator,
replace context: NSWritingToolsCoordinator.Context,
range: NSRange,
with attributedString: NSAttributedString
) async {
// 更新文本存储
textStorage.replaceCharacters(in: range, with: attributedString)
}
更新选择:
func writingToolsCoordinator(
_ coordinator: NSWritingToolsCoordinator,
updateSelectedRange selectedRange: NSRange,
in context: NSWritingToolsCoordinator.Context
) async {
// 更新选择
self.selectedRange = selectedRange
}
为动画提供预览:
// 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
)
}
校对标记:
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")
}
}
语义富文本结果选项:
coordinator.writingToolsResultOptions = [.richText, .presentationIntent]
与显示属性的区别:
显示属性(粗体、斜体):
呈现意图(标题、代码块、强调):
示例:
// 检查呈现意图
if attributedString.runs[\.presentationIntent].contains(where: { $0?.components.contains(.header(level: 1)) == true }) {
// 这是一个标题
}
struct RecipeEditor: View {
@State private var text: AttributedString = "配方文本"
var body: some View {
TextEditor(text: $text)
}
}
支持的属性:
// 文本对齐方式 (AttributedString.TextAlignment)
var text = AttributedString("居中对齐段落")
text.alignment = .center // .left, .right, .center
// 双向文本的书写方向
var bidiText = AttributedString("Hello عربي")
bidiText.writingDirection = .rightToLeft // .leftToRight, .rightToLeft
var multiline = AttributedString("多行\n段落。")
multiline.lineHeight = .exact(points: 32) // 固定高度
multiline.lineHeight = .multiple(factor: 2.5) // 倍数
multiline.lineHeight = .loose // 系统宽松间距
@State private var selection: AttributedTextSelection?
TextEditor(text: $text, selection: $selection)
AttributedTextSelection:
enum AttributedTextSelection {
case none
case single(NSRange)
case multiple(Set<NSRange>) // 用于双向文本
}
获取选中的文本:
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]
}
}
var text = AttributedString("这是我的狗")
var selection = AttributedTextSelection(range: text.range(of: "狗")!)
// 用纯文本替换
text.replaceSelection(&selection, withCharacters: "猫")
// 用带属性的内容替换
let replacement = AttributedString("马")
text.replaceSelection(&selection, with: replacement)
使用 RangeSet 处理非连续选择:
let text = AttributedString("选择此文本的多个部分")
let range1 = text.range(of: "选择")!
let range2 = text.range(of: "文本")!
let rangeSet = RangeSet([range1, range2])
var substring = text[rangeSet] // DiscontiguousAttributedSubstring
substring.backgroundColor = .yellow
// 转换回 AttributedString
let combined = AttributedString(substring)
控制视图层次结构的选择亲和性:
TextEditor(text: $text, selection: $selection)
.textSelectionAffinity(.upstream) // .upstream 或 .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 // 配料始终为绿色
} else {
value = nil // 其他使用默认值
}
}
}
系统行为:
定义属性:
struct IngredientAttribute: CodableAttributedStringKey {
typealias Value = UUID // 配料 ID
static let name = "ingredient"
}
extension AttributeScopes.RecipeAttributeScope {
var ingredient: IngredientAttribute.Type { IngredientAttribute.self }
}
属性行为:
extension IngredientAttribute {
// 在配料后输入时不扩展
static let inheritedByAddedText = false
// 如果运行中的文本更改则移除
static let invalidationConditions: [AttributedString.InvalidationCondition] = [
.textChanged
]
// 可选:约束到段落边界
static let runBoundaries: AttributedString.RunBoundaries = .paragraph
}
安全的索引更新:
// 在突变期间转换更新索引/选择
text.transform(updating: &selection) { mutableText in
// 查找范围
let ranges = mutableText.characters.ranges(of: "黄油")
// 一次性为所有范围设置属性
for range in ranges {
mutableText[range].ingredient = ingredientID
}
}
// selection 现在已更新以匹配转换后的文本
不要使用旧的索引:
// 错误 - 索引因突变而失效
let range = text.characters.range(of: "黄油")!
text[range].foregroundColor = .green
text.append(" (无盐)") // range 现在无效!
同一内容的多个视图:
characters — 字素簇unicodeScalars — Unicode 标量utf8 — UTF-8 代码单元utf16 — UTF-16 代码单元所有视图共享相同的索引。
来自专家文章:
usageBoundsForTextContainer 在滚动期间会变化.layoutManager 会触发回退.transform(updating:) 来保持索引有效TextKit 2 中不支持:
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
每周安装量
99
代码库
GitHub 星标
610
首次出现
2026年1月21日
安全审计
安装于
opencode81
gemini-cli76
codex76
claude-code74
cursor71
github-copilot71
Complete reference for TextKit 2 covering architecture, migration from TextKit 1, Writing Tools integration, and SwiftUI TextEditor with AttributedString through iOS 26.
TextKit 2 uses MVC pattern with new classes optimized for correctness, safety, and performance.
NSTextContentManager (abstract)
NSTextContentStorage
NSTextElement (abstract)
NSTextParagraph
NSTextLayoutManager
NSTextLayoutFragment
textLineFragments — array of NSTextLineFragmentlayoutFragmentFrame — layout bounds within containerrenderingSurfaceBounds — actual drawing bounds (can exceed frame)NSTextLineFragment
NSTextViewportLayoutController
willLayout, configureRenderingSurface, didLayoutNSTextContainer
NSTextLocation (protocol)
NSTextRange
NSTextSelection
NSTextSelectionNavigation
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:
Example (Kannada word "October"):
Solution: Use NSTextLocation, NSTextRange, NSTextSelection instead of glyph indices.
Immutable objects:
Benefits:
Pattern: To change layout/selection, create new instances with desired changes.
Always Noncontiguous: TextKit 2 performs layout only for visible content + overscroll region.
TextKit 1:
TextKit 2:
Viewport Delegate Methods:
textViewportLayoutControllerWillLayout(_:) — setup before layouttextViewportLayoutController(_:configureRenderingSurfaceFor:) — per fragmenttextViewportLayoutControllerDidLayout(_:) — cleanup after layout| TextKit 1 | TextKit 2 |
|---|---|
| Glyphs | Elements |
| NSRange | NSTextLocation/NSTextRange |
| NSLayoutManager | NSTextLayoutManager |
| Glyph APIs | NO glyph APIs |
| Optional noncontiguous | Always noncontiguous |
| NSTextStorage directly | Via NSTextContentManager |
From WWDC 2022:
.offset in name → TextKit 1.location in name → TextKit 2NSRange → 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)
NO direct glyph API equivalents. Must use higher-level structures.
Example (TextKit 1 - counting lines):
// 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):
// TextKit 2 - enumerate layout fragments
var lineCount = 0
textLayoutManager.enumerateTextLayoutFragments(
from: textLayoutManager.documentRange.location,
options: [.ensuresLayout]
) { fragment in
lineCount += fragment.textLineFragments.count
return true
}
Automatic Fallback to TextKit 1: Happens when you access .layoutManager property.
Warning (WWDC 2022):
"Accessing textView.layoutManager triggers TK1 fallback"
Once fallback occurs:
Prevent Fallback:
.textLayoutManager first (TextKit 2).layoutManager in else clause// 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:
_UITextViewEnablingCompatibilityModewillSwitchToNSLayoutManagerNotificationCreate TextKit 2 NSTextView:
let textLayoutManager = NSTextLayoutManager()
let textContainer = NSTextContainer()
textLayoutManager.textContainer = textContainer
let textView = NSTextView(frame: .zero, textContainer: textContainer)
// textView.textLayoutManager now available
New Convenience Constructor:
// iOS 16+ / macOS 13+
let textView = UITextView(usingTextLayoutManager: true)
let nsTextView = NSTextView(usingTextLayoutManager: true)
Customize attributes without modifying storage:
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):
func textContentManager(
_ textContentManager: NSTextContentManager,
shouldEnumerate textElement: NSTextElement,
options: NSTextContentManager.EnumerationOptions
) -> Bool {
// Return false to hide element
if hideComments && isComment(textElement) {
return false
}
return true
}
Provide custom layout fragments:
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
)
}
Viewport layout lifecycle:
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)
}
}
Add attributes that don't modify text storage:
textLayoutManager.addRenderingAttribute(
.foregroundColor,
value: UIColor.green,
for: ingredientRange
)
// Remove when no longer needed
textLayoutManager.removeRenderingAttribute(
.foregroundColor,
for: ingredientRange
)
// 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 available in UIKit (iOS 16+), previously AppKit-only.
// 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
}
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:
// 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> and <pre> tags automatically ignored.
Advanced integration for custom text engines.
// 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
Provide context:
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:
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:
func writingToolsCoordinator(
_ coordinator: NSWritingToolsCoordinator,
updateSelectedRange selectedRange: NSRange,
in context: NSWritingToolsCoordinator.Context
) async {
// Update selection
self.selectedRange = selectedRange
}
Provide previews for animation:
// 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:
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")
}
}
Semantic rich text result option:
coordinator.writingToolsResultOptions = [.richText, .presentationIntent]
Difference from display attributes:
Display attributes (bold, italic):
PresentationIntent (header, code block, emphasis):
Example:
// Check for presentation intent
if attributedString.runs[\.presentationIntent].contains(where: { $0?.components.contains(.header(level: 1)) == true }) {
// This is a heading
}
struct RecipeEditor: View {
@State private var text: AttributedString = "Recipe text"
var body: some View {
TextEditor(text: $text)
}
}
Supported attributes:
// 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
}
Get selected 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)
Work with non-contiguous selections using 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)
Control selection affinity for the view hierarchy:
TextEditor(text: $text, selection: $selection)
.textSelectionAffinity(.upstream) // .upstream or .downstream
Use .upstream when selection should resolve toward the beginning of text at line boundaries.
Constrain which attributes are editable:
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:
TextEditor(text: $text)
.attributedTextFormattingDefinition(RecipeFormattingDefinition.self)
Control attribute values based on custom logic:
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:
Define attribute:
struct IngredientAttribute: CodableAttributedStringKey {
typealias Value = UUID // Ingredient ID
static let name = "ingredient"
}
extension AttributeScopes.RecipeAttributeScope {
var ingredient: IngredientAttribute.Type { IngredientAttribute.self }
}
Attribute behavior:
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
}
Safe index updates:
// 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:
// BAD - indices invalidated by mutation
let range = text.characters.range(of: "butter")!
text[range].foregroundColor = .green
text.append(" (unsalted)") // range is now invalid!
Multiple views into same content:
characters — grapheme clustersunicodeScalars — Unicode scalarsutf8 — UTF-8 code unitsutf16 — UTF-16 code unitsAll views share same indices.
From expert articles:
usageBoundsForTextContainer changes during scroll.layoutManager triggers fallback.transform(updating:) to keep indices validUnsupported in TextKit 2:
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, /foundation/attributedstring/textalignment, /foundation/attributedstring/lineheight, /foundation/discontiguousattributedsubstring, /uikit/writing-tools, /appkit/enhancing-your-custom-text-engine-with-writing-tools
Weekly Installs
99
Repository
GitHub Stars
610
First Seen
Jan 21, 2026
Security Audits
Gen Agent Trust HubPassSocketPassSnykPass
Installed on
opencode81
gemini-cli76
codex76
claude-code74
cursor71
github-copilot71
React 组合模式指南:Vercel 组件架构最佳实践,提升代码可维护性
120,000 周安装
edit-article AI文章编辑助手 - 智能结构化重写,提升内容清晰度与连贯性
1,300 周安装
CTF密码学挑战速查指南 | 经典/现代密码攻击、RSA/ECC/流密码实战技巧
1,400 周安装
企业级智能体运维指南:云端AI系统生命周期管理、可观测性与安全控制
1,300 周安装
Vue.js开发指南:最佳实践、组件设计与响应式编程核心原则
1,500 周安装
Gemini Live API 开发指南:实时语音视频交互、WebSockets集成与SDK使用
1,600 周安装
dbs-hook:短视频开头优化AI工具,诊断开头问题并生成优化方案,提升视频吸引力
1,700 周安装