Log Entry

用弹簧动画让界面更生动

从连续性、初始速度和物理模型出发,理解弹簧动画的优势,并学习如何在 UIKit 与 Core Animation 中正确使用弹簧。

用弹簧动画让界面更生动

本文整理自 WWDC23 Session:Animate with springs。文中基于 duration 和 bounce 的新 API 从 iOS 17、iPadOS 17、macOS 14 等系统版本开始提供。

前言

我们构建的 UI 越来越动态,变化和运动无处不在。恰当的动画不仅让界面更有生命力,也能帮助用户理解界面中正在发生什么。

一个流畅、可交互的 UI 通常由三部分共同构成:

  • 转场(Transition):从一个场景切换至另一个场景。
  • 手势(Gesture):用户直接与设备和界面交互。
  • 动画(Animation):界面元素移动、缩放或改变视觉属性。

动画之间的差异有时很细微,但用户能够感受到一个动画是否自然。要让动画显得优雅、可信,关键是让运动具有良好的连续性。弹簧正是实现这一目标的通用工具。

弹簧动画最大的价值不是“弹跳”,而是它能够保持位置和速度连续,可以自然地承接手势速度,也能在动画被中途打断和重定向时平滑过渡。

为什么动画要用弹簧

动画建立连续性感知

如果一个对象从一个位置突然出现在另一个位置,用户会感到突兀,甚至难以理解界面发生了什么。让用户看到对象从起点移动到终点,可以建立空间上的连续性。

但只有位置连续还不够。如果对象的速度突然变化,运动依然会显得不自然。因此,一个好的运动动画通常应满足:

  1. 位置连续:对象不会瞬间跳到别处。
  2. 速度连续:对象不会在开始、结束或改变目标时突然改变速度。

常见动画曲线对比

动画类型 位置连续 静止状态下速度连续 可承接初始速度 适合场景
线性动画 是 否 否 循环旋转、进度指示等匀速运动
缓入缓出 是 是 否 起点和终点都固定、从静止开始的动画
弹簧动画 是 是 是 手势交互、可打断动画、绝大多数 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 中含义一致,跨框架实现相同运动风格时不需要反复换算参数。

如何选择参数

调节弹簧时,推荐按以下顺序进行:

  1. 先调整 duration,找到符合界面节奏的速度。
  2. 再调整 bounce,决定动画的性格。
  3. 在真机上连续、快速操作,检查动画被打断和重定向时是否自然。
  4. 检查应用内相似动画的参数是否一致。

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 调整性格。对于手势驱动动画,还要正确传入相对初始速度。让弹簧服务于交互连续性,并在整个应用中保持一致,才能真正让界面显得流畅而有生命力。

参考资料