用弹簧动画让界面更生动
本文整理自 WWDC23 Session:Animate with springs。文中基于 duration 和 bounce 的新 API 从 iOS 17、iPadOS 17、macOS 14 等系统版本开始提供。
前言
我们构建的 UI 越来越动态,变化和运动无处不在。恰当的动画不仅让界面更有生命力,也能帮助用户理解界面中正在发生什么。
一个流畅、可交互的 UI 通常由三部分共同构成:
- 转场(Transition):从一个场景切换至另一个场景。
- 手势(Gesture):用户直接与设备和界面交互。
- 动画(Animation):界面元素移动、缩放或改变视觉属性。
动画之间的差异有时很细微,但用户能够感受到一个动画是否自然。要让动画显得优雅、可信,关键是让运动具有良好的连续性。弹簧正是实现这一目标的通用工具。
弹簧动画最大的价值不是“弹跳”,而是它能够保持位置和速度连续,可以自然地承接手势速度,也能在动画被中途打断和重定向时平滑过渡。
为什么动画要用弹簧
动画建立连续性感知
如果一个对象从一个位置突然出现在另一个位置,用户会感到突兀,甚至难以理解界面发生了什么。让用户看到对象从起点移动到终点,可以建立空间上的连续性。
但只有位置连续还不够。如果对象的速度突然变化,运动依然会显得不自然。因此,一个好的运动动画通常应满足:
- 位置连续:对象不会瞬间跳到别处。
- 速度连续:对象不会在开始、结束或改变目标时突然改变速度。
常见动画曲线对比
| 动画类型 | 位置连续 | 静止状态下速度连续 | 可承接初始速度 | 适合场景 |
|---|---|---|---|---|
| 线性动画 | 是 | 否 | 否 | 循环旋转、进度指示等匀速运动 |
| 缓入缓出 | 是 | 是 | 否 | 起点和终点都固定、从静止开始的动画 |
| 弹簧动画 | 是 | 是 | 是 | 手势交互、可打断动画、绝大多数 UI 运动 |
线性动画在开始和结束时会发生速度跳变,因此一般不适合表现物体移动。缓入缓出动画从静止状态开始时表现良好,但它是一条预先确定的贝塞尔曲线,无法表达手势结束时已有的速度。
弹簧可以从任意初始速度开始。当用户拖动一个元素并松手时,弹簧会承接松手瞬间的速度,自然地继续运动到目标位置。在 UIKit 中,需要从手势识别器获取速度,并将其传递给弹簧动画。
动画被打断时仍然自然
当一个尚未完成的动画被重新指定目标时,弹簧会把当前速度作为新动画的初始速度。运动方向可能改变,但速度不会毫无过渡地归零,因此频繁操作时依然流畅。
这也是弹簧特别适合交互式 UI 的原因:用户不必等待动画完成,可以随时继续操作。
弹簧不等于弹跳
弹跳只是弹簧的一种表现。即使 bounce = 0,弹簧仍然具有很自然的运动曲线:
- 开始和结束没有突兀的速度跳变;
- 越接近目标,运动越缓慢;
- 没有一个明显的“突然结束”时刻;
- 运动形态接近现实中物体受摩擦力后逐渐停止的过程。
多个属性使用不同弹簧、不同开始时间和结束时间,并不一定是问题。现实世界中的物体本来也不会严格同时开始、同时停止。合理的错落反而能组合出更自然的动画。
弹簧的工作原理
物理模型
弹簧动画模拟的是现实世界中的“物体 + 弹簧”系统。传统物理模型由三个参数决定:
- 质量(mass):物体的质量。
- 刚度(stiffness):弹簧将物体拉回目标位置的力度。
- 阻尼(damping):环境摩擦使运动衰减的程度。
这组参数适合描述真实物理系统,却不适合日常 UI 调参。设计动画时,我们通常更关心“动画有多快”和“动画有多弹”,而不是虚拟物体究竟有多重。
因此,Apple 在 WWDC23 引入了一组更直观、并在各动画框架间统一的参数:
- 感知时长(duration):控制动画节奏;数值越大,动画越慢。
- 弹性(bounce):控制动画性格;数值越大,越容易越过目标并产生振荡。
三种弹簧形态
bounce 的取值范围是 -1.0 ... 1.0:
bounce |
物理分类 | 运动表现 | 常见用途 |
|---|---|---|---|
> 0 |
欠阻尼(underdamped) | 越过目标并振荡,具有弹跳感 | 活泼的反馈、手势结束后的运动 |
= 0 |
临界阻尼(critically damped) | 不越过目标,平滑且自然 | 最通用的 UI 动画 |
< 0 |
过阻尼(overdamped) | 不越过目标,曲线比临界阻尼更平坦 | 速度衰减、需要克制感的运动 |
可以将这三类简单记为:有弹性的、平滑的、扁平的。
数学原理
框架已经实现了弹簧的求值和速度衔接。理解数学原理主要用于建立直觉或实现自定义动画,并不影响日常使用。
当 bounce = 1 时,系统中没有摩擦力,物体会像余弦波一样永远振荡,无法真正停在目标位置。此时 duration 对应振荡周期。
当 bounce 逐渐减小时,系统中加入了阻尼。弹簧的运动可以理解为:
弹簧运动 = 余弦振荡 × 指数衰减余弦波产生往复运动,指数衰减让振幅越来越小,最终在视觉上趋近目标。通过调整余弦曲线的偏移和缩放,弹簧可以匹配任意初始速度。
当 bounce = 0 时,振荡消失,运动可以看作直线与指数衰减的乘积;当 bounce < 0 时,曲线由两个指数函数相加构成。这类过阻尼曲线也适合描述滚动视图中的速度衰减。
感知时长与结算时长
因为指数衰减理论上永远不会完全停止,弹簧动画也不存在严格意义上的结束时刻。框架会在运动不再对 UI 产生明显影响时移除动画,这段时间称为结算时长(settling duration)。
它与配置动画时传入的 duration 不同:
duration是稳定、可预测的感知时长,用于控制用户感受到的动画节奏。settling duration是动画充分接近静止所需的时间,会受弹性、初始速度等因素影响,难以预测。
不要依赖结算时长安排用户可见的 UI 变化。如果需要在动画完成后执行操作,可以使用 UIKit 动画 API 提供的 completion handler。
如何在代码中使用弹簧
需要注意的是,在原版的演讲视频中,并没有任何关于UIkit使用弹簧的部分
我们只能假设部分组件是自己自带弹簧动画的,
但是如果我们想要自己实现,这就是另外的价钱了(雾)
使用OC来写弹簧动画十分的复杂,我直接给出代码,并在代码下给出参数的讲解
(实际上你让AI来写肯定是没什么问题的)
使用 UIKit 的新弹簧 API
从 iOS 17 开始,UIKit 可以直接使用 springDuration 和 bounce 配置弹簧:
[UIView animateWithSpringDuration:0.6
bounce:0.2
initialSpringVelocity:0
delay:0
options:UIViewAnimationOptionAllowUserInteraction
animations:^{
self.animatedView.center = targetCenter;
} completion:^(BOOL finished) {
if (!finished) {
return;
}
[self updateInterfaceAfterAnimation];
}];这里的 springDuration 是感知时长,bounce 的取值范围为 -1.0 ... 1.0。当动画被新的动画替换时,completion handler 的 finished 会是 false,不应无条件执行后续操作。
承接手势结束时的速度
UIPanGestureRecognizer 的 velocityInView: 返回的是每秒移动的点数,而 initialSpringVelocity 需要的是相对于动画总位移的速度。可以用“手势速度 ÷ 剩余位移”进行转换:
- (void)finishDraggingView:(UIView *)view
targetCenter:(CGPoint)targetCenter
velocity:(CGPoint)velocity {
CGPoint displacement = CGPointMake(
targetCenter.x - view.center.x,
targetCenter.y - view.center.y
);
CGVector initialVelocity = CGVectorMake(
displacement.x == 0 ? 0 : velocity.x / displacement.x,
displacement.y == 0 ? 0 : velocity.y / displacement.y
);
UISpringTimingParameters *timing =
[[UISpringTimingParameters alloc] initWithDuration:0.5
bounce:0.2
initialVelocity:initialVelocity];
UIViewPropertyAnimator *animator =
[[UIViewPropertyAnimator alloc] initWithDuration:0
timingParameters:timing];
[animator addAnimations:^{
view.center = targetCenter;
}];
[animator startAnimation];
}手势结束时调用:
CGPoint velocity = [panGestureRecognizer velocityInView:view.superview];
[self finishDraggingView:view
targetCenter:targetCenter
velocity:velocity];对于只沿单一方向移动的动画,也可以直接将该方向的手势速度除以剩余距离,再传给 animateWithSpringDuration:bounce:initialSpringVelocity:delay:options:animations:completion::
CGFloat distance = targetCenter.x - view.center.x;
CGFloat relativeVelocity = distance == 0 ? 0 : velocity.x / distance;
[UIView animateWithSpringDuration:0.5
bounce:0.2
initialSpringVelocity:relativeVelocity
delay:0
options:UIViewAnimationOptionAllowUserInteraction
animations:^{
view.center = targetCenter;
} completion:nil];需要注意,initialSpringVelocity 不是绝对速度。直接传入 velocityInView: 得到的点每秒数值,通常会导致动画速度异常。
兼容旧版 UIKit 弹簧 API
旧版 UIKit 使用阻尼比配置弹簧:
[UIView animateWithDuration:0.6
delay:0
usingSpringWithDamping:0.8
initialSpringVelocity:0
options:UIViewAnimationOptionAllowUserInteraction
animations:^{
view.center = targetCenter;
} completion:nil];新旧 API 的参数方向相反:
- 新 API 的
bounce越大,弹性越明显。 - 旧 API 的
usingSpringWithDamping越小,弹性越明显。
新项目应优先使用基于 duration 和 bounce 的 API,因为它更容易根据视觉效果调参,并且与 Core Animation 使用相同的参数语义。
使用 Core Animation
需要直接操作图层动画时,可以使用 CASpringAnimation:
CASpringAnimation *animation =
[[CASpringAnimation alloc] initWithPerceptualDuration:0.6
bounce:0.2];
animation.keyPath = @"position";
CALayer *presentationLayer = (CALayer *)layer.presentationLayer;
CGPoint startPosition = presentationLayer ? presentationLayer.position : layer.position;
animation.fromValue = [NSValue valueWithCGPoint:startPosition];
animation.toValue = [NSValue valueWithCGPoint:targetPosition];
layer.position = targetPosition;
[layer addAnimation:animation forKey:@"position.spring"];修改 Core Animation 的模型层属性时,要先将最终值写入 layer.position,否则动画结束后图层会跳回旧值。使用 presentationLayer 作为起点,则可以在动画被打断时从当前视觉位置继续运动。
duration 和 bounce 在 UIKit 与 Core Animation 中含义一致,跨框架实现相同运动风格时不需要反复换算参数。
如何选择参数
调节弹簧时,推荐按以下顺序进行:
- 先调整
duration,找到符合界面节奏的速度。 - 再调整
bounce,决定动画的性格。 - 在真机上连续、快速操作,检查动画被打断和重定向时是否自然。
- 检查应用内相似动画的参数是否一致。
bounce 的选择建议
bounce |
视觉感受 | 建议 |
|---|---|---|
0 |
平滑、渐进、通用 | 不确定时的默认选择 |
0.1 ... 0.15 |
几乎感觉不到弹跳,但收束更利落 | 适合希望略显活泼的常规 UI |
0.2 ... 0.3 |
能明显感受到弹性 | 适合手势结束、状态切换和强调反馈 |
> 0.4 |
弹跳明显且夸张 | 普通 UI 元素应谨慎使用 |
< 0 |
克制、缓慢、扁平 | 适合需要稳定感或速度衰减的场景 |
不确定时,可以从 bounce = 0 开始。它是最通用的弹簧,需要更活泼或更具物理感时,再逐步增加 bounce。
让动画与产品气质一致
参数没有绝对正确的答案。选择前需要考虑应用整体气质:
- 严肃、效率导向的应用应使用更短、更克制的弹簧。
- 活泼、娱乐导向的应用可以加入适量弹性。
- 手势驱动的动画适合保留更多物理感。
- 同类元素应尽量复用相同预设,避免界面运动风格混乱。
实践检查清单
- 动画是否帮助用户理解变化,而不是只用于装饰?
- 移动对象的位置和速度是否连续?
- 用户快速重复操作时,动画是否能被自然打断?
- 是否优先从
bounce = 0开始调参? - 弹跳是否过度,影响信息读取或操作效率?
- 是否避免依赖不可预测的结算时长控制 UI 流程?
- 动画风格是否与应用整体气质一致?
- 是否考虑“减弱动态效果”等辅助功能设置?
总结
弹簧动画的重点并不是弹跳,而是自然的运动和速度连续性。它可以承接手势速度、平滑处理动画中断,并以接近现实物体的方式逐渐停止。
实践中可以先从 bounce = 0 开始,先用 springDuration 确定节奏,再用 bounce 调整性格。对于手势驱动动画,还要正确传入相对初始速度。让弹簧服务于交互连续性,并在整个应用中保持一致,才能真正让界面显得流畅而有生命力。