实战:用 AttributedString 在 SwiftUI 中构建富文本编辑
Code-along: Cook up a rich text experience in SwiftUI with AttributedString
2025年6月9日
一句话判断
TextEditor 的类型从 String 换成 AttributedString 就能解锁富文本编辑——但这只是起点,真正的问题在于 AttributedString 的 Index 失效机制和 RangeSet 的双向文本语义,不了解这些就会在”修改文本后光标跳到末尾”的坑里出不来。
这场 Session 讲了什么
一个食谱编辑器 App 的完整实战,分三个阶段。
第一阶段:把 TextEditor 的状态类型从 String 改成 AttributedString,直接支持富文本编辑——加粗、斜体、自定义字体、前景色、背景色、行高、对齐、Genmoji 全部自动可用。TextEditor 里的属性和 SwiftUI 的非编辑 Text 完全一致,可以无缝转换。
第二阶段:构建自定义控件。核心是选中文本的处理——TextEditor 的 selection 属性是 AttributedTextSelection 类型,用 indices 函数得到的是 AttributedTextSelection.Indices(可能是单个 Range 也可能是 RangeSet)。为什么是 RangeSet?因为双向文本——一段英文夹希伯来文的文本,用户视觉上连续选中的内容,在 AttributedString 的存储结构里可能是多个不连续的区间。
自定义属性(IngredientAttribute)标记食材,选中”butter”按加号按钮就能把它加入配料表并标记为食材。用 ranges(of:) 找所有匹配位置,用 RangeSet 一次性设置属性避免循环。
第三阶段:定义文本格式化规则。AttributedTextFormattingDefinition 协议让你指定 TextEditor 允许哪些属性、不允许哪些。配合 AttributedTextValueConstraint 可以做更精细的控制——比如”只有标记了食材属性的文本才能是绿色”。
值得深挖的点
AttributedString Index 的失效机制是最容易踩的坑
这是整个 Session 最核心的知识点。AttributedString 底层是树结构,Index 存储的是”树的路径”而非偏移量。任何 mutation——哪怕是修改了一个无关位置的属性——都会让所有 Index 失效,包括不在修改范围内的。失效的 Index 如果继续使用,SwiftUI 会把光标强制跳到文本末尾来防止崩溃。
Session 给出的解法是 AttributedString.transform 方法。它接收一个 Range(或 RangeSet)和一个修改闭包,闭包结束后自动更新 Range 指向新的位置。SwiftUI 也提供了 transform(updating:) 变体来同时更新 AttributedTextSelection。
实操建议:如果你要修改多个不连续的区间,先合并成一个 RangeSet,然后一次性修改——避免每个区间修改后剩下的 Index 全部失效。
双向文本与 RangeSet
希伯来文和英文混排的场景是 RangeSet 存在的根本原因。用户在屏幕上连续选中一段内容,在 AttributedString 的逻辑顺序里可能是两个不连续的区间。如果你只用单个 Range 来处理选中文本,在双向文本场景下会丢失部分选中内容。
text[selection] 这个下标直接支持用 selection 切片——它内部处理了 RangeSet 到 discontiguous AttributedSubstring 的转换。
AttributedStringKey 的属性约束三件套
inheritedByAddedText:新输入的文本是否继承当前属性值。拼写检查属性应该设 false——新输入的词还没有经过检查。食材属性也应该设 false——在食材名称后面输入的文字不应该自动变成食材。
invalidationConditions:什么情况下属性应该被移除。textChanged 是最常见的条件——用户修改了食材名称中的某个字母,整个食材标记应该移除,避免”半绿色半默认色”的尴尬。
runBoundaries:属性值的边界约束。段落对齐属性应该用 .paragraph——不能让段落里的某个词用不同对齐方式。
代码片段
选中文本 -> 添加配料
struct RecipeEditor: View {
@State var text = AttributedString()
@State var selection = AttributedTextSelection()
var newIngredientSuggestion: AttributedString? {
let selected = text[selection]
guard !selected.isEmpty else { return nil }
return AttributedString(selected)
}
var body: some View {
TextEditor(text: $text, selection: $selection)
.preference(key: NewIngredientPreferenceKey.self,
value: newIngredientSuggestion)
}
}
坑:text[selection] 返回的是 AttributedSubstring(discontiguous),需要转成 AttributedString 才能在外部使用。
用 transform 安全修改文本
// 错误方式:修改后 ranges 失效
let ranges = text.ranges(of: "milk")
for range in ranges {
text[range].ingredient = IngredientAttribute(id: newID)
} // 第一次修改后,后续 range 全部失效
// 正确方式:合并为 RangeSet 一次性修改
let ranges = text.ranges(of: "milk")
let rangeSet = ranges.reduce(into: RangeSet<AttributedString.Index>()) { result, range in
result.insert(contentsOf: range)
}
text[rangeSet].ingredient = IngredientAttribute(id: newID)
// 同时更新 selection
text.transform(updating: $selection) { text in
text[rangeSet].ingredient = IngredientAttribute(id: newID)
}
坑:如果你需要同时修改 selection,用 transform(updating:);如果只修改文本不关心光标位置,用 transform。
自定义格式化定义与值约束
struct RecipeFormatting: AttributedTextFormattingDefinition {
var body: some AttributedTextFormattingDefinition<SwiftUIAttributes> {
AttributeScopes.SwiftUIAttributes.foregroundColor
AttributeScopes.SwiftUIAttributes.inlinePresentationIntent // Genmoji
IngredientAttribute.self
}
}
struct IngredientsAreGreen: AttributedTextValueConstraint {
typealias Scope = SwiftUIAttributes
typealias Key = AttributeScopes.SwiftUIAttributes.foregroundColor
static func constrain(_ value: inout AttributeScopes.SwiftUIAttributes.foregroundColor.Value?,
in context: inout ConstraintContext) {
if context.value(of: IngredientAttribute.self) != nil {
value = .green
} else {
value = nil // 使用默认颜色
}
}
}
坑:值约束会影响 TextEditor 的工具栏状态——如果约束让颜色只能是绿色,TextEditor 会自动禁用颜色选择器。
最佳实践
自定义属性的设计:如果你的属性表示某种”语义标记”(比如食材、链接、注解),应该实现 inheritedByAddedText = false 和 invalidationConditions = [.textChanged]。这确保属性不会意外扩散,修改后自动清理过期状态。
TextEditor 的 selection 状态管理:不要在 mutation 后手动管理 selection——用 transform(updating:) 让框架帮你更新 Index。手动管理在简单场景下可能 work,但双向文本和多区间选择场景一定会出问题。
格式化定义的粒度:按功能域拆分格式化定义,不要一个巨型定义管所有属性。比如”食材标记”和”文本样式”应该分开——前者控制自定义属性和颜色约束,后者控制字体和对齐。这样 TextEditor 的工具栏会更精准。
还有什么值得关注
- AttributedString 支持 CodableAttributedStringKeys,这意味着自定义属性可以跨 App 传输——两个都声明了 IngredientAttribute 的 App 之间复制粘贴能保留食材标记。
- TextEditor 对 Genmoji 的支持是自动的——只要你的格式化定义包含 inlinePresentationIntent,键盘的 Genmoji 输入就能直接用。
- AttributedString 现在有 UTF-8 和 UTF-16 视图,和底层 NSString 交互时更方便。
- SwiftUI 的 PreferenceKey 机制在这里用来把选中文本的配料建议传递给侧边栏——经典的 SwiftUI 数据流模式。