大多数浏览器和
Developer App 均支持流媒体播放。
-
认识 TextKit 2
认识 TextKit 2:Apple 的下一代文本引擎,经过重新设计,具有更佳的准确性、安全性和性能。发现 TextKit 2 如何帮助您为互联网受众提供更好的文本体验,通过混合文本内容和视觉内容可创建更多样性的布局,并确保流畅的滚动性能。我们将向您介绍最新的 API,深入了解一些实例,并提供 app 现代化指导。
资源
相关视频
WWDC22
WWDC21
-
下载
唐娜:嗨 我是唐娜谭 是一名 TextKit工程师 我的同事克里斯威利摩尔 稍后将会加入此视频 我们要介绍的是TextKit 2 Apple的下一代文本引擎
为了解TextKit 2的全部内容 让我们简要回顾一下最初的TextKit 我们称之为TextKit 1 TextKit 1是一个用来驾驭文本布局 和显示的文本引擎 且横跨所有Apple平台 UIKit和AppKit中的 文本控件使用了TextKit 1 来管理文本内容的 存储和控制布局
20多年前 TextKit 1首次出现在 OpenStep的系统上 多年来 它与我们一起成长和发展 从macOS 10.0 到iOS 7 到macOS 11和iOS 14
令人惊讶的是 TextKit 1 仍然在所有Apple设备上 支持如此多的基本功能 几十年来 技术设计和工程原理 发生了很大变化 由于TextKit 1最初的规则定义, 因此多年以来提供与我们的新技术 完美集成的API 变得更具挑战性 同时仍然提供高标准的性能
这就是我们构建TextKit 2的原因 TextKit 2是 Apple的下一代文本引擎 建立在前瞻性的 设计原则的集合之上 你猜怎么着? 你已经在Mac上使用TextKit 2了 在BigSur中 我们更新了整个操作系统中 在幕后使用TextKit 2 的许多文本组件 大惊喜来了: 自macOS 11以来 你一直在使用TextKit 2 现在 让我们简要回顾一下 让我们这么做的架构
TextKit 2与TextKit 1共存
就像它的前身一样 TextKit 2建立在Foundation Quartz和核心文本之上 UIKit和AppKit中的文本控件 建立在TextKit 2之上 TextKit 2还松散地保留了 其前身的MVC设计 视图部分保留在 UIKit和AppKit框架的视图对象中 虽然有我们老朋友的新版本 NSTextStorage和NSLayoutManager
除了这些新版本 还有更多新的类别和协议 加入了模型层和控制器层 其中有很多 但不要惊慌 这些新组件简单、 集中且组合起来很强大
它们使你可以更轻松地表达 你对文本的各种需求 而且不用那么担心系统如何 完成你想要的
现在我们已经获得了 系统的架构视图 让我们深入了解细节
首先 我们将讨论TextKit 2的 核心设计原则 以及这些原则将如何改变 你对自定义存储的看法 你的应用程序中的布局和文本显示
之后 克里斯将引导你完成我们创建的 TextKit 2范例应用程序 合作编写一本食谱
此应用程序使用新的TextKit 2 类别在CALayer中布局和显示文本 在这里 你将了解设计原则 如何在实践中发挥作用
最后 我们将介绍一些 重要的技术细节 用于为TextKit 2 将你的应用程序现代化
那么让我们从设计原则开始吧
TextKit 2的核心高层设计原则 是正确性、安全性和性能 我们采取了一种平衡的方法 这三个原则都很重要 所以没有优先顺序 我们将在其中讨论它们
这些高级设计原则中的每一个 都告知了系统中的特定设计更改
为了正确性 TextKit 2 将字形处理抽象化了
为了安全 TextKit 2更加关注值语义
而对于性能 TextKit 2使用基于视区的布局 和渲染
我们将从正确性开始 在这方面 我们抽象化了字形处理 以对于国际化文本 提供一致的体验 Apple设备在世界各地都有用户 因此提供正确布局非常重要 呈现和交互 所有语言和脚本的文本
我们希望每个人都能够 在他们的设备上 阅读文本并与之交互 并且一些TextKit 1API的设计 使得难以用普遍正确的方式 处理国际化文本
要理解为什么 我们首先需要了解 什么是字形 字形是一个或 若干个字符 的视觉表现
在许多西方语言中 一个字形通常代表 一个字符 但这并不总是正确的
你可以有多个字形代表一个字符 也可以相反 单个字形可以代表 多个字符
这个用于表示 多个字符的 单独字形 就称为连字
西方语言 没有太多的连字 它们通常不会影响 文本的易读性 你仍然可以在 没有连字的情况下阅读它
但并非所有语言 都是如此 像阿拉伯语 和梵文这样的书写体 使用大量连字 它们确实会影响易读性 看这个用阿拉伯文字写成的词
这是一个乌尔都语单词 意思是“瞬间” 现在花点时间 比较这两个渲染
在右边用连字 书写的完整单词 看起来与 在左边单独的字符 大不相同
该语言的母语读者 会认为左边的版本难以辨认
TextKit 1中的许多API 需要使用字形索引或范围 例如 要获取某些文本的边界矩形 你需要知道你想要的文本的字形范围
如果文本是西方语言 找出正确的字形范围还行
在这个英语例子中 很容易找到文本的 前四个字符的字形范围
现在看看卡纳达语 一种印度数百万人 使用的书写体和语言
它不仅使用了大量连字 而且字形可用各种有趣的方式 重新排序和组合
这在卡纳达语意为“十月” 在4号字符索引处有一个分割元音 所以它被分割成两个字形
然后左边是字符一和二 在两者的连字产生之前 字形间的重新排序
代表3号索引字符的字形 也被替换为连接形式
最后 它被绘制在一个字形下方 在分裂元音中 现在 如果你不明白 我刚才说的话 那完全没问题
这些是框架 应该为你处理的细节 这样你就可以专注于 构建你的应用程序
重点是 在这样的文本里 找到前四个字符 的字形范围 这是不可能的
没有单个字形范围 可以代表 那四个字
由于许多TextKit 1API 需要字形范围 使用这些API 可能会破坏 像这样复杂脚本的 布局和渲染 这就是TextKit 2 抽象化字形处理的原因 TextKit 2使用CoreText 呈现所有文本… 因此你将自动获得 复杂脚本的正确渲染
使用TextKit 2 你根本不必管理字形 相反地 你使用更高级别的对象 控制文本布局和交互
这是NSTextSelection 这些更高级别的对象之一
它包含表示文本选取的 所有必要上下文 比如它的粒度 它的亲和力 以及组成选取的 可能不相交的文本范围
NSTextSelection上的 这些属性是只读的 所以你不会修改 选取对象的实例来改变它们 相反地 你使用NSTextSelection Navigation的实例 对文本选取执行操作 接收表示结果选取的 NSTextSelection的新实例
你可以要求导航对象 为你提供选取 由点击或鼠标 在屏幕上的某个点 按下的事件所产生的 或从向前或向后导航 得到一个新的 选取结果 这使作业上 变得更容易 就像将选取对象向前 扩展一个词的范围 并得到正确的结果 考虑在从右到左的语言中的 双向文本
现在我想让你注意 关于这些新的选取API 一些有趣的事情 这个方法需要一个 NSTextLocation 这是TextKit 2中的 另一个新对象
见见NSTextLocation和NSTextRange
它们与UIKit中的UITextPosition 和UITextRange类别非常相似 除非你不需要对它们进行子类化 大多数情况下 你将会通过TextKit 2 使用默认的位置 和范围对象
使用对象而不是整数 能允许更具表现力的文件模型 因为范围是根据 彼此相对的位置定义的 HTML文件对象模型 就是一个很好的例子 由于它具有嵌套元素 因此位置需要代表 在文件中的 绝对位置 以及 可见文本中的位置 这不能用单一的 数字索引来表达
这就是正确性 接下来是安全
在这方面 我们设计的 TextKit 2更强调了值语义 能更好地与Swift和SwiftUI 等技术的目标保持一致
当我说“值语义”时 我不是在谈论值类型 我们没有把NSLayoutManager 变成一个结构体
值类型保留着其数据的唯一副本 以防止该数据发生突变 这能使你的代码更安全、更稳定 通过消除非计划中的共享 和相关的副作用 但是值类型并不是 获得这种好处的唯一方法
不可变类具有初始化后 无法更改的属性 这也可以防止他们的数据发生突变
这些类的行为与值类型相似 因此我们将它们称为具有值语义 如果要更改这些 对象之一中的数据 你必须制作一个全新的实例 来代替原来的 而TextKit 2中的 许多类别 都是这样设计的
为了说明这种设计更改的好处 让我们重新回忆一下 TextKit 1的设计 文本从存储器 到屏幕的流向 以前是这样工作的
对文本存储的更新通知了布局管理器 然后它会生成字形 定位它们 并将它们 直接绘制到视图中
使用这种将字形 直接绘制到视图中的方法 会很难弄清楚 在何处分隔文本 以创建自定义绘图空间
为了理解我的意思 看看这个来自范例应用程序 先睹为快的截图 我在其中对食谱留下了一些评论
注意评论是如何显示在 它所指的食谱下方的 它是用这种独特的气泡形状绘制的 靛蓝色背景和白色文本
我们应该采取什么方法 在正确的位置插入注释 并使它们看起来 与文本的其余部分不同?
你可能希望通过 将配方文本分成有意义的单元 或元素来做到这点 将每个评论放在自己的元素中 并将每个评论放在 与之相关的食谱之后 同时提供如何绘制注释的说明 在TextKit 1中 现实就大不相同了 你必须担心很多细节 比如找到字形索引 确保字形 不在字位簇的中间 如果是 则调整该字形索引 更改行间距 并尽可能自定义线段的几何
这些细节 与你尝试做的事情无关 因此在TextKit 2上 我们的目标是将期望变为现实 我们改变了系统中的文本流 使这样的方法成为可能
以下是该流程在TextKit 2中的 工作方式
对文本存储的更新会通过一个 称为内容管理器的新对象
内容管理器将文本分成元素 并追踪它们的状态 当需要进行布局时 文本布局管理器会向内容管理器 询问元素
然后文本布局管理器会将元素 布置到文本容器中 并生成包含布局 和定位信息的布局片段
当需要展示时 布局片段会被移交 给协调位置的 ViewportLayoutController 以及你选取的渲染表面中 这些片段的布局 无论是视图还是图层
如你所见 此过程涉及许多新对象 值语义的重要性在此体现了出来
你通过在正确的点挂入系统 控制文本的布局和显示 并从使用值语义的对象 获取你需要的信息
要进行更改 你需要创建具有你想要的 更改的值的对象 之中的新实例 并将它们 交还给系统 系统使用了 替换对象中的值 用于布局和显示
那么现在 让我们认识这些新对象 并识别 在它们系统中 你可以接收或更换的不同的点 我们将从存储对象开始
这是NSTextElement 元素是文件的构建模块 每个元素代表内容的一部分 并包含一个范围 描述它在文件中的位置 元素具有值语义 它们的属性 包括范围 是在创建元素后不可变 且不能改变的
将文件建模为一系列元素 而不是一系列字符 给了我们更多的力量 我们获得了轻松区分 元素代表什么样的内容的能力 无论是一段文本 一个附件 或其他一些自定义类型 我们可以根据元素的类型 决定如何布置元素
现在让我们认识一下 NSTextContentManager
内容管理器知道 如何从文本内容生成元素 并追踪整个文档中 这些元素的范围 它还知道如何使用后备存储 以及如何生成新元素 以及更新的范围 当后备存储中的内容发生变化时
把内容管理器当成是 后备存储的包装器
内容管理器提供了 将原始数据转换为元素的接口
NSTextContentManager 和NSTextElement 都是抽象类型 因此 如果你需要使用 自定义文件模型 则可以对它们进行子类化 或自定义后备存储 标题和文档提供了 有关如何执行此操作的指导 但大多数情况下 你可以使用TextKit 2 提供的默认设置
认识NSTextContentStorage 和NSTextParagraph 这些是默认的内容管理器和元素类型 NSTextContentStorage是一个使用 NSTextStorage 作为后备存储的内容管理器
它知道如何将文本存储的内容 划分为段落元素 它们是NSTextParagraph的实例 NSTextContentStorage也知道 如何当文本存储中的文本发生变化时 生成更新的段落元素 这让我想到一个重点
在对底层文本存储进行更改时 你应该将更新打包在这个 performEditingTransaction方法中 这确保了 TextKit 2系统的其他部分 收到你的更改通知
你可以用内容存储代理 做一些很酷的事情 无需实现完整的 NSTextContentManager子类
在这个视频的后面 克里斯将介绍如何使用内容代理 无需修改文本存储 来更改评论字体和颜色 以及如何完全隐藏评论 所以请继续关注更多细节
好的 现在我们了解了TextKit 2 如何从你的文本内容中创建元素 这处理了我们新方法的 前两个步骤
内容存储自动将文本 分成段落元素 它还知道 如何为新评论创建新段落
接下来 让我们弄清楚如何实现 最后的两个步骤: 评论的定位和显示 回到我们的流程图 我们需要获取 评论元素的布局信息 有新的布局对象可以帮助我们 完成这些任务 现在来看看它们吧
见识一下NSTextLayoutManager 文本布局管理器控制着 文本布局的过程 NSTextLayoutManager类似于来自 TextKit 1的旧NSLayoutManager 但有一个主要区别: NSTextLayoutManager不会处理字形
相反地 NSTextLayoutManager获取文本元素 将它们布置到文本容器中 并为这些元素生成布局片段
你可以使用布局片段来获取 文本元素的布局信息 那么现在让我们了解一下布局片段
认识NSTextLayoutFragment 布局片段包含一个或多个 文本元素的布局信息 就像元素一样 它们使用值语义 并且它们的属性是不可变的
所以文本布局管理器 将为我们的每个评论元素 创建布局片段 然后我们可以使用 来自布局片段的信息 来定位和显示它们
布局片段通过三个属性 传达布局信息: 一个textLineFragments数组 layoutFragmentFrame 和renderingSurfaceBounds
如果你想自定义或更改布局 通过这每一个属性了解你获得的信息 至关重要 所以我们接下来会讨论这个
对于第一个属性 我们将遇到NSTextLineFragment 行列片段包含了布局片段中 每一行文本的测量信息
这些对于 获取几何信息很有用 用于特定行 或计算布局片段中的行数
第二个属性 布局片段框架 描述布局片段中的文本如何 布局在文本容器区域内 在TextKit 2中 文字布局基本上是在容器内 把布局片段框架堆叠起来的 把这些框架想象成瓷砖 系统正在把文本容器区域 划分成瓦片 其中每个布局片段 是一个单一的瓷砖
空行有自己的布局片段框架 如图所示 一般来说 布局片段框架 可用于在UI中的片段内容附近 定位其他视图 或用于计算文本内容的总高度
现在 这个框架 不能准确地代表 绘制文本 本身所需的空间 该信息来自 第三个属性 渲染表面边界 描述了绘制文本 所需的区域 这是你所要 使用的矩形 获取视图坐标空间中 文本的大小 这与布局片段的 框架不同 因为文本可能会 超出片段框架的边缘 变音符号会发生这种情况 或者 如此处所示 带有斜体字体的 下缘钩 注意J的左下边缘如何 从布局片段框架中 伸出一点点 它并没有那么突出 所以这里有一个更极端的例子
某些字体 如Zapfino 具有延伸很远的字形 超出排版范围 渲染表面 边界会更大 在这种情况下 而不是布局片段框架
现在我们了解了布局片段 提供的布局信息 让我们退后一点 谈谈如何使用这些信息 来自定义文本元素的布局
由于布局片段是不可变的 你不能直接在一个片段上 改变布局信息
回到我们的流程图 我们需要钩入布局过程 并创建NSTextLayoutFragment的 新实例 使用我们想要更改的信息
然后你使用这个代理方法 在NSTextLayoutManager上 连接到布局过程 当文本布局管理器 在布局过程中调用此方法 正在从元素 生成布局片段 在这里你有机会 对于一个元素 创建自己的布局片段
这完成了最后两个步骤 在我们解决评论问题的方法中 我们通过使用 NSTextLayoutFragment的子类 处理定位和自定义绘图 我们的评论布局片段 并在文本布局管理器代理中 提供我们自定义片段的实例
在这个视频的后面 克里斯将在我们的范例应用程序中 演示这是如何完成的
这就是安全 现在让我们继续讨论性能
性能是任何文本引擎 面临的最大挑战之一 TextKit 2对于非常广泛的场景 来说非常快 从快速渲染每个只有几行的标签 到布置数百兆字节的文档 以交互速率滚动浏览 对于这些场景 当你滚动浏览 这些非常大的文档时 以可变速率 和不连续的文本布局 出色的性能是绝对是必不可少的
让我们回顾一下 连续和非连续布局之间的区别
此图显示了一个文件 其中黄色矩形 表示屏幕上的 可见内容区域
连续布局 从文档的最开头开始 并按顺序 从文本的开头到结尾
因此 如果你滚动到文档中间某个点 连续布局会对于在那之前 出现的所有文本 执行布局
这包括了 在屏幕上滚动的所有文本 一路回到了开头 如果有很多文本 性能可能会很慢 滚动时可能会出现动画卡顿 在最坏的情况下 它可能会挂起
相反地 非连续布局意味着 我们可以在文档中的 任何地方布置一段文本 而不布置它之前的碎片
现在 当你滚动到文档中间时 该可见区域会立即进行布局
这通过了 仅对在屏幕上可见的文本部分 执行布局来提高性能 加上一个额外的过度滚动区域 从而获得更流畅的滚动体验 TextKit 2中的布局总是不连续的
相比之下 非连续布局在 TextKit 1中是可选的
它是通过NSLayoutManager上的 布尔属性启用的 这个API很简单 但是因为很简单 所以无法表达布局的状态信息 在你请求布局信息时
非连续布局依赖于 在文件的其他部分 被布局好之后 可能会改变的估计 在TextKit 1上 你只能打开或关闭非连续布局 无法控制 文档的哪些部分被布局 并且无法知道布局何时完成 以及布局估计值被更新为实际值
TextKit 2API更丰富、更具表现力
TextKit 2为可见内容区域中的元素 提供一致的布局信息 并在该可见区域的 布局更新时通知你
该区域称为视区 你可以在视区布局之前 期间、和之后 通过调整或重新定位 来管理视区并获得回叫函数
为了获得最佳性能 你的代码应该关注 处理视区 区域内的布局信息 尽可能避免为视区外的元素 请求布局信息
视区外元素的 布局信息 可能不太准确 除非你明确询问 以确保与这些元素对应的 文本范围的布局 此调用可能很昂贵 尤其是对于大型文档
回顾我们之前的流程图 还有另一个新的控制器类别 帮助我们管理视区
认识 NSTextViewportLayoutController 这是视区布局信息的真实来源 它与文本布局管理器对话 以获取布局片段 对于视区区域内的元素 你可以通过文本布局管理器上的属性 访问视区布局控制器
现在我们已经遇到了视区布局控制器 我们来谈谈如何参与 进入视区布局过程中
视区布局控制器对其代理 在视区布局过程中 调用了三个重要的方法: TextViewportLayoutController WillLayout、textViewportController configureRenderingSurface FortextLayoutFragment 和textViewportLayoutController DidLayout
首先 视区布局控制器在视区中 布置元素之前 调用了willLayout方法 在这里你可以进行 任何设置工作以准备布局 例如清除视图 或图层的内容
接下来 视区布局控制器会调用 configureRenderingSurface 对于在视区中可见的每个布局片段 你可以在此处更新 每个片段视图或图层的几何形状
最后 视区布局控制器 布局完成后调用 didLayout方法 在视区中可见的 所有布局片段
在这里你可以 在视区布局完成后 执行任何 需要的更新 就像你想 调整视区一样 使最后一个元素 在屏幕上完全可见 这就是对性能的总结 现在我将把它交给克里斯 来向你展示 如何在实践中使用TextKit 2 谢谢唐娜 我们编写了一个范例应用程序 演示了一些不同的方式 让你可以使用TextKit 2 来布置应用程序中的文本并与之交互 你可以下载此视频中使用的范例代码 让我们打开它并尝试一下 我们正在使用此协作应用程序 来查看食谱书 这样我们就可以弄清楚 我们午餐想做什么 滚动浏览食谱如预期般地工作 但在幕后却发生了一些特别的事情: 仅有视区中可见的段落 被绘制出来 而不是每个段落 都被渲染 在同一个大平面上 每个段落都被渲染到自己的图层中
如果我单击此处工具栏中的 “显示边界”按钮 这些彩色矩形会出现 橙色矩形会显示每层的边界 将文本绘制到 单独的图层中 让我们实现一个有趣的功能: 我可以对食谱发表评论 现在 我觉得鸡蛋三明治听起来不错 所以我要双击这一段 然后输入“嘿 这听起来不错” 然后按回车键 插入评论
我刚刚往文档里 插入了 一个新段落 气泡背景则是 通过 NSTextLayoutFragment的 自定义子类所绘制 称为BubbleLayoutFragment 我们稍后会再谈
特别的是 当我往文档 插入评论时 所有在评论 下方的段落 都会移动以腾出空间 如果你第一次 没有看到 我将点击工具栏中的这个乌龟按钮 来启用慢速模式
让我们添加另一个评论 “是啊 今天就去吃午饭吧” 按回车后 评论就被添加到 它下面的文件 而且它下面的所有段落里 都在缓慢地跑着动画 如果你想 隐藏所有评论 你可以单击 在工具栏中的 “切换评论”按钮 这实际上并不是在 编辑底层文档 而是询问 文本内容管理器 在为了布局 枚举文本元素时 跳过注解
TextKit 2在iOS上 和在macOS 上一样好用 这意味着macOS应用程序的TextKit 2 部分可以在iOS上重复使用 让我们在iPad上运行它
我们已经使用这些部分编写了 我们协作应用的iOS 版本 具有所有相同的功能 我长按一个段落发表评论 然后输入“嘿 听起来不错” 并按回车
就像macOS上的应用程序一样 我可以点击评论 显示/隐藏按钮 隐藏所有评论
我刚刚浏览了一个应用程序 它使用了TextKit 2的 布局、绘制和与文本交互 现在让我们回顾一下 范例应用程序中的一些代码 以及TextKit 2如何使它成为可能
该应用程序演示了 TextKit 2提供的许多功能 但我现在想专注于两个领域: 它是如何使用 NSViewportLayoutController 在视区中布置文本 以及它如何实现自定义 隐藏行为和评论的呈现
当文本布局管理器即将布局文档时 或者因为它发生了变化 容器大小已更改 或文档中以前未见过的部分 已移入视区 它会调用 textViewportLayout ControllerWillLayout 在其视区布局代理上 我们在这里使用它 来清除所有文本子层 并开启一个动画交易
对于文本布局管理器 所布局的 每个文本元素 它会调用 textViewportLayoutController configureRenderingSurfaceFor textLayoutFragment 在这里 我们要 显示一个图层 文本布局片段 更新其几何形状 将其动画化 到它的新位置 如果可能 并将它添加 作为视图的子层
当布局管理器完成布局后 它会调用textViewportLayout ControllerDidLayout 我们提交动画事务 更新选取亮点 并更新内容大小 以便正确放置滚动拇指
现在 让我们来谈谈评论 TextKit 2提供了几个 可用于自定义布局元素的钩子 和布局片段生成 我将向你展示 我们如何在文档中接受评论 为显示设置自定义属性 如字体和颜色 并在它们后面绘制气泡
对于文档中的每个段落 文本内容存储为其代理提供了机会 来自定义该段落的属性 在我们的实现中 我们在评论上设置自定义字体和颜色 无需更改底层文本 存储的字体或颜色
文本内容管理器还让其代理人 在布局期间有机会决定 文本布局管理器将显示哪些文本元素
文本元素错误的返回 会阻止其被显示 在这里 我们通过 选取不枚举来隐藏评论 无需从底层文本存储中 实际删除它们
文本布局管理器也有一个代理 通过在textElement中 实现textLayoutManager textLayoutFragmentForlocation 代理可以生成自定义文本布局片段 而不是默认 给定NSTextElement的 NSTextLayoutFragment实例 在这种情况下 当它遇到一个 代表一条评论的 NSTextElement 它会创建一个 BubbleLayoutFragment 这是NSTextLayoutFragment的 自定义子类
BubbleLayoutFragment会覆盖 NSTextLayoutFragment的绘制方法 好在调用 顶部绘制文本的 基类实现之前 绘制背景气泡 请注意 目前正在通过 我们之前设置的 自定义字体 和文本颜色 来呈现文本
我讲解了范例应用程序 如何使用TextKit 2 执行基于视区的文本动画布局 以及它如何在这些彩色气泡中 呈现评论 一路从自定义属性 到在文本存储中 自定义绘图 但是范例代码中还有更多 利用TextKit 2提供的新API 包括诠释鼠标事件 以确定文本选取 渲染文本选取突出显示 在文件中的特定段落 放置评论弹出框 以及估计文档高度等 你可以在范例代码中找到 所有这些主题的进一步讨论 让我们交回给唐娜 来谈谈为TextKit 2 准备你的应用程序 谢谢 克里斯 这是TextKit 2 如何实际应用的一个好例子 既然我们已经讨论了 TextKit 2可以做什么 让我们讨论一些 应用程序现代化的方法
到目前为止我们讨论的所有内容 都适用于创建你自己的 TextKit 2堆栈 与通用视图或图层一起使用 所有新类别都可以在iOS 15的UIKit 和macOS 12的AppKit中使用 因此 如果你想走这条路 今天就可以开始使用 TextKit 2编写新代码
另一方面 许多应用程序使用 内置的文本控件 如文本视图 充分利用所有出色的免费功能 例如辅助功能支持 以及选取和编辑服务 其中一些控件已为使用TextKit 2 而经过更新 如果你的应用程序使用内置控件 还有一些额外的细节 需要注意 保持兼容性对我们 和对你都很重要 由于TextKit 1是内置文本控件 不可或缺的一部分 我们将竭尽全力维护 使用它们的应用程序的兼容性 这就是为什么在iOS 15 和macOS 12中 只有一些控件自动使用 TextKit 2的原因
此外 某些控件需要采取额外的步骤 才能在这些操作系统版本中 使用TextKit 2 对于AppKit开发者 NSTextView 不会自动使用TextKit 2
如果要将TextKit 2与NSTextView 一起使用 则需要在创建时 以编程方式选取加入
做法如下: 首先 创建一个文本布局管理器 接下来 创建一个文本容器 然后使用 NSTextLayoutManager上的 textContainer属性 将文本容器 与文本布局管理器关联起来 最后 使用指定的初始化程序 创建你的NSTextView 与文本容器 现在你将拥有一个使用 TextKit 2的文本视图
你可以通过NSTextView上新的属性 访问文本布局管理器 和文本内容存储 只有一件事要小心
回想一下NSTextView 有个layoutManager属性 它允许获取和设置它的 NSLayoutManager
NSLayoutManager 是个TextKit 1对象 它与TextKit 2堆栈不兼容
文本视图不能同时拥有 布局管理器和文本布局管理器
所以就这样吧 我们为NSTextView 在需要将其切换 到TextKit 1的时候 添加了 特殊的兼容模式 文本视图 可以自动检测 是否需要 使用该模式 并用NSLayoutManager 来替换 NSTextLayoutManager 为了获得最佳性能 文本视图将从那时起 保留在 兼容模式下
即使你选取使用TextKit 2 你的文本视图也会自动切换 到TextKit 1 如果你在你的文本视图或文本容器上 明显地调用layoutManager 属性的话
如果遇到尚不支持的文本内容 或侦测到需要TextKit 1的其他条件 文本视图也会切换
这也可能发生在场域编辑器身上 NSTextField的字段编辑器 默认使用TextKit 2 但是如果你的文本字段子类 从字段编辑器的布局管理器 请求布局信息 字段编辑器将会 为了该窗口中的所有文本字段 切换到TextKit 1
系统会在文本视图切换到 TextKit 1 之前和之后发出通知 你可以观察这些通知 以接收此信息
通知对象包含对更改模式的 确切文本视图的引用
欲知完整详情 有关在AppKit的 TextKit 1兼容模式 请参考在Apple 开发者门户上的文档
对于UIKit开发人员 UITextField在iOS 15中 自动使用TextKit 2
带有TextKit 2的 UITextView在iOS 15中则不可用
我们正在努力确保 所有使用UITextView的 应用程序的最大兼容性 并且它们的数量相当多 同时 你可以查看现有的代码 来使用UITextView的 layoutManager属性 并考虑如何使用 TextKit 2表达你的意图 这样一来 一旦它可用了 你就可以准备过渡
本环节到此结束 现在你已经认识了TextKit 2 Apple的新文本引擎 带我们进入了未来 我们期待看到你使用 TextKit 2构建的内容 谢谢观看 [轻快的音乐]
-
-
32:22 - Responding to layout updates: textViewportLayoutControllerWillLayout()
func textViewportLayoutControllerWillLayout(_ controller: NSTextViewportLayoutController) { contentLayer.sublayers = nil CATransaction.begin() }
-
32:47 - Responding to layout updates: textViewportLayoutController(_:configureRenderingSurfaceFor:)
private func animate(_ layer: CALayer, from source: CGPoint, to destination: CGPoint) { let animation = CABasicAnimation(keyPath: "position") animation.fromValue = source animation.toValue = destination animation.duration = slowAnimations ? 2.0 : 0.3 layer.add(animation, forKey: nil) } private func findOrCreateLayer(_ textLayoutFragment: NSTextLayoutFragment) -> (TextLayoutFragmentLayer, Bool) { if let layer = fragmentLayerMap.object(forKey: textLayoutFragment) as? TextLayoutFragmentLayer { return (layer, false) } else { let layer = TextLayoutFragmentLayer(layoutFragment: textLayoutFragment, padding: padding) fragmentLayerMap.setObject(layer, forKey: textLayoutFragment) return (layer, true) } } func textViewportLayoutController(_ controller: NSTextViewportLayoutController, configureRenderingSurfaceFor textLayoutFragment: NSTextLayoutFragment) { let (layer, layerIsNew) = findOrCreateLayer(textLayoutFragment) if !layerIsNew { let oldPosition = layer.position let oldBounds = layer.bounds layer.updateGeometry() if oldBounds != layer.bounds { layer.setNeedsDisplay() } if oldPosition != layer.position { animate(layer, from: oldPosition, to: layer.position) } } if layer.showLayerFrames != showLayerFrames { layer.showLayerFrames = showLayerFrames layer.setNeedsDisplay() } contentLayer.addSublayer(layer) }
-
33:10 - Responding to layout updates: textViewportLayoutControllerDidLayout()
func textViewportLayoutControllerDidLayout(_ controller: NSTextViewportLayoutController) { CATransaction.commit() updateSelectionHighlights() updateContentSizeIfNeeded() adjustViewportOffsetIfNeeded() }
-
33:47 - Overriding text attributes for comments
func textContentStorage(_ textContentStorage: NSTextContentStorage, textParagraphWith range: NSRange) -> NSTextParagraph? { // In this method, we'll inject some attributes for display, without modifying the text storage directly. var paragraphWithDisplayAttributes: NSTextParagraph? = nil // First, get a copy of the paragraph from the original text storage. let originalText = textContentStorage.textStorage!.attributedSubstring(from: range) if originalText.attribute(.commentDepth, at: 0, effectiveRange: nil) != nil { // Use white colored text to make our comments visible against the bright background. let displayAttributes: [NSAttributedString.Key: AnyObject] = [.font: commentFont, .foregroundColor: commentColor] let textWithDisplayAttributes = NSMutableAttributedString(attributedString: originalText) // Use the display attributes for the text of the comment itself, without the reaction. // The last character is the newline, second to last is the attachment character for the reaction. let rangeForDisplayAttributes = NSRange(location: 0, length: textWithDisplayAttributes.length - 2) textWithDisplayAttributes.addAttributes(displayAttributes, range: rangeForDisplayAttributes) // Create our new paragraph with our display attributes. paragraphWithDisplayAttributes = NSTextParagraph(attributedString: textWithDisplayAttributes) } else { return nil } // If the original paragraph wasn't a comment, this return value will be nil. // The text content storage will use the original paragraph in this case. return paragraphWithDisplayAttributes }
-
34:06 - Hiding comments
func textContentManager(_ textContentManager: NSTextContentManager, shouldEnumerate textElement: NSTextElement, with options: NSTextElementProviderEnumerationOptions) -> Bool { // The text content manager calls this method to determine whether each text element should be enumerated for layout. // To hide comments, tell the text content manager not to enumerate this element if it's a comment. if !showComments { if let paragraph = textElement as? NSTextParagraph { let commentDepthValue = paragraph.attributedString.attribute(.commentDepth, at: 0, effectiveRange: nil) if commentDepthValue != nil { return false } } } return true }
-
34:28 - Generating special layout fragments for comments
func textLayoutManager(_ textLayoutManager: NSTextLayoutManager, textLayoutFragmentFor location: NSTextLocation, in textElement: NSTextElement) -> NSTextLayoutFragment { let index = textLayoutManager.offset(from: textLayoutManager.documentRange.location, to: location) // swiftlint:disable force_cast let commentDepthValue = textContentStorage!.textStorage!.attribute(.commentDepth, at: index, effectiveRange: nil) as! NSNumber? if commentDepthValue != nil { let layoutFragment = BubbleLayoutFragment(textElement: textElement, range: textElement.elementRange) layoutFragment.commentDepth = commentDepthValue!.uintValue return layoutFragment } else { return NSTextLayoutFragment(textElement: textElement, range: textElement.elementRange) } }
-
34:58 - Drawing the comment bubble
var commentDepth: UInt = 0 private var tightTextBounds: CGRect { var fragmentTextBounds = CGRect.null for lineFragment in textLineFragments { let lineFragmentBounds = lineFragment.typographicBounds if fragmentTextBounds.isNull { fragmentTextBounds = lineFragmentBounds } else { fragmentTextBounds = fragmentTextBounds.union(lineFragmentBounds) } } return fragmentTextBounds } // Return the bounding rect of the chat bubble, in the space of the first line fragment. private var bubbleRect: CGRect { return tightTextBounds.insetBy(dx: -3, dy: -3) } private var bubbleCornerRadius: CGFloat { return 20 } private var bubbleColor: Color { return .systemIndigo } private func createBubblePath(with ctx: CGContext) -> CGPath { let bubbleRect = self.bubbleRect let rect = min(bubbleCornerRadius, bubbleRect.size.height / 2, bubbleRect.size.width / 2) return CGPath(roundedRect: bubbleRect, cornerWidth: rect, cornerHeight: rect, transform: nil) } override var renderingSurfaceBounds: CGRect { return bubbleRect.union(super.renderingSurfaceBounds) } override func draw(at renderingOrigin: CGPoint, in ctx: CGContext) { // Draw the bubble and debug outline. ctx.saveGState() let bubblePath = createBubblePath(with: ctx) ctx.addPath(bubblePath) ctx.setFillColor(bubbleColor.cgColor) ctx.fillPath() ctx.restoreGState() // Draw the text on top. super.draw(at: renderingOrigin, in: ctx) }
-
37:26 - Opting NSTextView in to TextKit 2
var scrollView: NSScrollView! var containerSize = CGSize.zero var textContainer = NSTextContainer() // Important: Keep a reference to text storage since NSTextView weakly references it. var textContentStorage = NSTextContentStorage() override func viewDidLoad() { super.viewDidLoad() scrollView = NSScrollView(frame: NSRect(origin: CGPoint(), size: CGSize(width: view.bounds.width, height: view.bounds.height))) scrollView.translatesAutoresizingMaskIntoConstraints = false view.addSubview(scrollView) NSLayoutConstraint.activate([ scrollView.leadingAnchor.constraint(equalTo: (view.leadingAnchor)), scrollView.trailingAnchor.constraint(equalTo: (view.trailingAnchor)), scrollView.topAnchor.constraint(equalTo: (view.topAnchor)), scrollView.bottomAnchor.constraint(equalTo: (view.bottomAnchor)) ]) setUpScrollView(scrollsHorizontally: false) } func setUpScrollView(scrollsHorizontally: Bool) { scrollView.borderType = .noBorder scrollView.hasVerticalScroller = true scrollView.hasHorizontalScroller = scrollsHorizontally setUpTextContainer(scrollsHorizontally: scrollsHorizontally) setUpTextView(scrollsHorizontally: scrollsHorizontally) } func setUpTextContainer(scrollsHorizontally: Bool) { let contentSize = scrollView.contentSize if scrollsHorizontally { containerSize = NSSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude) textContainer.containerSize = containerSize textContainer.widthTracksTextView = false } else { containerSize = NSSize(width: contentSize.width, height: CGFloat.greatestFiniteMagnitude) textContainer.containerSize = containerSize textContainer.widthTracksTextView = true } } func setUpTextView(scrollsHorizontally: Bool) { let textLayoutManager = NSTextLayoutManager() textLayoutManager.textContainer = textContainer textContentStorage.addTextLayoutManager(textLayoutManager) // Workaround: Pass textLayoutManager.textContainer to the NSTextView initializer let textView = NSTextView(frame: scrollView.contentView.bounds, textContainer: textLayoutManager.textContainer) textView.isEditable = true textView.isSelectable = true textView.minSize = CGSize() textView.maxSize = containerSize textView.isVerticallyResizable = true textView.isHorizontallyResizable = scrollsHorizontally textContentStorage.performEditingTransaction { textView.textStorage?.append(NSAttributedString(string: "Text content...")) } scrollView.documentView = textView }
-
-
正在查找特定内容?在上方输入一个主题,就能直接跳转到相应的精彩内容。
提交你查询的内容时出现错误。请检查互联网连接,然后再试一次。