大多数浏览器和
Developer App 均支持流媒体播放。
-
探索 SwiftUI 动画
探索 SwiftUI 强大的动画功能,并了解这些功能如何协同工作以产生令人印象深刻的视觉效果。了解 SwiftUI 如何刷新视图的渲染、确定要设置动画的内容、随着时间的推移插入值以及传播当前事务的背景。
章节
- 1:03 - Anatomy of an update
- 6:40 - Animatable
- 11:36 - Animation
- 20:00 - Transaction
资源
相关视频
WWDC23
-
下载
♪ ♪
Kyle:大家好 我是 Kyle 是 SwiftUI 团队中的一员 动画是现代 App 设计的 关键组成部分 当应用得恰到好处时 它可以为你的 UI 带来清晰和生动的效果 让你的 App 添加动画变得简单 是我们在起初开发 SwiftUI 时的核心动机之一 这是 SwiftUI 为何 如此塑造的重要原因 本次讲座将概述 SwiftUI 强大的动画能力 以及它们如何协同工作 产生令人印象深刻的视觉效果 我将介绍 SwiftUI 如何刷新视图的渲染 使用可动画来确定 要进行动画处理的内容 使用动画来实现随时间进行的插值 以及使用事务 来传递当前更新的上下文
近年来 我和同事们一直 在争论哪种才是最佳的宠物伙伴 是毛茸茸的动物 还是非毛茸茸的动物 我们很好奇能否达成共识 于是我们制作了 一个 App 来进行投票调查
每个宠物都有一个投票按钮 当你轻点时 投票计数会改变 并且头像会移动 以反映当前的排名情况 在上次的投票中 毛茸茸的猫咪获得了第一名 但只是险胜 眼下的投票意义重大 不能让其仅由机缘决定 因此我要添加一个新功能 当我轻点时 我选择的头像将放大 以便引导人们给正确的宠物投票 再次轻点 它会缩小回去 这样已经很棒了 但是如果能加入动画效果会更好 在添加动画之前 我将追踪 SwiftUI 如何刷新视图的渲染 以便让你更好了解视图更新的结构 在这个练习中 我将重点聚焦在宠物头像视图 SwiftUI 跟踪视图的依赖项 比如这个选中的状态变量 当事件发生时 比如轻点 就会打开一个更新事务
如果任何依赖项 发生了变化 视图将无效 并且在事务关闭时 框架将调用 body 来生成一个新值以刷新渲染
这个视图的主体由一个轻点手势、 一个缩放效果和一张图片组成
在幕后 SwiftUI 维护着 一个长寿命周期的依赖图 用于管理视图及其数据的生命周期 图中的每个节点 称为一个属性 映射到 UI 的一个细粒度部分 当选中状态变为 true 时 这些下游属性的值就过时了 它们通过 逐层拆开新视图值来进行刷新
一旦相应图的属性已更新 视图的 body 值就会被丢弃
最后 图形会代表你 发出绘图命令来更新渲染
我仅将图放大 以便可视化属性的生命周期
属性在诞生时有一个初始值 当事件发生时 会打开一个更新事务 上游依赖项发生了变化 框架调用 body 属性的值进行更新 事务关闭 这样 图中每个属性的当前值 随着时间的推移而演变
这就是视图更新的结构 现在我将添加一个动画
如果我用 withAnimation 围绕我的状态变化进行包装 当轻点手势闭包触发时 动画会为事务设置好 然后切换选中状态 并且下游属性会失效 与之前一样 body 会被调用 以提供新的属性值
这就是有趣的地方 scaleEffect 是一个特殊的属性 称为“可动画属性” 当可动画属性的值发生变化时 它会检查是否为事务设置了动画 如果是 它会复制动画 并使用动画在旧值和新值之间 随着时间插值
我会进一步放大 scaleEffect 可动画属性 以查看它是如何运作的 首先要注意的是 可动画属性 在概念上具有模型值和展示值 目前它们是相同的 然后 一个事件触发 一个带有动画的事务开始运行 状态发生改变 body 被调用以刷新过时的属性值 由于值已改变 属性制作了一个动画的本地副本 用于计算当前的展示值
SwiftUI 知道 属性图包含运行中的动画 并会调用相应的可动画属性 以产生下一帧 对于内置的可动画属性 例如 scaleEffect SwiftUI 非常高效 它能在非主线程上工作 并且无需调用任何你的视图代码 这是动画效果的展示
好极了 当有人使用“动画”这个词时 他们可能指的是 视图随时间变化的整体视觉体验
目前我介绍的是 在 SwiftUI 中 有两个互不相关的方面 共同形成整体的视觉体验 可动画属性 例如 scaleEffect 决定了要进行动画处理的数据 而动画决定了数据随时间如何变化 我会逐一深入探讨每一个方面 从可动画开始 它决定了何时进行动画处理 在 SwiftUI 中 对于符合可动画协议的任何视图 SwiftUI 会为其 构建一个可动画属性 唯一的要求是视图定义 一个可读写的数据向量 用于进行动画处理 数据必须符合 VectorArithmetic VectorArithmetic 与你数学课本上的向量定义相匹配 它支持 向量加法和标量乘法 如果你不太熟悉向量 不要灰心 向量基本上只是 一个固定长度的数字列表 对于 SwiftUI 动画 处理向量的目的 主要是为了抽象化列表的长度 例如 CGFloat 和 Double 是一维向量 而 CGPoint 和 CGSize 定义了二维向量 CGRect 定义了四维向量
通过处理向量 SwiftUI 能够使用单个泛型实现 来对所有这些类型和 更多类型进行动画处理
到目前为止 出于简单起见 我将 scaleEffect 表示为一个一维比例因子
一维 scaleEffect 的 可动画一致性将非常直接 其 animatableData 只需是 CGFloat 实际上 scaleEffect 允许你独立配置变换操作中的 宽度、高度和相对锚点 这全部都是可动画的 所以 scaleEffect 实际上定义了一个四维向量 用于其可动画数据 这个向量由一个用于 宽度和高度缩放的 CGSize 和一个用于相对锚点的 UnitPoint 组成
AnimatablePair 将这两个向量 合并为一个较大的向量 它是一个公共类型 你也能使用它 如果你正在让自己创建的视图符合 Animatable 协议 那么它可能会派上用场
scaleEffect 只是 SwiftUI 中 内置的许多可动画视觉效果之一 所以绝大多数情况下 你不需要直接 使用 Animatable 这个 API 在极少数情况下 一个高级用例 可能需要调用你自己的视图遵循 Animatable 协议 考虑一个宠物 Podium 视图 它使用自定义的 RadialLayout 将其子视图沿圆弧分布 默认情况下 在动画中改变偏移角度 会使宠物头像沿直线移动到新位置 注意到宠物是如何走捷径 并穿过圆圈内部的吗? 那不是我想要的
相反 我想让宠物头像 沿着圆的周长进行动画 通过让 Podium 符合 Animatable 协议 并将偏移角作为其可动画数据 我可以实现这个效果
为什么会导致如此不同的效果呢?
为解释这个问题 从默认行为开始 我将逐步介绍每个版本 Podium 视图的动画更新 该行为使得头像沿直线移动
Podium 的主体由 RadialLayout 和三个头像组成 当一个事务打开时 如果偏移角发生了变化 就会调用 body 来刷新过时的下游属性值 然后运行布局 更新每个子视图的位置 这就是默认版本的 动画更新的样子 激活的可动画数据 是视图位置 CGPoint 它在笛卡尔坐标空间中进行插值 这意味着每个头像沿着直线移动 在自定义版本中 当我让 Podium 符合 Animatable 协议时 变化的是 body 成为激活的可动画属性 并将偏移角度 作为其可动画数据 这如何导致每个头像 都沿着弧线移动?
在这个自定义版本中 对于动画的每一帧 SwiftUI 都会用一个 新的偏移角度来调用 body 并重新运行布局
这非常强大 有时候 在动画自定义布局或绘制代码时 这可能是实现所需效果的唯一方式 但要记住 自定义可动画一致性的动画效果 可能比内置的动画效果更加昂贵 因为它会在动画的 每一帧都运行 body 方法 因此 只有在无法使用内置效果 实现所需效果时 才使用这个工具 接下来 我将介绍动画 这是一种在一段时间内 插值可动画数据的泛化的算法
之前 我通过将状态变化包装在 withAnimation 中 为宠物头像视图添加了动画
你可以通过传递 显式动画来自定义这个过程 例如弹簧效果
SwiftUI 内置了 许多强大的动画效果 大致可以分为三类: 时间曲线动画、弹簧动画 以及修改基础动画的高阶动画
时间曲线动画 可能是你最熟悉的一类动画 例如 渐进减速就是 一种时间曲线动画
所有时间曲线动画 都需要指定一个曲线 用于定义动画的速度 以及一个持续时间
时间曲线能使用贝塞尔控制点创建 通过调整起始和结束控制点 可以改变动画的初始和最终速度
UnitCurve 类型可独立使用 用于计算在 0 和 1 之间的 相对点上的数值和速度
SwiftUI 提供了 一些内置的时间曲线预设: 线性、 渐进、 减速、 渐进减速
所有时间曲线动画 还可以指定自定义持续时间
下一类动画是弹簧动画 它通过运行弹簧模拟 来确定给定时间点的值
你可能熟悉传统的弹簧参数化方式 例如质量、刚度和阻尼 但我们发现这些方式并不特别直观 所以我们创造了一种新的方式 你只要指定动画的感知持续时间 以及弹簧的弹性程度 这样更加直观
和 UnitCurve 一样 Spring 类型也能单独使用 在给定时间点计算弹簧的值和速度
SwiftUI 内置了三种弹簧预设: 平滑 没有弹跳; 跳跃 轻微弹跳; 弹力 较多弹跳 如果你不确定如何参数化弹簧动画 这些预设是可靠的选择 可以获得良好的效果
每个预设还可以进行调整 以更改持续时间或调整弹性
我们强烈推荐使用弹簧动画 因为弹簧动画会保留 物体的速度和动能 使用弹簧动画可以让 你的 UI 更加真实和自然 事实上 我们可以 强烈感受到弹簧动画的优势 所以在 iOS 17 和相关版本中 当你使用裸 withAnimation 时 我们将弹簧光顺作为新的默认值
最后一类动画是 修改基础动画的高阶动画 可以放慢或加速 并能在基础动画开始之前添加延迟 它们还可以重复基础动画任意次数 并在正向播放和反向播放之间切换
现在我们引入了全新的动画类别: 自定义动画 CustomAnimation 协议 使你可以访问我们用于实现 SwiftUI 中所有内置动画的 相同低级泛化的入口点 CustomAnimation 协议有三个要求: animate、shouldMerge 和 velocity
我将开始着重讲解 animate shouldMerge 和 velocity 是可选要求 稍后我会回过头来讨论它们
animate 方法接收 用于动画的目标向量、 自动画开始以来所经过的时间以及 包含其他动画状态的上下文信息 Animate 返回当前动画的值 如果动画已经结束 则返回 nil 这个值向量是从哪里来的呢? 它来自于视图的可动画数据 在宠物头像视图中 就是 scaleEffect 回想一下 scaleEffect 的 可动画数据是一个四维向量 包含二维的宽度和高度比例 当选择宠物头像时 它从 1 x 1 的比例动画 到 1.5 x 1.5 的比例 向量加法和标量乘法操作使得 SwiftUI 可以相互减去这两个向量 从而计算它们之间的差值
这个差值实际上 就是被动画处理的内容
这意味着在实践中 scaleEffect 可动画属性中的 动画并非从 1 到 1.5 进行插值 而是从 0 到 0.5 除此之外 这使得实现 animate 方法变得更加方便 让我向你展示一下
我将通过持续时间进行插值 实现一个线性时间曲线动画
回想一下 animate 方法 接收用于动画的目标向量 我可以使用标量乘法 将向量按经过的持续时间进行缩放 一旦全部持续时间经过 我将返回 nil 表示动画已完成并可以移除 就是这样 因为这个实现是泛化的 它适用于任意维度的可动画数据 这就是可动画和动画如何协同工作 在你的 UI 中 产生令人印象深刻的视觉效果
接下来 我将回到 CustomAnimation 的两个可选需求: shouldMerge 和 velocity 它们的用途是什么?
想象一下 你是 scaleEffect 可动画属性 用户轻点一下 一个事务打开了 你的值发生了变化 你制作了动画的本地副本 然后你开始愉快地 对你的增量向量进行动画 一切都进行得很顺利 直到用户在动画完成之前再次轻点 你该怎么办? 你设置了一个新的动画 并在其中调用 shouldMerge
默认实现会返回 false 这是时间曲线动画的做法 在这种情况下两个动画会一起运行 它们的结果将被系统合并 这是 SwiftUI 动画 使用增量向量的另一个原因 它使得在多个动画同时运行时 可以轻松计算正确的合并展示值 但如果我选择了一个弹簧动画 而不是时间曲线动画呢? 弹簧动画会结合之前动画的状态 重写 shouldMerge 返回 true 这使得它们保留了 速度并重新定位到新的值 这比增量组合更加自然 比如时间曲线动画 而这就是最终的 velocity 需求所用之处 实现它允许在正在运行的动画 与新动画合并时保留速度 那么我将通过 添加 velocity 的实现 来完成我的线性时间曲线动画
在这次讲解中 我一直使用“事务”这个词 来表示 为 UI 更新执行的一组任务 在 SwiftUI 代码中 事务还指涉相关且功能强大的 数据流构造和 API 家族 你可能已经熟悉环境和偏好 这是 SwiftUI 隐式传递给 视图层次结构的字典 事务与此类似 它是 SwiftUI 使用的字典 用于隐式传递当前更新所有上下文 尤其是动画
我之前对可动画属性 如何读取当前动画的 解释有些模糊 所以我将追踪 另一个关于头像视图的动画更新 这次 我将更加详细地解释 当轻点手势闭包触发时 withAnimation 在根事务字典中设置了一个动画
然后调用 body 来更新属性值 事务字典在属性图中传播 当它到达可动画属性时 该属性检查是否设置了动画 如果设置了动画 它将创建一个副本用于驱动展示值 事务仅对特定更新相关 一旦陈旧的属性被刷新 它将被丢弃 在事务字典中 将动画流向视图层次结构 这使得许多强大的 API 可以控制动画 何时以及如何应用到你的视图
现在 宠物头像视图 只能通过轻点的方式来选择 我将把 selected State 变量 更改为一个 Binding 这样它也能通过编程方式进行选择
但是如何为视图属性动画编程更改? 我可以使用 transaction 修饰符 来访问动画 因为它在事务字典内部 随着视图层次结构传递
如果我从这个修饰符中 设置了动画 则每当调用 body 时 即使没有动画 或者在事务中 有其他动画 该属性也会覆盖动画 当它到达 scaleEffect 时 这个动画将用于 插值比例因子
这真的很酷
但是这种模式存在一个问题 无差别地覆盖 所有子视图的动画 当 SwiftUI 刷新视图时 可能会导致意外的动画效果 相反 对于这种使用情况 SwiftUI 提供了一个 animation 视图修饰符 它接受一个额外的值参数 可以更精确地 限定效果范围 它只会 在值发生变化时 将动画写入事务中
现在 连接好了之后 这个 withAnimation 就没有作用了 所以我们可以将其移除
animation 视图修饰符 也是一个强大的工具 用于在视图的 不同部分应用不同的动画
例如 宠物头像具有阴影 为了简单起见 我在之前的示例中忽略了它 当头像被选择时 阴影半径会增加 以增强其悬浮在背景上的视觉效果
经过测试 我决定要让阴影的动画 比缩放效果的动画更为柔和 为实现这一点 我可以在 scaleEffect 以及阴影之间插入另一个 animation 视图修饰符 现在 事务将选择弹性弹簧 用于缩放效果的动画
同时 使用较为缓和的弹簧光顺 用于阴影半径的动画
由于 animation 修饰符 仅在值发生变化时生效 减少了意外动画的可能性 但是 如果头像的图像恰好在与选择 操作相同的事务中发生了变化 它将会继承阴影的弹簧光顺动画 用于其内容过渡 这值得细细琢磨 这个 animation 视图修饰符 在叶组件中运行良好 其中整个子层次结构 都在你的控制之下 但对于非叶组件 其中包含任意子内容 意外动画发生的可能性更高
例如假设我希望在另一个 与宠物无关的 App 中 重新使用我的头像 通过接受任意子内容 我可以使其变得更加泛化 在这种情况下 当 selected 发生变化时 我很难保证 子内容也不会发生变化
这可能导致意外动画 哎呀 好消息是 我们有一个新版本的 animation 视图修饰符 它专门用于这种使用情况 它将动画范围限定在可动画属性上 在其 body 闭包中指定 它的工作原理如下 想象一下 事务中没有动画 当事务到达 animation 视图修饰符的属性时 将创建一个带有指定动画的副本 该副本在向下传播 但仅应用于 作用范围内的可动画属性 完成任务后 副本被丢弃 原始事务继续进行
因此 当事务到达子内容时 由于原始事务不受任何 中间 animation 视图修饰符影响 就不会发生意外动画 自 SwiftUI 首个版本以来 一组有限的事务 API 已经可用 现在我们正在引入 自定义事务键的功能 你可以利用事务字典 来隐式传播你自己的更新特定数据
如果你以前声明过自定义环境键 那么对声明自定义事务键会很熟悉 模式是创建一个符合 TransactionKey 协议的唯一类型 唯一的要求是 提供一个 defaultValue 然后在事务上 声明一个计算属性作为扩展 使用你的键 从事务字典中读取和写入 在这里 我定义了一个布尔键 用于跟踪 给定更新中的头像是否被点击 我将根据它的值决定使用哪种动画 如果头像是通过交互选择的 我将使用 更活跃的弹簧将其放大或缩小 但是如果头像是以编程方式选择的 则使用更柔和的弹簧将其放大 我可以通过 withTransaction 围绕我的状态变化进行包装 在事务字典中设置给定更新的值 这似乎并不陌生 withAnimation 只是 withTransaction 的一个薄包装器
传递给 withTransaction 的参数 是事务上的一个密钥 同时也是要设置的值
事务在 SwiftUI 的 隐式数据流构造中是独特的 因为它在每次更新结束都会被丢弃 这意味着 除非明确设置了当前更新的值 否则事务字典中的每个值 都会恢复为其键的默认值
在头像视图中 当轻点手势闭包触发时 avatarTapped 被设置为 当前更新的 true
事务还包含了 动画键的默认值 即 nil
事务沿着视图层次结构传播 直到达到事务修饰符 在这里 头像视图读取 avatarTapped 并根据其值设置适当的动画
它向下传播视图层次结构
这相当不错 但与之前一样 它可能导致意外的动画效果 为了让你更精细地控制修改事务 我们正在引入 事务修饰符的两个新变体 一个允许你 使用值参数进行范围界定 另一个允许你将范围 界定到在 body 闭包中定义的 子层次结构 这些变体与前面介绍的 作用域动画视图修饰符相对应
在本讲座中 我解释了 SwiftUI 强大的动画图元 可动画、动画和事务
为下一步作准备 我建议你查看两个相关的讲座 “使用弹簧动画”为你提供了 更多关于为什么以及如何 在你的 App 中 有效使用弹簧动画的指导 而“在 SwiftUI 中进行高级动画设计” 则介绍了构建多步动画的强大新工具 我希望这个内容能让你更好地了解 SwiftUI 动画的工作原理 并能让你更熟练地 在 App 中利用动画 谢谢 ♪ ♪
-
-
2:14 - Pet Avatar - Unanimated
struct Avatar: View { var pet: Pet @State private var selected: Bool = false var body: some View { Image(pet.type) .scaleEffect(selected ? 1.5 : 1.0) .onTapGesture { selected.toggle() } } }
-
4:13 - Pet Avatar - Animated
struct Avatar: View { var pet: Pet @State private var selected: Bool = false var body: some View { Image(pet.type) .scaleEffect(selected ? 1.5 : 1.0) .onTapGesture { withAnimation { selected.toggle() } } } }
-
11:49 - Pet Avatar - Explicit Animation
struct Avatar: View { var pet: Pet @State private var selected: Bool = false var body: some View { Image(pet.type) .scaleEffect(selected ? 1.5 : 1.0) .onTapGesture { withAnimation(.bouncy) { selected.toggle() } } } }
-
12:48 - UnitCurve Model
let curve = UnitCurve( startControlPoint: UnitPoint(x: 0.25, y: 0.1), endControlPoint: UnitPoint(x: 0.25, y: 1)) curve.value(at: 0.25) curve.velocity(at: 0.25)
-
13:56 - Spring Model
let spring = Spring(duration: 1.0, bounce: 0) spring.value(target: 1, time: 0.25) spring.velocity(target: 1, time: 0.25)
-
17:25 - MyLinearAnimation
struct MyLinearAnimation: CustomAnimation { var duration: TimeInterval func animate<V: VectorArithmetic>( value: V, time: TimeInterval, context: inout AnimationContext<V> ) -> V? { if time <= duration { value.scaled(by: time / duration) } else { nil // animation has finished } } }
-
19:50 - MyLinearAnimation with Velocity
struct MyLinearAnimation: CustomAnimation { var duration: TimeInterval func animate<V: VectorArithmetic>( value: V, time: TimeInterval, context: inout AnimationContext<V> ) -> V? { if time <= duration { value.scaled(by: time / duration) } else { nil // animation has finished } } func velocity<V: VectorArithmetic>( value: V, time: TimeInterval, context: AnimationContext<V> ) -> V? { value.scaled(by: 1.0 / duration) } }
-
22:44 - Pet Avatar - Animation Modifier
struct Avatar: View { var pet: Pet @Binding var selected: Bool var body: some View { Image(pet.type) .scaleEffect(selected ? 1.5 : 1.0) .animation(.bouncy, value: selected) .onTapGesture { selected.toggle() } } }
-
23:44 - Pet Avatar - Multiple Animation Modifiers
struct Avatar: View { var pet: Pet @Binding var selected: Bool var body: some View { Image(pet.type) .shadow(radius: selected ? 12 : 8) .animation(.smooth, value: selected) .scaleEffect(selected ? 1.5 : 1.0) .animation(.bouncy, value: selected) .onTapGesture { selected.toggle() } } }
-
25:20 - Generic Avatar - Scoped Animation Modifiers
struct Avatar<Content: View>: View { var content: Content @Binding var selected: Bool var body: some View { content .animation(.smooth) { $0.shadow(radius: selected ? 12 : 8) } .animation(.bouncy) { $0.scaleEffect(selected ? 1.5 : 1.0) } .onTapGesture { selected.toggle() } } }
-
28:45 - Pet Avatar - Transaction Modifier
struct Avatar: View { var pet: Pet @Binding var selected: Bool var body: some View { Image(pet.type) .scaleEffect(selected ? 1.5 : 1.0) .transaction(value: selected) { $0.animation = $0.avatarTapped ? .bouncy : .smooth } .onTapGesture { withTransaction(\.avatarTapped, true) { selected.toggle() } } } } private struct AvatarTappedKey: TransactionKey { static let defaultValue = false } extension Transaction { var avatarTapped: Bool { get { self[AvatarTappedKey.self] } set { self[AvatarTappedKey.self] = newValue } } }
-
28:58 - Generic Avatar - Scoped Transaction Modifier
struct Avatar<Content: View>: View { var content: Content @Binding var selected: Bool var body: some View { content .transaction { $0.animation = $0.avatarTapped ? .bouncy : .smooth } body: { $0.scaleEffect(selected ? 1.5 : 1.0) } .onTapGesture { withTransaction(\.avatarTapped, true) { selected.toggle() } } } } private struct AvatarTappedKey: TransactionKey { static let defaultValue = false } extension Transaction { var avatarTapped: Bool { get { self[AvatarTappedKey.self] } set { self[AvatarTappedKey.self] = newValue } } }
-
-
正在查找特定内容?在上方输入一个主题,就能直接跳转到相应的精彩内容。
提交你查询的内容时出现错误。请检查互联网连接,然后再试一次。