SwiftUI 中的 Animation 类型描述了值变化的时序曲线,控制值向目标值移动的速度以及在稳定下来之前是否会过冲。我们可以使用 [animation(_:value:)](<https://developer.apple.com/documentation/swiftui/view/animation%28_%3Avalue%3A%29>) 修饰符将其应用于特定视图,或者将状态更改包装在 withAnimation( *: :* ) 中,以便一次性为所有受影响的视图添加动画效果。
Rectangle()
.frame(width: 100, height: 100)
.scaleEffect(isExpanded ? 2 : 1)
.animation(.default, value: isExpanded)
自 iOS 17 起, 默认选项是苹果预设的弹簧动画。如果需要更精细地控制动画时序,我们可以将其替换为其他 Animation 值。本文将介绍 SwiftUI 提供的缓动和弹簧动画,以及用于实现完全自定义时序逻辑的 CustomAnimation 协议。
缓动动画使用三次贝塞尔曲线来描述其时间曲线,该曲线由四个点定义:两个固定端点分别为 (0, 0) 和 (1, 1),以及两个控制点,用于调整它们之间的曲线形状。控制点的位置决定了动画的加速和减速方式,从而赋予每个预设独特的特性。

SwiftUI 提供了四个用于缓动动画的预设,每个预设都对应一个特定的控制点配置。每个预设都以静态属性的形式提供,并带有默认持续时间;同时,它们也以静态方法的形式提供,该方法接受以秒为单位的显式 duration 参数。
线性预设将两个控制点直接放置在曲线的端点上,这意味着它们对曲线没有任何影响,从而产生恒定的斜率。动画从开始到结束以相同的速度移动,这在大多数界面中显得有些机械,但对于像持续旋转的指示器这类需要保持匀速的场景来说,却是合适的选择。

Rectangle()
.frame(width: 250, height: 250)
.rotationEffect(.degrees(isAnimating ? 360 : 0))
.animation(.linear(duration: 6), value: isAnimating)
easeIn 预设会将第一个控制点向右移动,使曲线的起始部分变平缓。动画开始时速度较慢,然后逐渐加速直至结束,这非常适合即将离开屏幕的元素。

Rectangle()
.frame(width: 250, height: 250)
.offset(x: isVisible ? 0 : 340)
.animation(.easeIn(duration: 2), value: isVisible)
easeOut 预设会将第二个控制点向左移动,使末端变平。动画快速开始,然后逐渐减速直至停止。这是元素进入或稳定到最终位置时最常用的预设。

Rectangle()
.frame(width: 250, height: 250)
.offset(x: isVisible ? 0 : -340)
.animation(.easeOut(duration: 2), value: isVisible)
easeInOut 预设会将两个控制点都向内移动,从而形成两端平缓、中间陡峭的曲线。它非常适合在屏幕上两个位置之间移动的元素,因为元素的离开和到达都需要更柔和的边缘。在 iOS 17 之前,这是 SwiftUI 的默认设置。

Rectangle()
.frame(width: 250, height: 250)
.offset(y: hasMoved ? 200 : -200)
.animation(.easeInOut(duration: 2), value: hasMoved)
当四个预设曲线无法生成我们所需的精确曲线时, [timingCurve(_:duration:)](https://developer.apple.com/documentation/swiftui/animation/timingcurve(_%3Aduration%3A)) 方法允许我们指定自定义曲线。自 iOS 17 起,该方法接受一个 UnitCurve 值,该类型表示独立于任何动画的时间曲线。
我们使用 bezier(startControlPoint:endControlPoint:) 创建自定义曲线,传入两个 UnitPoint 值来定义三次贝塞尔曲线的控制点。起始控制点决定曲线如何离开原点,结束控制点决定曲线如何到达终点。

Rectangle()
.frame(width: 250, height: 250)
.offset(y: hasMoved ? 200 : -200)
.animation(
.timingCurve(.bezier(
startControlPoint: UnitPoint(x: 0.2, y: 0.8),
endControlPoint: UnitPoint(x: 0.8, y: 0.2)
), duration: 2),
value: hasMoved
)
在 iOS 17 之前,可以使用 timingCurve(::::duration:) 实现相同的结果,它直接将四个控制点值作为原始 Double 参数接收。
UnitCurve 还自带一些命名预设,我们可以直接将它们传递给 timingCurve(_:duration:) 。除了上面这些预设之外, UnitCurve 还提供了三种圆形缓入缓出 (circularEaseIn) 、 缓出缓出 (circularEaseOut) 和缓入缓出缓出 (circularEaseInOut) 的变体。与基于贝塞尔曲线的控制点不同,这些圆形变体使用四分之一圆弧作为其基本形状。这使得加速和减速的效果比贝塞尔曲线更加灵敏,在实际应用中感觉更加迅捷。

Rectangle()
.frame(width: 250, height: 250)
.offset(y: hasMoved ? 200 : -200)
.animation(
.timingCurve(.circularEaseInOut, duration: 2),
value: hasMoved
)
与遵循固定数学曲线的缓动动画不同,弹簧动画模拟的是连接在弹簧上的物理物体。在每一帧中,SwiftUI 都会施加两个力:弹簧力将物体拉向目标位置,而阻尼力则阻碍其运动。
这些力之间的平衡决定了动画的特性。当阻尼力足够强时,物体能够平稳地到达目标位置而不会过冲。当阻尼力较弱时,物体会过冲,并在最终稳定下来之前来回摆动。SwiftUI 提供了三个预设值: smooth 、 snappy 和 bouncy ,分别对应于这个范围内的特定点。

平滑预设就像一个临界阻尼弹簧:阻尼力恰到好处,既能防止任何过冲,又能使物体以最快的速度到达目标位置,避免越界,从而实现平稳流畅的到达效果。
Rectangle()
.frame(width: 250, height: 250)
.offset(x: isVisible ? 0 : -340)
.animation(.smooth, value: isVisible)
快速预设模式的阻尼略微不足,会在稳定下来之前产生一个小的过冲。这种微妙的弹性使其听起来比 smooth 更活泼。
Rectangle()
.frame(width: 250, height: 250)
.offset(x: isVisible ? 0 : -340)
.animation(.snappy, value: isVisible)
弹性预设的阻尼较小,会产生清晰可见的过冲和短暂的振荡,然后才稳定下来。它突出了运动本身,适合营造活泼、充满活力的氛围。
Rectangle()
.frame(width: 250, height: 250)
.offset(x: isVisible ? 0 : -340)
.animation(.bouncy, value: isVisible)
SwiftUI 提供了多种自定义弹簧动画特性的方法,从调整内置预设到直接指定弹簧参数。
前面提到的三种弹簧预设都对应一个静态方法,该方法接受一个 duration 和 extraBounce 参数。duration duration 控制弹簧回弹所需的时间,而 extraBounce 则在预设的默认弹跳值基础上增加额外的弹跳力。较小的正值会使预设的弹跳力略微增强,而较大的正值则会使 bouncy 力更加强劲。
Rectangle()
.frame(width: 250, height: 250)
.offset(x: isVisible ? 0 : -340)
.animation(
.bouncy(duration: 0.5, extraBounce: 0.2),
value: isVisible
)
duration / bounce 模型或 response / dampingFraction 模型。如果无法通过内置的定时选项实现所需的动画效果,我们可以使用 iOS 17 及更高版本提供的 CustomAnimation 协议来自行实现定时逻辑。该协议包含一个必需的方法 [animate(value:time:context:)](https://developer.apple.com/documentation/swiftui/customanimation/animate(value%3Atime%3Acontext%3A)) ,SwiftUI 会在每一帧调用此方法,传入要动画化的值和自动画开始以来经过的时间,并期望返回当前时刻的插值值;如果动画结束,则返回 nil 。
以下示例使用 CustomAnimation 来实现弹弓曲线,其中视图在射向目标之前会短暂地向后拉动。
struct SlingshotAnimation: CustomAnimation {
let duration: TimeInterval = 0.6
func animate<V: VectorArithmetic>(
value: V,
time: TimeInterval,
context: inout AnimationContext<V>
) -> V? {
guard time <= duration else { return nil }
let t = time / duration
let curve = t * t * (3 - 2 * t) + sin(t * .pi) * -0.3
return value.scaled(by: curve)
}
}
extension Animation {
static var slingshot: Animation {
Animation(SlingshotAnimation())
}
}
将符合规范的类型包装在 Animation 扩展中,即可将其作为静态属性使用,这与内置预设使用的模式相同。
Rectangle()
.frame(width: 250, height: 250)
.offset(x: isVisible ? 0 : 340)
.animation(.slingshot, value: isVisible)
