WWDC Quick Look 💓 By SwiftGGTeam
Code-along: Cook up a rich text experience in SwiftUI with AttributedString

Code-along: Cook up a rich text experience in SwiftUI with AttributedString

观看原视频

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)
    }
}

关键点:

  • textString,只能存纯文本。
  • 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。

接着定义自定义属性 IngredientAttribute12: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 序列化。
  • ValueIngredient.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 渲染前重写属性。
  • constraininout 的:检查当前 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 = falseinvalidationConditions = [.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

评论

GitHub Issues · utterances