Highlight
一个食谱编辑器 App 的完整实战,分三个阶段。
核心内容
Max 在做一个食谱编辑器 App。左边是食谱列表,中间是 TextEditor,右边是配料表。痛点很具体:他想让食谱里的关键词(食材名)一眼可见,但 TextEditor 的状态类型是 String,只能存纯文本。加粗、字号、颜色、Genmoji,全部做不到。
WWDC25 给 SwiftUI 的 TextEditor 加了 AttributedString 支持。把 @Binding var text 的类型从 String 改成 AttributedString,整个编辑器立刻获得加粗、斜体、下划线、删除线、自定义字体、字号、前景色、背景色、字距、行间距、基线偏移、Genmoji、行高、对齐、书写方向。所有属性都和 SwiftUI 的非编辑态 Text 一致——TextEditor 里编辑的内容可以直接用 Text 展示出来,不用任何转换。
这个 session 是个 code-along,分三段推进:第一段把 String 升级成 AttributedString;第二段做自定义控件,让用户选中”butter”按一下加号就把它登记成食材;第三段定义自己的文本格式规则,限制 TextEditor 允许哪些属性。
详细内容
第一阶段:升级 TextEditor
起点是一个最朴素的 TextEditor(01:15)。
import SwiftUI
struct RecipeEditor: View {
@Binding var text: String
var body: some View {
TextEditor(text: $text)
}
}
关键点:
text是String,只能存纯文本。- TextEditor 默认的工具栏不会显示富文本控件。
只改一个类型(01:45)。
@Binding var text: AttributedString
关键点:
- 类型换成
AttributedString后,系统 UI 自动出现加粗、斜体等格式按钮。 - Font 和 Color 是语义属性,自动支持 Dark Mode 和 Dynamic Type。
- 键盘里的 Genmoji 直接可用。
第二阶段:构建自定义控件
目标是选中文本后通过 PreferenceKey 把建议传给 inspector。第一次尝试会编译失败(05:36)。
struct RecipeEditor: View {
@Binding var text: AttributedString
@State private var selection = AttributedTextSelection()
var body: some View {
TextEditor(text: $text, selection: $selection)
.preference(key: NewIngredientPreferenceKey.self, value: newIngredientSuggestion)
}
private var newIngredientSuggestion: IngredientSuggestion {
let name = text[selection.indices(in: text)] // build error
return IngredientSuggestion(suggestedName: AttributedString())
}
}
关键点:
selection类型是AttributedTextSelection。selection.indices(in: text)返回AttributedTextSelection.Indices,它是个枚举,第二个 case 用RangeSet存。- 直接拿这个返回值去下标
text,类型对不上,编译不过。
为什么用 RangeSet 而不是单个 Range?Jeremy 用希伯来语夹英文的例子(09:24)解释:双向文本里用户视觉上连续选中的一段,存储里可能跨越多个不连续的区间。所以 SwiftUI 选择用 RangeSet 表达 selection。
修正版直接用 selection 作下标(11:40)。
private var newIngredientSuggestion: IngredientSuggestion {
let name = text[selection]
return IngredientSuggestion(suggestedName: AttributedString(name))
}
关键点:
- SwiftUI 提供了直接用 selection 切片的 subscript。
text[selection]得到一个AttributedSubstring,可能是不连续的。AttributedString(name)把它实例化成新的 AttributedString 传给 inspector。
接着定义自定义属性 IngredientAttribute(12:32)。
struct IngredientAttribute: CodableAttributedStringKey {
typealias Value = Ingredient.ID
static let name = "SampleRecipeEditor.IngredientAttribute"
}
extension AttributeScopes {
struct CustomAttributes: AttributeScope {
let ingredient: IngredientAttribute
}
}
extension AttributeDynamicLookup {
subscript<T: AttributedStringKey>(
dynamicMember keyPath: KeyPath<AttributeScopes.CustomAttributes, T>
) -> T {
self[T.self]
}
}
关键点:
CodableAttributedStringKey让属性可以被 Codable 序列化。Value是Ingredient.ID,把文本和配料表里的条目关联起来。AttributeScope把自定义属性收编进 SwiftUI 的属性体系。AttributeDynamicLookup的 subscript 让你能直接写text[range].ingredient = id。
按下加号按钮把所有”butter”都标记为食材时,要修改 text 又不能搞乱光标(20:22)。
onApply: { ingredientId in
let ranges = RangeSet(text.characters.ranges(of: name.characters))
text.transform(updating: &selection) { text in
text[ranges].ingredient = ingredientId
}
}
关键点:
text.characters.ranges(of:)找到所有匹配位置。- 把所有 ranges 包成一个
RangeSet,一次性给 subscript 用,避免 for 循环里反复修改 text。 transform(updating:)在闭包里修改 text 的同时,让框架自动更新 selection 的 Index——直接修改 text 不更新 selection,光标会被重置。
第三阶段:定义文本格式
AttributedTextFormattingDefinition 协议声明 TextEditor 允许哪些属性(22:03)。
struct RecipeFormattingDefinition: AttributedTextFormattingDefinition {
struct Scope: AttributeScope {
let foregroundColor: AttributeScopes.SwiftUIAttributes.ForegroundColorAttribute
let adaptiveImageGlyph: AttributeScopes.SwiftUIAttributes.AdaptiveImageGlyphAttribute
let ingredient: IngredientAttribute
}
var body: some AttributedTextFormattingDefinition<Scope> {
}
}
关键点:
Scope列出 TextEditor 接受的属性集合,其它属性会被丢掉。- 通过
.attributedTextFormattingDefinition(RecipeFormattingDefinition())修饰符挂到 TextEditor 上。
再加一条值约束:“只有标记为食材的文本才能是绿色”(23:50)。
struct IngredientsAreGreen: AttributedTextValueConstraint {
typealias Scope = RecipeFormattingDefinition.Scope
typealias AttributeKey = AttributeScopes.SwiftUIAttributes.ForegroundColorAttribute
func constrain(_ container: inout Attributes) {
if container.ingredient != nil {
container.foregroundColor = .green
} else {
container.foregroundColor = nil
}
}
}
关键点:
AttributedTextValueConstraint在 SwiftUI 渲染前重写属性。constrain是inout的:检查当前 run 是否有ingredient属性,有就强制涂绿。- 把
IngredientsAreGreen()写进body,约束自动生效。
最后给自定义属性加约束(32:46)。
struct IngredientAttribute: CodableAttributedStringKey {
typealias Value = Ingredient.ID
static let name = "SampleRecipeEditor.IngredientAttribute"
static let inheritedByAddedText: Bool = false
static let invalidationConditions: Set<AttributedString.AttributeInvalidationCondition>? = [.textChanged]
}
关键点:
inheritedByAddedText = false:在已有食材标记后面输入新字符,新字符不会被标记成食材。invalidationConditions = [.textChanged]:底层文本变化时自动清掉这个属性,避免标记和实际文本对不上。- 还可以设
runBoundaries = .paragraph让属性按段落对齐。
核心启发
1. 把 String 字段升级成 AttributedString
为什么值得做:TextEditor 升级一行代码,立刻拿到加粗、字号、颜色、Genmoji、Dark Mode、Dynamic Type 全套支持。
怎么开始:找到所有 TextEditor(text: $text) 中 text 为 String 的位置,先评估改成 AttributedString 后的存储和序列化影响(AttributedString 本身是 Codable 的),再做替换。
2. 用自定义属性做”语义标记”
为什么值得做:笔记 App 的标签、富文本编辑器的链接、Markdown 渲染器的代码块、CRM 备注里的 @ 提及——这些都是”文本里嵌入对象引用”的场景,过去靠正则或字符串拼接实现,脆弱难维护。AttributedString 的自定义属性是原生方案。
怎么开始:定义 CodableAttributedStringKey,把 Value 设成你要引用的对象 ID。务必加上 inheritedByAddedText = false 和 invalidationConditions = [.textChanged],避免新输入的字符继承标记、避免文本变化后属性和文本错位。
3. 用 AttributedTextFormattingDefinition 锁定编辑器风格
为什么值得做:默认的 TextEditor 接受所有富文本属性,用户可以把字号调到 200 号、把背景换成红色。如果你的 App 要保持视觉一致性(比如笔记导出后要在邮件里展示),必须限制可用属性。
怎么开始:声明一个 AttributeScope,只列出你要支持的属性;再用 AttributedTextValueConstraint 写跨属性的约束(比如”标题字号必须 ≥18”、“代码块字体必须等宽”)。
4. 修改 AttributedString 时优先用 transform(updating:)
为什么值得做:直接修改 text 会让现有的 Range 和 selection 索引失效,光标跳动、选区错乱。手动算偏移在双向文本和 emoji 场景下几乎一定会出 bug。
怎么开始:把所有 text[range] = ... 包进 text.transform(updating: &selection) { text in ... },让框架帮你算 index 漂移。批量修改多处时,先收集成 RangeSet,再一次性赋值。
关联 Session
- Embracing Swift concurrency — Swift 并发模型的核心概念,配合 SwiftUI 状态更新理解 selection 异步行为。
- Explore Swift and Java interoperability — Swift 和 Java 在同一代码库混用的方法。
- Improve memory usage and performance with Swift — Swift 代码的性能与内存优化技巧,AttributedString 大文档场景值得参考。
- Optimize SwiftUI performance with Instruments — 新的 SwiftUI Instrument,用来诊断 TextEditor 富文本场景的渲染瓶颈。
评论
GitHub Issues · utterances