
-
跟着视频学编程:使用 SwiftUI 和 AttributedString 精心打造富文本体验
了解如何使用 SwiftUI 的 TextEditor API 和 AttributedString 构建富文本体验。探索如何启用富文本编辑功能、构建用来操控编辑器内容的自定控件,并对提供的众多格式选项进行自定。了解 AttributedString 的高级功能,以便精心打造超棒的文本编辑体验。
章节
- 0:00 - 简介
- 1:15 - TextEditor 和 AttributedString
- 5:36 - 构建自定控件
- 22:02 - 定义文本格式
- 34:08 - 后续步骤
资源
相关视频
WWDC25
WWDC22
WWDC21
-
搜索此视频…
大家好 我叫 Max 是 SwiftUI 团队的工程师 我叫 Jeremy 是 Swift Standard Libraries 团队的工程师 我们很高兴为大家介绍 如何充分利用 SwiftUI 的强大功能 构建富文本编辑体验 还有 AttributedString 的强大功能 在 Jeremy 的帮助下 我将介绍 SwiftUI 中富文本体验的 所有重要方面: 首先我将讨论升级 TextEditor 以使用 AttributedString 支持富文本 然后我将构建自定控件 通过独特的功能改进我的编辑器 最后我将创建自己的文本格式定义 让我的编辑器和 其中的内容始终保持美观!
虽然我白天是一名工程师 但我也是…
一名致力于烹饪出 完美羊角面包的厨艺达人! 最近我一直在构建一个 小型食谱编辑器 用来记录我的每一次尝试!
编辑器的左侧是食谱列表 中间的文本编辑器用于编辑食谱文本 右侧的检查器显示食材列表 我想让食谱文本中 最重要的部分脱颖而出 这样我在烹饪时就能一目了然 今天我将通过升级这个编辑器 来支持富文本 从而实现这一目标
这是使用 SwiftUI 的 TextEditor API 实现的编辑器视图 我的文本状态类型显示为 String 因此目前它仅支持纯文本 我只需将 String 改为 AttributedString
就能显著增强视图的功能
现在我可以支持富文本了 我可以使用系统 UI 来切换粗体 并应用各种其他格式 例如增加字体大小
我现在还可以从键盘插入智绘表情
而得益于 SwiftUI 的 字体和颜色属性的语义性质 新的 TextEditor 还可以支持 深色模式和动态类型!
事实上 TextEditor 支持粗体、斜体、 下划线、删除线、自定字体、 磅值大小、前景色和背景色 字距调整、跟踪、 基线偏移、智绘表情以及… 段落样式的各个方面! 行高、文本对齐方式和基本书写方向 也可用作 SwiftUI 的 独立 AttributedString 属性
所有这些属性都与 SwiftUI 的 不可编辑的文本一致 因此你可在 TextEditor 中输入内容 稍后再用 Text 显示内容! 与 Text 一样 TextEditor 使用从环境中 计算出的默认值 来替换任何值为 nil 的 AttributedStringKeys Jeremy 我必须坦白… 到目前为止 我在摸索中 学会了使用 AttributedString 但在开始构建控件之前 我真的需要复习一下 确保我的知识足够扎实 我还需要将羊角面包面团做好 以便稍后使用 你介意在我准备面团的时候 回顾一下之前的知识吗? 没问题 Max! 在你准备羊角面包面团的同时 我将花点时间讨论一些 在使用富文本编辑器时 会派上用场的 AttributedString 基础知识 简而言之 AttributedString 包含一串字符 以及一组属性段 例如这个 AttributedString 存储的 文本是“大家好! 谁准备好开始烹饪了?” 它还包含三个属性段: 起始段仅包含字体属性 一个添加了前景色的属性段 以及仅使用字体属性的最终属性段
AttributedString 类型 与你在整个 App 中使用的 其他 Swift 类型相得益彰 它是一种值类型 就像标准库中的字符串一样 它使用 UTF-8 编码存储它的内容 AttributedString 还提供 对许多常用 Swift 协议的遵从性 例如 Equatable、Hashable、 Codable 和 Sendable Apple SDK 附带了各种预定义的属性 比如 Max 之前分享的那些 这些属性分组为不同的属性范围 AttributedString 还支持 你在 App 中定义的自定属性 以便为你的 UI 设置个性化样式
我将使用几行代码 创建前面提到的 AttributedString 首先 我创建一个 以文本开头的 AttributedString 接下来 我创建字符串“cooking” 将它的前景色设为橙色 并将它附加到文本中 然后 我用结束问号结束句子 最后 我将整个文本的字体 设置为 largeTitle 字体 现在我已准备好 在设备上的 UI 中显示它
如需进一步了解 创建 AttributedString 的基础知识 以及创建和使用 你自己的自定属性和属性范围 请观看 WWDC 2021 讲座 “Foundation 中的新功能”
Max 看起来你已经做好了面团! 准备好深入探索在食谱 App 中 使用 AttributedString 的细节了吗? 当然 Jeremy 你的建议真是 “揉”到我的心巴上了 我一直希望提供更好的控件 将我的文本编辑器 连接到 App 的其余部分 我想构建一个按钮 用于将食材添加到 右侧检查器中的列表 但无需再次手动输入食材名称 例如 我想能通过选择 食谱中已经显示的 “butter”一词 然后只需按下按钮 就可以将它标记为食材
我的检查器已经定义了一个首选项键 我可以在编辑器中 使用它来为列表添加新的食材
我传递给首选项视图修饰符的值 会在视图层次结构中弹出气泡 并且可以通过名称 “NewIngredientPreferenceKey” 被使用食谱编辑器的任何视图读取
让我在视图主体下方 为这个值定义一个计算属性
对于这个建议 我只需提供名称 作为 AttributedString 当然 我并不是想 建议编辑器中的全部文本 而是想建议当前选中的文本 就像我之前展示的“butter” TextEditor 通过 可选的选择参数传达所选内容
将它绑定到 AttributedTextSelection 类型的局部状态
现在我已经提供了 视图中需要提供的上下文 让我回到计算食材建议的属性
现在 我需要获取选中的文本
让我尝试在选中内容上 使用这个索引函数的结果 为文本添加下标
嗯 这似乎不是正确的类型
它返回了 AttributedTextSelection.Indices 我来查找一下
哦 真有意思 我只有一个选择 但 Indices 类型的第二种情况是 由一组范围表示
Jeremy 趁着我给羊角面包开酥 你能解释一下 为什么会出现这个问题吗? 哈 真有趣 想到美味的羊角面包 我也是“酥”了 不过不用担心 Max 我会解释为什么这个 API 不使用 你期望的范围类型 为了说明为什么这个 API 会使用 RangeSet 而不是单个范围 我要深入探讨 AttributedString 选择 我将讨论多个范围如何构成选择 并演示如何在代码中使用 RangeSet 你可能在 AttributedString API 或其他集合 API 中 使用过单个范围 单个范围允许你 切分 AttributedString 的一部分 并对这段内容单独执行操作 例如 AttributedString 提供了 API 允许你一次性将属性快速应用于 文本的一部分 我使用了 .range(of:) API 在我的 AttributedString 中查找 文本“cooking”的范围 接下来 我使用这个范围 通过下标操作符 切分 AttributedString 将“cooking”一词设置为橙色
但是 仅使用一个范围切分 AttributedString 在适用于所有语言的文本编辑器中 并不足以表示文本选择 例如 我可能会使用这个食谱 App 来存储我计划在假期里烹饪的 光明节甜甜圈食谱 其中包含一些希伯来文字 我的食谱说: “将 Sufganiyot 放进锅里” 说明文本使用英文 而食品的传统名称使用希伯来文 在文本编辑器中 我会一次性选择 单词“Sufganiyot”的一部分 和“in”一词 但这在 AttributedString 中 实际上是多个范围! 由于英语是一种从左到右的语言 因此编辑器在视觉上是 从左到右排列句子的 然而“Sufganiyot”的希伯来语部分 排列方向却相反 因为希伯来语是一种从右到左的语言 虽然这段文本的双向性质 会影响屏幕上的视觉布局 但 AttributedString 仍以一致的顺序存储所有文本 这种排序将我的选择分为两个范围: 单词“Sufganiyot”的开头 以及“in”一词 不包括希伯来文本的结尾 正因如此 SwiftUI 文本选择类型 使用多个范围 而不是单个范围
要进一步了解如何 将 App 本地化为双向文本 请观看 WWDC 2022 讲座 “向左语言” 以及今年的 “提升 App 的多语言体验”讲座
要支持这些类型的选择 AttributedString 支持 使用 RangeSet 进行切分 也就是 Max 之前在选择 API 中 注意到的类型 就像你可以用单个范围切分 AttributedString 一样 你还可以用 RangeSet 对它进行切分 以生成不连续的子字符串 在这个示例中 我在字符视图上 使用 .indices(where:) 函数 创建了一个 RangeSet 以查找文本中的所有大写字符 将这个切片的前景色设置为蓝色 会使所有大写字符变为蓝色 而其他字符保持不变 SwiftUI 还提供了一个等效的下标 可以直接使用选择 来切分 AttributedString 嘿 Max 如果你已经给那个 诱人的羊角面包开好酥了 我认为使用 接受选择的下标 API 应该能解决你代码中的构建错误! 让我来试一试! 我可以直接使用选择为文本添加下标
然后将这个不连续的 AttributedSubstring 转变为一个新的 AttributedString
太棒了! 现在 当我在设备上运行这段代码 并选择“butter”一词时 SwiftUI 会自动调用我的属性 newIngredientSuggestion 来计算新值 这个新值会在 我 App 的其余部分弹出气泡 然后我的检查器会自动将建议 添加在食材列表的底部 然后只需轻点一下 我就能将它提交到食材列表中! 这样的功能可以 显著提升编辑器的体验
我对这个新增功能非常满意 但基于 Jeremy 到目前教给我的知识 我想我可以更进一步! 我想更好的可视化文本中的食材! 我首先需要一个自定范围 能将一段文本标记为食材 我会在一个新文件中定义这个属性
这个属性的值 是它所引用的食材的 ID 现在 我要回到 RecipeEditor 文件中 计算 IngredientSuggestion 的属性
IngredientSuggestion 允许我 提供一个闭包作为第二个参数
当我按下加号按钮时 这个闭包将会调用 食材会添加到列表中 我将使用这个闭包来更改编辑器文本 用我的 Ingredient 属性 标记名称出现的位置 让新创建食材的 ID 传递到闭包中
接下来 我需要在文本中找到 所有出现建议食材名称的位置
为此 我可在 AttributedString 的 字符视图上调用 ranges(of:)
现在 我设置好了范围 接下来只需为每个范围 更新 Ingredient 属性的值!
在这里 我使用了 我已定义的 IngredientAttribute 的简称
我们来试一试!
我并不指望看到什么新效果 毕竟我的自定属性 没有任何关联的格式 让我选择“yeast”并按下加号按钮
等等 那是什么?! 我的光标之前是在顶部 而不是在结尾! 让我再试一次!
我选择“salt”
按下加号按钮 我的选择会跳到最后! Jeremy 我得把羊角面包面团擀开 所以我现在无法调试… 你知道为什么我的选择被重置了吗? 我们可不希望使用你的 App 的 烹饪爱好者们会有这样的体验! 你为什么不开始擀面团呢 我会深入探讨这种意想不到的行为
为了演示在这里发生了什么 以及如何解决它 我会详细介绍 AttributedString 索引 它们构成了富文本编辑器 使用的范围和文本选择
AttributedString.Index 表示 文本中的单个位置 为了支持它强大而高效的设计 AttributedString 将内容 存储在树状结构中 它的索引会存储 遍历这个树状结构的路径 由于这些索引构成 SwiftUI 中文本选择的构建块 App 中意外的选择行为 源于 AttributedString 索引 在这些树状结构中的行为方式 在处理 AttributedString 索引时 应牢记两个要点 首先 对 AttributedString 的任何更改都会使它的所有索引失效 即使是那些不在更改范围内的索引 使用过期的食材永远不会做出好菜 我可以保证 用过时索引处理 AttributedString 你只会获得同样糟糕的体验 其次 索引只能用于创建它们的 AttributedString
现在 我将探索我之前创建的示例 AttributedString 中的索引 来说明它们是如何工作的! 正如之前提到的 AttributedString 以树状结构存储它的内容 这里有一个 关于树状结构的简化示例 使用树状结构可以获得更好的效果 并避免在改变文本时 复制大量数据
AttributedString.Index 通过存储 一条贯穿树状结构的路径 来引用文本中的特定位置 这条存储路径允许 AttributedString 从索引中快速找到特定文本 但这也意味着这个索引包含 有关整个 AttributedString 树状结构的布局信息 当你更改一个 AttributedString 时 它可能会调整树状结构的布局 这会使之前记录的所有路径失效 即使这个索引的目标 仍然存在于文本中
此外 即使两个 AttributedString 具有相同的文本 和属性内容 它们的树状结构可能采用不同的布局 使得它们的索引彼此不兼容
使用索引遍历这些树状结构 来查找信息 需要在 AttributedString 的 一个视图中使用索引 虽然索引特定于 某个 AttributedString 但你可在这个 AttributedString 的 任何视图中使用索引 Foundation 提供 对于文本内容的字符 或称为字素簇、 构成每个字符的单独 Unicode 标量
以及字符串的属性段的视图支持
要进一步了解 字符视图和 Unicode 标量视图的差异 请参阅 Apple 的开发者文档 了解 Swift 字符类型
与不使用 Swift 的字符类型 的其他类字符串类型 例如 NSString 进行交互时 你可能还需要访问它的较低层级内容 AttributedString 现在还提供 文本的 UTF-8 标量 和 UTF-16 标量的视图 这两种视图仍与所有现有视图 共用相同的索引
讨论完索引和选择的详细信息 我将重新讨论 Max 在食谱 App 中遇到的问题 IngredientSuggestion 中的 onApply 闭包 会更改 AttributedString 但它不会更新选择中的索引! SwiftUI 检测到这些索引不再有效 并将选择移到文本末尾 以防止 App 崩溃 要解决这个问题 应使用 AttributedString API 在更改文本时更新索引和选择
这里有一个简化的代码示例 它与食谱 App 存在相同的问题 首先 我在文本中找到 “cooking”这个词的范围 然后我将“cooking”的范围 设置为橙色前景色 我还在字符串中插入“chef”一词 以添加更多食谱主题
更改文本会更改 AttributedString 树状结构的布局
在更改字符串后 使用 cookingRange 变量是无效的 这甚至可能导致 App 崩溃 AttributedString 提供了一个 transform 函数来实现这一操作 这个函数接受一个 Range 或一个 Range 数组 以及一个会就地更改所提供的 AttributedString 的闭包
在闭包的结尾时 transform 函数会使用新索引 更新你提供的范围 以确保你可以在生成的 AttributedString 中 正确使用这个范围 虽然在 AttributedString 中 文本可能发生了变化 但范围仍然指向 相同的语义位置 在这个示例中 就是“cooking”一词 SwiftUI 也提供了一个等效函数 会更新选择而不是范围
哇 Max 那些羊角面包 看起来太漂亮了! 如果你已准备好回到你的 App 使用这个新的 transform 函数 也有助于你的代码变得更有条理! 谢谢你! 听上去这正是我想要的! 看看我能否在代码中应用这个函数
首先 我不应该像这样循环遍历范围 在循环到最后一个范围时 文本已多次更改 而索引也已过时 通过先将 Range 转换为 RangeSet 我可以完全避免这个问题
然后我可以用它 进行切分并移除循环
这样所有内容只需一次更改 而且我不需要在每次更改后 更新剩余的范围
其次 在我要更改的范围旁边 还有一个表示光标位置的选择 我需要始终确保它 与转换后的文本相匹配 我可以在 AttributedString 上 使用 SwiftUI 的 transform(updating:) 重载 来实现这一功能
太好了 现在我的选择 会在文本更改时立即更新!
让我们看看是否有效! 我可以选择“牛奶” 它会显示在列表中 而且当我添加它时 我的选择保持不变! 为了仔细检查 现在当我按下键盘上的 Command+B 我可以看到“milk”一词变成了粗体 正如预期的那样!
现在我的食谱文本中 已经包含了所有信息 我想用一些颜色来强调食材! 所幸 TextEditor 提供了 一个相应的工具: AttributedTextFormattingDefinition 协议 自定文本格式定义完全围绕 你的文本编辑器响应的 AttributedStringKey 以及它们可能包含的值构建 我在这里已经声明了一个遵从 AttributedTextFormattingDefinition 协议的类型
默认情况下 系统使用 SwiftUIAttributes 范围 对属性的值没有约束
在食谱编辑器的范围中
我只想允许前景色、智绘表情 以及我的自定 Ingredient 属性
回到我的食谱编辑器 我可以使用 attributedTextFormattingDefinition 修饰符 将我的自定定义 传递给 SwiftUI 的 TextEditor
这一更改能让我的 TextEditor 允许任意食材、任意智绘表情 和任意前景色
任何其他属性现在 都将采用它们的默认值 请注意 你仍然可以通过 修改环境来更改 整个编辑器的默认值 基于这个选择 TextEditor 已经对系统格式 UI 进行了一些重要的更改 它不再提供用于更改对齐方式、 行高或字体属性的控件 因为相应的 AttributedStringKey 不在我的范围内 不过我仍然可以使用颜色控件 将任意颜色应用于文本 即使这些颜色不一定合理
糟糕 牛奶不见了!
我其实只想让食材高亮显示为绿色 所有其他内容均使用默认颜色 我可以使用 SwiftUI 的 AttributedTextValueConstraint 协议 来实现这种逻辑
让我回到 RecipeFormattingDefinition 文件 并声明约束
要遵从 AttributedTextValueConstraint 协议 首先指定它所属的 AttributedTextFormattingDefinition 范围 然后是我想要限制的 AttributedStringKey 在这个示例中是指前景色属性 限制属性的实际逻辑 存在于 constrain 函数中 在这个函数中 我将 AttributeKey 的值 即前景色 设置为我认为有效的值
在这个示例中 逻辑完全取决于 是否设置了 Ingredient 属性
如果设置了 那么前景色应为绿色
否则就应为 nil
这表示 TextEditor 应替换默认颜色
现在我已经定义了约束 我只需将它添加到 AttributedTextFormattingDefinition 的主体中
之后 SwiftUI 将处理所有剩余工作 TextEditor 会自动 将定义和约束应用到 文本的任何部分 然后再显示在屏幕上
所有的食材现在以绿色显示!
有趣的是 TextEditor 已经停用了它的颜色控件 尽管前景色在我的格式定义范围内 鉴于添加了 IngredientsAreGreen 约束 这一点说得通 前景色现在完全取决于 文本是否使用 Ingredient 属性进行标记 TextEditor 会自动探测 AttributedTextValueConstraints 来确定潜在更改对当前选择是否有效 例如 我可以尝试将“Milk”一词的 前景色再次设置为白色 之后运行我的 IngredientsAreGreen 约束 会将前景色变回绿色 这样 TextEditor 就明白 这不是有效的更改并会禁用控件 我的值约束也将应用于 我粘贴到编辑器中的文本 当我用 Command+C 拷贝食材 然后用 Command+V 再次粘贴时 我的自定成分属性会被保留 使用 CodableAttributedStringKeys 甚至可在不同 App 的文本编辑器间 实现这一功能 只要两个 App 都在它们的 AttributedTextFormattingDefinition 中 列出了相应的属性
这非常棒 但仍有一些需要改进的地方: 将光标放在食材“milk”的末尾 我可以删除字符或继续输入 它会像普通文本一样行为 这让人感觉它只是绿色文本 而不是特定名称的食材 为了呈现正确的效果 我希望 Ingredient 属性在 我在属性段结尾键入时不会扩展 如果我修改前景色 我希望立即重置 整个单词的前景色
Jeremy 如果我答应 稍后多给你一个羊角面包 你能帮我实现它吗? 嗯…一个可能不够 再加几个就成交 Max! 在你把羊角面包放进烤箱时 我将解释哪些 API 可能有助于解决这个问题 利用 Max 演示的格式定义约束 你可以限制每个文本编辑器 可以显示哪些属性和哪些特定值 为了帮助解决 食谱编辑器的这个新问题 AttributedStringKey 协议 提供了额外的 API 来约束在对任何 AttributedString 进行修改时 属性值应如何更改 当属性声明约束时 AttributedString 会始终确保 属性之间以及属性与文本内容 保持一致 从而避免意外状态 并实现更简洁、高效的代码 我将深入探讨几个示例来说明 何时可以将这些 API 用于你的属性 首先 我将讨论那些 值与 AttributedString 中的 其他内容融为一体的属性 例如拼写检查属性
这里有一个拼写检查属性 它通过红色虚线下划线 表示单词“ready”拼写错误 在对文本进行拼写检查后 我需要确保拼写检查属性 仍然只应用于我已经验证的文本 但是如果我继续在文本编辑器中键入 插入的文本默认会继承现有文本的 所有属性 这不是我期望的拼写检查属性行为 因此我在 AttributedStringKey 中 添加一个新属性来修正这个问题 通过在 AttributedStringKey 类型上 声明 inheritedByAddedText 属性 并将值设为“false” 任何 后续添加文本都不会继承这个属性值
现在 在将新文本添加到字符串中时 新文本将不包含拼写检查属性 因为我尚未对这些新词进行拼写检查 可惜我又发现了 这个属性的另一个问题 现在 当我在标记为拼写错误的 单词中间添加文本时 这个属性会在添加的文本下方的 红色虚线中显示一个不自然的断点 由于我的 App 还没有检查 这个单词是否错误拼写 我真正想要的是 从这个单词中移除这个属性 以避免 UI 中出现过时的信息
为此 我将向 AttributedStringKey 类型添加另一个属性: invalidationConditions 属性 这个属性声明在什么情况下 应将这个属性的一段 从文本中移除 AttributedString 提供了文本和特定属性 发生变化时的条件 且属性键可以 基于任意数量的条件失效 在这个示例中 每当属性运行的文本发生更改时 我都需要移除此属性 所以我将使用“textChanged”值
现在 在属性段的中间插入文本 将使整段中的属性失效 确保在 UI 中避免这种不一致的状态 我认为这两个 API 可能有助于保持 Ingredient 属性 在 Max 的 App 中有效! 在 Max 完成烤箱操作的同时 我将再演示一类属性: 这些属性需要 文本各部分之间的值一致 例如段落对齐属性
我可以对文本中的每个段落 应用不同的对齐方式 但是 单个单词不能使用 与段落其余部分不同的 对齐方式 要在 AttributedString 更改期间 强制执行这一要求 我在 AttributedStringKey 类型上 声明一个 runBoundaries 属性 Foundation 支持将属性段边界 限制为段落边缘 或指定字符的边缘 在这个示例中 我将这个属性定义为 限制在段落边界以内 以要求它从段落的开始到结尾 具有一致的值
现在 这种情况变得不可能了 如果我只对段落中的一个词 应用左对齐值 AttributedString 会自动 将这个属性扩展到段落的 整个范围 此外 当我枚举对齐属性时 AttributedString 会枚举 每个单独的段落 即使两个连续的段落 包含相同的属性值 其他属性段边界的行为相同: AttributedString 将值 从一个边界扩展到下一个边界 并确保枚举的属性段 在每个属性段边界上中断
哇 Max 那些羊角面包闻起来很香! 如果羊角面包都放进烤箱里了 你认为其中一些 API 是否可以 补充你的格式定义 来实现你想要的自定属性的行为? 听上去正是我需要的秘方! 羊角面包都放进烤箱了 所以我可以马上试试!
在我的自定 IngredientAttribute 中 我将实现可选的 inheritedByAddedText 要求 将值设为 false 这样 如果我在食材后面输入 它就不会扩展
其次 让我使用 textChanged 实现 invalidationConditions 这样当我删除食材中的字符时 它将不再被识别!
我们来试一试! 当我在“milk”的末尾添加“y”时 “y”不会再变为绿色 而当我删除“milk”的一个字符时 Ingredient 属性 会立即从整个单词移除 根据我的 AttributedTextFormattingDefinition 前景色属性继续 完全遵循我的自定 ingredient 属性的行为!
谢谢 Jeremy 这个 App 变得真的很棒! 别客气! 现在 关于你答应的那些羊角面包… 别担心 就快做好了 不过 你为什么不看着烤箱呢 我有点担心 Luca 会偷走它们 啊 那个 Luca!我早有耳闻 对小组件和羊角面包可是迷恋得很 交给我了 老大! 在我跟 Jeremy 去看守面包之前 让我给你一些最终提示: 你可以将我的 App 下载为示例项目 从中进一步了解 如何使用 SwiftUI 的 Transferable Wrapper 进行无损拖放或导出到 RTFD 以及通过 Swift Data 持久化 AttributedString AttributedString 是 Swift 开源 Foundation 项目的一部分 在 GitHub 上找到它的实现 为它的发展做出贡献 或者通过 Swift 论坛 与社区建立联系! 使用新的 TextEditor 在你的应用中添加 对智绘表情输入的支持变得更加简单 所以不妨现在就这样做吧! 我迫不及待想看到 大家如何使用这个 API 升级 App 中的文本编辑功能 只需一点小小的点缀 就能让整体设计更加出彩!
嗯 太美味了! 不 应该是太丰富了!
-
-
1:15 - TextEditor and String
import SwiftUI struct RecipeEditor: View { @Binding var text: String var body: some View { TextEditor(text: $text) } }
-
1:45 - TextEditor and AttributedString
import SwiftUI struct RecipeEditor: View { @Binding var text: AttributedString var body: some View { TextEditor(text: $text) } }
-
4:43 - AttributedString Basics
var text = AttributedString( "Hello 👋🏻! Who's ready to get " ) var cooking = AttributedString("cooking") cooking.foregroundColor = .orange text += cooking text += AttributedString("?") text.font = .largeTitle
-
5:36 - Build custom controls: Basics (initial attempt)
import SwiftUI 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()) } }
-
8:53 - Slicing AttributedString with a Range
var text = AttributedString( "Hello 👋🏻! Who's ready to get cooking?" ) guard let cookingRange = text.range(of: "cooking") else { fatalError("Unable to find range of cooking") } text[cookingRange].foregroundColor = .orange
-
10:50 - Slicing AttributedString with a RangeSet
var text = AttributedString( "Hello 👋🏻! Who's ready to get cooking?" ) let uppercaseRanges = text.characters .indices(where: \.isUppercase) text[uppercaseRanges].foregroundColor = .blue
-
11:40 - Build custom controls: Basics (fixed)
import SwiftUI 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] return IngredientSuggestion( suggestedName: AttributedString(name)) } }
-
12:32 - Build custom controls: Recipe attribute
import SwiftUI struct IngredientAttribute: CodableAttributedStringKey { typealias Value = Ingredient.ID static let name = "SampleRecipeEditor.IngredientAttribute" } extension AttributeScopes { /// An attribute scope for custom attributes defined by this app. struct CustomAttributes: AttributeScope { /// An attribute for marking text as a reference to an recipe's ingredient. let ingredient: IngredientAttribute } } extension AttributeDynamicLookup { /// The subscript for pulling custom attributes into the dynamic attribute lookup. /// /// This makes them available throughout the code using the name they have in the /// `AttributeScopes.CustomAttributes` scope. subscript<T: AttributedStringKey>( dynamicMember keyPath: KeyPath<AttributeScopes.CustomAttributes, T> ) -> T { self[T.self] } }
-
12:56 - Build custom controls: Modifying text (initial attempt)
import SwiftUI 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] return IngredientSuggestion( suggestedName: AttributedString(name), onApply: { ingredientId in let ranges = text.characters.ranges(of: name.characters) for range in ranges { // modifying `text` without updating `selection` is invalid and resets the cursor text[range].ingredient = ingredientId } }) } }
-
17:40 - AttributedString Character View
text.characters[index] // "👋🏻"
-
17:44 - AttributedString Unicode Scalar View
text.unicodeScalars[index] // "👋"
-
17:49 - AttributedString Runs View
text.runs[index] // "Hello 👋🏻! ..."
-
18:13 - AttributedString UTF-8 View
text.utf8[index] // "240"
-
18:17 - AttributedString UTF-16 View
text.utf16[index] // "55357"
-
18:59 - Updating Indices during AttributedString Mutations
var text = AttributedString( "Hello 👋🏻! Who's ready to get cooking?" ) guard var cookingRange = text.range(of: "cooking") else { fatalError("Unable to find range of cooking") } let originalRange = cookingRange text.transform(updating: &cookingRange) { text in text[originalRange].foregroundColor = .orange let insertionPoint = text .index(text.startIndex, offsetByCharacters: 6) text.characters .insert(contentsOf: "chef ", at: insertionPoint) } print(text[cookingRange])
-
20:22 - Build custom controls: Modifying text (fixed)
import SwiftUI 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] return IngredientSuggestion( suggestedName: AttributedString(name), onApply: { ingredientId in let ranges = RangeSet(text.characters.ranges(of: name.characters)) text.transform(updating: &selection) { text in text[ranges].ingredient = ingredientId } }) } }
-
22:03 - Define your text format: RecipeFormattingDefinition Scope
struct RecipeFormattingDefinition: AttributedTextFormattingDefinition { struct Scope: AttributeScope { let foregroundColor: AttributeScopes.SwiftUIAttributes.ForegroundColorAttribute let adaptiveImageGlyph: AttributeScopes.SwiftUIAttributes.AdaptiveImageGlyphAttribute let ingredient: IngredientAttribute } var body: some AttributedTextFormattingDefinition<Scope> { } } // pass the custom formatting definition to the TextEditor in the updated `RecipeEditor.body`: TextEditor(text: $text, selection: $selection) .preference(key: NewIngredientPreferenceKey.self, value: newIngredientSuggestion) .attributedTextFormattingDefinition(RecipeFormattingDefinition())
-
23:50 - Define your text format: AttributedTextValueConstraints
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 } } } // list the value constraint in the recipe formatting definition's body: var body: some AttributedTextFormattingDefinition<Scope> { IngredientsAreGreen() }
-
29:28 - AttributedStringKey Constraint: Inherited by Added Text
static let inheritedByAddedText = false
-
30:12 - AttributedStringKey Constraint: Invalidation Conditions
static let invalidationConditions: Set<AttributedString.AttributeInvalidationCondition>? = [.textChanged]
-
31:25 - AttributedStringKey Constraint: Run Boundaries
static let runBoundaries: AttributedString.AttributeRunBoundaries? = .paragraph
-
32:46 - Define your text format: AttributedStringKey Constraints
struct IngredientAttribute: CodableAttributedStringKey { typealias Value = Ingredient.ID static let name = "SampleRecipeEditor.IngredientAttribute" static let inheritedByAddedText: Bool = false static let invalidationConditions: Set<AttributedString.AttributeInvalidationCondition>? = [.textChanged] }
-
-
- 0:00 - 简介
本节介绍了这场讲座的目标:演示如何使用 SwiftUI 和 AttributedString 构建富文本编辑体验。主讲人 Max 将围绕三个核心主题展开介绍:升级 `TextEditor` 以支持富文本、构建自定控件以增强编辑器,以及创建自定文本格式定义以确保样式一致。
- 1:15 - TextEditor 和 AttributedString
本节重点介绍了将底层数据类型从 `String` 更改为 `AttributedString` 可升级 SwiftUI `TextEditor`,从而为富文本体验提供支持。升级之后,App 将立即获得对系统 UI 控件的支持,能够使用粗体、斜体、字体大小、颜色和智绘表情等格式选项。`TextEditor` 支持多种属性,包括段落样式。此外还快速回顾了 `AttributedString` 的基础知识,包括结构 (字符和属性运行)、值类型特性、UTF-8 编码方式及其对常见 Swift 协议的遵从性;并重点介绍了预定义属性的用法,以及定义自定属性的功能。
- 5:36 - 构建自定控件
本节介绍了如何为 `TextEditor` 构建可与应用程序其余部分交互的自定控件。示例中演示了如何添加一个按钮,以便用户将所选文本标记为“配料”;并介绍了如何使用首选项键在编辑器和 UI 的其他部分之间进行通信。此外还深入探讨了 `AttributedString` 中文本选择的复杂性,讲解了为什么 `AttributedTextSelection` 类型使用 `RangeSet` 而不是单个 `Range` 来处理双向文本和不连续的选择;重点介绍了如何借助下标 API,直接使用所选内容对 AttributedString 进行切片。最后演示了如何使用闭包来更改编辑器文本,用自定的 `Ingredient` 属性来标记所选文本的出现次数;还探讨了更改属性字符串会重置选择的问题,并介绍了 AttributedString 索引的概念,以及使用 transform 函数在更改后更新索引的重要性。
- 22:02 - 定义文本格式
本节重点介绍如何使用属性文本格式定义协议来控制 `TextEditor` 中可用的格式选项。其中说明了如何创建自定格式定义,指定编辑器应响应哪些 `AttributedStringKeys`,从而限制可用的格式设置选项;还引入了 `AttributedTextValueConstraint`,以强制执行特定的格式规则,例如确保配料始终以绿色高亮显示;进一步说明了如何使用 `AttributedStringKey` 协议约束属性值;介绍了如何利用 inheritedByAddedText` 和 `invalidationConditions` 等属性,控制属性在文本更改期间的继承和失效行为;最后探讨了如何使用 `runBoundaries` 属性,强制保持文本各部分 (如段落) 的属性值一致。
- 34:08 - 后续步骤
本节提供了一些最终提示和资源:鼓励开发者下载示例项目,探索如何实现无损拖放、RTFD 导出操作,以及如何使用 Swift Data 持久化 `AttributedString`;强调了 `AttributedString` 是 Swift 开源 Foundation 项目的一部分,并鼓励大家积极参与其中,为其发展做出贡献;还鼓励开发者使用新的 `TextEditor`,为自己的 App 添加智绘表情支持。