前段时间我们组接手一个 App 的动效优化,iOS 同事在群里突然抛出一个问题:“Spring 弹簧动画,起点终点是不是固定的,但时长不固定?”我当时没急着回答,而是现场把 iOS、Android 两端的弹簧动画 API 拉出来测了一圈,最后发现这个问题的答案不仅分平台,还分 API 实现,不能一概而论。
先强调一下,这篇聊的 Spring 是动画领域里的“弹簧物理动画”,英文叫 Spring Animation,跟 Java 后端那个 Spring Framework 的 IoC 容器、Spring Boot 完全是两个东西,只是名字撞了车。
这个问题的标准答法,一句话能说完:真实物理弹簧动画的起点终点是输入参数,必须指定,而动画时长不是参数,是物理计算的结果,所以确实不固定。但麻烦的地方在于,很多动画系统里有一批类似 Spring 效果的 API,又要求你必须传一个 duration,比如 iOS 的UIView.animate(withDuration:usingSpringWithDamping:)。这就让不少人产生了“Spring 动画时长为啥是固定”的错觉。
这篇文章就把整个问题的来龙去脉讲清楚,顺便把我踩过的坑、日常调参数的方法一并分享,适合刚接触动效开发、或者在动画时长问题上栽过跟头的移动端和前端同学。
1. 先给结论:真实弹簧动画里,时长是算出来的,不是传进去的
1.1 哪些值固定,哪些值不固定
任何一个真正的物理弹簧动画,由三个输入值决定运动状态:
- fromValue(起点):动画开始瞬间的初始状态。
- toValue(终点/目标值):弹簧要“试图”到达的位置。
- initialVelocity(初始速度):松手那一刻已经具备的速度,可以是零,也可以是往下拉时的滑动速度。
这三个值在动画启动前必须明确指定,所以说是“固定”的。但这个固定是相对于动画过程而言的,不是说运行中就不能改——不少物理弹簧引擎允许你动态更新目标值,比如下拉刷新回弹前还没停下来,用户又往下拉了一段,这时候终点会变化,系统会重新计算。所以更准确的说法是:每个计算周期内,弹簧的目标值是已知的,但整个过程的目标不一定是一成不变的。
不固定的是三样东西:
- 动画总时长。引擎不会提前告诉你“你这动画跑 0.8 秒”,它只负责每帧算力、算速度、算位置,直到“足够安静”为止。
- 每一帧的位移增量。刚松手时位移变化快,越接近终点越慢,甚至可能反向冲过头再来回振荡。
- 到达终点前是否会过冲。这个取决于阻尼系数,过冲之后还会带出一串小幅度回摆。
换句话说,真实弹簧动画的时长是“结果”,不是“输入”。你只能通过参数去影响它,不能直接规定它。这是物理仿真动画和传统补间动画最本质的区别。
1.2 为啥时长没法提前定死:阻尼和振荡的锅
要理解为什么时长不固定,得看最基本的弹簧运动模型。真实弹簧加了一个阻尼器之后,系统整体受力大概是这样的:
- 弹簧拉力想把物体拉回目标位置,拉力大小跟“当前距离”成正比。
- 阻尼力阻碍运动,大小跟“当前速度”成正比。
- 物体本身还有质量,质量越大,同样受力下加速度越小。
这三者组合出来的运动方程,数学上是一个二阶常系数微分方程,解出来大概有三种行为:
- 欠阻尼(dampingRatio < 1):物体会冲过目标点,然后回来,再冲过去,来回衰减振荡。
- 临界阻尼(dampingRatio = 1):不振荡,以最快的速度收敛到目标点,刚好不冲过头。
- 过阻尼(dampingRatio > 1):也不振荡,但收敛速度反而变慢,像在很黏的液体里慢慢挪。
正因为存在欠阻尼这种“来回折腾”的情况,“多久才能停”就变得完全依赖参数配合了。你把阻尼调大一点,振荡次数少了,但临界阻尼附近其实最快;你把刚度调大,振荡频率变高,但衰减速度也可能变化。这些因素互相耦合,没有一个直观的“秒数”可以提前写死。
1.3 一个反直觉但很重要的现象:起点和终点的距离越长,时长通常越久
同一套弹簧参数,同样是“到终点静止”,从 0 拉到 100 和从 0 拉到 50,耗时一样吗?
很多人的第一反应是“一样,因为参数相同、目标比例相同”。但如果动画结束判定用的是绝对误差(比如跟目标位置相差小于 0.5pt,速度小于某阈值就算停),那答案就不一样了。物理规律还是挺直观的:你把弹簧拉得越远,松手后要衰减到同一个绝对误差范围,就需要多走一段路,自然多花一点时间。
我第一次意识到这个,是做一个“卡片从屏幕外弹入”的动画。卡片入场距离在窄屏和宽屏上不一样,当时我以为是参数没调好导致时长漂移,后来才反应过来这就是弹簧物理的正常表现。所以如果你发现“同样参数,在不同设备上动画时长不同”,先别急着怀疑代码有问题,先看看起始位置和目标位置的距离是不是变了。
2. 苹果系的两套 Spring:一个假装有时长,一个真没有
iOS 是大家最常把 Spring 动画时长搞混的重灾区,因为它在系统层面同时提供了两套 API,原理完全不同。
2.1 UIView 的 usingSpringWithDamping:duration 是“规划时长”,不是物理时长
iOS 开发者最熟悉的应该是下面这行代码:
UIView.animate( withDuration: 0.6, delay: 0, usingSpringWithDamping: 0.6, initialSpringVelocity: 0, options: [.curveEaseOut], animations: { self.card.center.y = targetY } )这里withDuration: 0.6就是整个动画的播放时间,系统会在 0.6 秒内把动画放完。usingSpringWithDamping: 0.6只是用来控制“弹性外观”的阻尼比,让你看到轻微过冲和回摆,但动画不会无限拖下去,到了 0.6 秒就被强制结束。
严格来说,这种动画不是物理仿真,而是用弹簧衰减的数学公式在固定时间轴上生成曲线,本质上更接近一种“长得像弹簧的缓动曲线”。你可以把它理解成:系统用弹簧公式算了一些中间帧,再把这些帧压缩到你指定的时间窗口里。所以它当然有时长,而且是你传进去的。
这也是很多人回答“Spring 动画时长不固定”时被喷的原因——他用的 API 明明必须要传 duration。
2.2 CASpringAnimation:真正的物理弹簧动画,duration 会被忽略
如果你用的是 Core Animation 里的CASpringAnimation,那就是正儿八经的物理弹簧了:
let spring = CASpringAnimation(keyPath: "position") spring.fromValue = NSValue(cgPoint: CGPoint(x: 320, y: 100)) spring.toValue = NSValue(cgPoint: CGPoint(x: 320, y: 400)) spring.mass = 1.5 spring.stiffness = 180 spring.damping = 25 spring.initialVelocity = 0 // 单独添加动画时,duration 可以不关心 view.layer.add(spring, forKey: "springPosition")注意,CASpringAnimation的duration属性是会被系统忽略的。Apple 的逻辑是:真正的动画时长由 settlingDuration 计算得出,你不需要手动指定。settlingDuration是一个只读属性,你可以在动画提交之前打印它来做预判:
print(spring.settlingDuration)但有一个实操要点:如果你把 spring 动画塞进CAAnimationGroup里统一管理,那group.duration必须手动设置为spring.settlingDuration或更大值,否则动画会被 group 的时间窗截断。这个坑非常隐蔽,我遇到过一次——单独跑动画正常,放进组里之后动画突然只走一半就停住,排查了半天才想起来 group 有自己独立的时长控制。
2.3 UIViewPropertyAnimator 的弹簧版本
除了上面两个,iOS 还有一个UIViewPropertyAnimator,它既能做普通时间动画,也能接物理弹簧参数:
let timing = UISpringTimingParameters( mass: 1.0, stiffness: 180, damping: 25, initialVelocity: .zero ) let animator = UIViewPropertyAnimator(duration: 0, timingParameters: timing)这里初始化时duration传多少其实没意义,因为timingParameters是弹簧参数,动画时长会由系统根据弹簧收敛情况自动推断,animator.durationInSeconds只在动画运行起来后才反映真实需要的时长。
2.4 两套 API 的对照
| API | 用户是否指定时长 | 真实时长来源 | 物理弹簧? |
|---|---|---|---|
UIView.animate(withDuration:usingSpringWithDamping:) | 是,必须传 duration | 指定时长,曲线被压缩进时间窗 | 否,弹簧曲线模拟 |
CASpringAnimation | 否,duration 被忽略 | settlingDuration计算得出 | 是 |
UIViewPropertyAnimator+UISpringTimingParameters | 否,duration 参数无意义 | 系统按弹簧参数估算 | 是 |
所以,判断一个 Spring 动画“时长固不固定”,第一件事是确定 API 是哪种实现。凡是要求你传 duration 的 spring 动画 API,基本都不是纯物理仿真;真正物理仿真的动画 API 反而故意不给你设置时长的入口。
3. 跨端对比:Android、Flutter、Web 谁固定谁不固定
Spring 动画不止 iOS 有,Android、Flutter、Web 前端都有类似实现,但它们对时长的处理方式各不相同,放在一起对比会非常有意思。
3.1 Android:SpringAnimation 从不提时长
Android 侧用 androidx 的SpringAnimation和SpringForce,它的设计思路也是纯物理仿真:
val springForce = SpringForce(finalPosition = 1f).apply { stiffness = SpringForce.STIFFNESS_MEDIUM dampingRatio = SpringForce.DAMPING_RATIO_MEDIUM_BOUNCY } val springAnimation = SpringAnimation( view, DynamicAnimation.TRANSLATION_Y ).apply { springForce = springForce startVelocity = 0f } springAnimation.start()这里面没有任何 duration 参数。动画开始后,系统每一帧根据当前的位置和速度计算新的力和加速度,直到速度和位移都收敛到足够小,动画才回调结束。Android 的文档里也明确说:Spring 动画是非时间驱动的,与ObjectAnimator这类时间驱动动画有本质区别。
在这个模型下,动画时长完全由stiffness难度、dampingRatio阻尼比、mass质量、初始速度综合决定。你可以通过addEndListener拿到动画真正停止的那一刻,但没法提前知道准确秒数。
3.2 Flutter:SpringSimulation 的时间是自变量,不是因变量
Flutter 有个底层物理仿真类叫SpringSimulation,它直接暴露了一个非常有意思的接口:你给它一个时间t,它给你算出那个时间点的位移值。
final simulation = SpringSimulation( const SpringDescription(mass: 1, stiffness: 100, damping: 10), 0, // start 1, // end 0, // velocity ); var t = 0.0; while (!simulation.isDone(t)) { final value = simulation.x(t); // 用 value 刷新 UI t += 1 / 60; }在这个模型里,时间是自变量,位置是因变量。所以 Flutter 的物理弹簧天然没有“总时长”这个概念——仿真可以无限迭代下去,只是当某个时刻起的值足够接近终点且速度足够小,isDone才会返回 true。
实际开发里你不会这样手写循环,而是用AnimationController搭配SpringSimulation,由 controller 驱动时间往前走。但底层逻辑说明了一个核心事实:物理弹簧模型里,时间线是测量出来的,不是规划出来的。
3.3 Web 前端:React Spring 的 config 有两种模式
前端圈最常用的 React Spring 库,为了兼顾两种需求,把选择权直接交给了开发者:
- 默认情况下,它的
config是物理弹簧参数,即mass、tension、friction,这时动画时长不固定,由物理仿真带动。 - 如果你在
config里显式传了duration,库就会切换成时间映射模式,用预设时长把动画走完,此时物理参数会被忽略。
useSpring({ from: { x: 0 }, to: { x: 200 }, config: { mass: 1, tension: 170, friction: 26 }, // 物理时长,不固定 });useSpring({ from: { x: 0 }, to: { x: 200 }, config: { duration: 500 }, // 固定时长,模拟弹簧曲线 });这种设计其实挺聪明的,它把一个本来很底层的问题封装成“物理模式 / 时间模式”两种选项,让开发者在“真实的弹簧感觉”和“严格的时间约束”之间自由选择。
3.4 一张表说清各端差异
| 平台/方案 | 真实物理弹簧 | 是否要求传 duration | 时长是否固定 |
|---|---|---|---|
iOSUIView.animate弹簧 | 否 | 是 | 固定 |
iOSCASpringAnimation | 是 | 否 | 不固定 |
iOSUIViewPropertyAnimator(弹簧参数) | 是 | 否 | 不固定 |
AndroidSpringAnimation | 是 | 否 | 不固定 |
FlutterSpringSimulation | 是 | 否 | 不固定 |
| React Spring 物理模式 | 是 | 否 | 不固定 |
| React Spring 时间模式 | 否 | 是 | 固定 |
看完这张表你应该能发现规律:所有真正物理仿真的 Spring 动画,都不要求你传 duration,时长也确实不固定;所有要求你传 duration 的,本质上是“弹簧风格曲线动画”,不是物理仿真。
4. 实操里的三个坑,每个都是排查了半天才发现的
4.1 坑一:把 withDuration 当成物理时长,动画看起来“被截断”
我在一个弹窗动画里用过UIView.animate的弹簧参数,随手填了withDuration: 0.4,然后为了让效果明显又把dampingRatio调小到 0.4。结果动画前半段过冲幅度很大、回摆也很漂亮,但到了 0.4 秒那个节点,动画被系统强制结束,画面从接近目标的位置突然“跳”到目标位置,非常突兀。
这就是“固定时长 + 强弹簧效果”的矛盾:弹簧阻尼比越小,理论上需要越长时间衰减到静止,但你只给它 0.4 秒的时间窗,剩下的振荡被截断了。
后来我的处理方式是:如果只是想要轻微的“橡皮筋感”,用固定时长的弹簧样式动画,阻尼比别调太低,0.6-0.8 通常比较稳;如果你想要那种真实的轻盈回弹,比如列表删除的果冻感,换 CASpringAnimation 或者 iOS 13+ 的 UISpringTimingParameters,让时长自动计算。
4.2 坑二:动画中途改 toValue,时长会重新算,千万别依赖旧计时
物理弹簧动画的真正优势之一是可以中途改目标值,比如交互式的拖拽、下拉刷新。但这也意味着:每次目标值改变,整个运动的“预计时长”都会推倒重算。
我之前做一个聊天列表的“消息弹入动画”,用户快速连发三条消息,每条都启动一次弹簧动画。因为每次动画的起点不一样(第一条从底部弹入,第二条从第一条的结束位置继续),同一套参数在第二次和第三次就出现了时长明显变短的情况。这不是参数没调好,是起点距离变短了,收敛自然更快。
如果你要让多个连续弹簧动画“看起来节奏一致”,最好的方式是不要连续 create 动画,而是在同一个物理动画里更新目标值(比如 iOS 用forKey重新设置toValue覆盖原动画),让它始终朝着一个不断变化的目标运动。这样每个中间阶段虽然可能被打断,但视觉上是一气呵成的,不会一卡一顿。
4.3 坑三:以为阻尼比相同,时长就相同
还有一次我把 Android 的dampingRatio调成固定值 0.7,然后只改了stiffness,同事过来问“不是阻尼比一样吗,怎么动画时长变了”。
原因是阻尼比只是“阻尼”的一个无量纲比例,时长还受另外两个参数影响:刚度越大,振荡频率越高,衰减到同样振幅的耗时通常越短(但过头也可能更复杂);质量越大,系统响应越迟钝,收敛越慢;初速度越大,自然也需要更多时间停下来。
所以,如果你想通过调参来改变动画时长,不能只盯着一个参数,要同时看 stiffness、damping(或 dampingRatio)、mass 三者的组合。通常我只动 stiffness 来调整“快慢”的感知,动 dampingRatio 来调整“弹不弹”的感知,mass 极少动。
5. 真遇到“必须 X 秒内完成”的需求,该怎么办
产品经理和设计稿经常给你一个硬指标:“这个动画要在 500ms 内完成。”可你心里清楚,物理弹簧动画的时长是算出来的,不是规定出来的。这时候有两条路可以走。
5.1 先判断需求本质:要的是“弹性感”还是“真实弹簧”
大多数动效需求要的只是“看起来有弹性、有点回弹”,不是让你做一个物理实验室。凡是这种需求,我都建议直接用固定时长的弹簧样式曲线动画,简单、可控、不会因为终点距离变化而漂移。
只有少数场景是真的需要物理感的,比如:
- 用户手指拖拽松手后的惯性回弹;
- 列表项的删除和新增张力动画;
- 需要跟手势速度联动的“初速度”动画;
- 全程跟手、随时可能被反转的交互元素。
这些场景建议用真实物理弹簧 API,因为它们的时长本来就不该固定,用户手势快慢、距离远近都在影响动画,强行固定时长反而会显得假。
5.2 需要固定时长,就直接用时间模式或曲线动画
iOS 上可以选择UIView.animate(withDuration:usingSpringWithDamping:),Android 可以用ObjectAnimator加自定义插值器,React Spring 就显式传config.duration。偶尔我也会用 CSS 的transition + cubic-bezier(...),做出来的弹性感在视觉上够用,而且天然固定时长。
这里想多说一句:固定时长动画不是“差”的弹簧,它只是把弹簧曲线压缩进了一个时间窗口。只要阻尼比和初始速度调到位,用户在视觉上根本分辨不出这是真物理还是曲线模拟。大多数线上产品,我敢说 90% 的“弹簧效果”其实都是曲线模拟,没必要非得上物理引擎。
5.3 坚持用真实弹簧时,怎么预估时长并和业务对齐
如果你就是想用真实物理弹簧,又必须给需求方一个“大概多久结束”的承诺,我目前的做法是:
- 先用典型参数组合跑一次真机 Demo,打印
settlingDuration(iOS)或用 updateListener 打到日志(Android)。 - 把相同参数在不同起止距离下的耗时记录下来,做一个参数预设表。
- 预设表直接写到代码注释或设计文档里,方便设计师评审时对照。
下面是一个我常用的 iOS 参数对照经验,仅供参考,具体数值要在真机上微调:
| 场景 | mass | stiffness | damping | 预估 settlingDuration |
|---|---|---|---|---|
| 轻微回弹 | 1.0 | 180 | 25 | 约 0.5s |
| 明显过冲的果冻感 | 1.0 | 120 | 15 | 约 0.8-1.0s |
| 快速收敛、几乎不弹 | 1.0 | 300 | 35 | 约 0.3s |
测试的时候一定用真机,模拟器帧率不稳定会导致感知偏差。iOS 也可以用 Xcode 模拟器的“慢动效”模式观察收敛过程,但最终时长判断还是以真机为准。
另外,真实物理弹簧动画结束回调可能比你预期晚,如果你的业务里动画结束后要接着触发下一段逻辑,一定要监听真正的 completion 回调,而不是用 “duration 到了就执行” 的倒计时。这是物理弹簧和普通动画在工程上的最大区别——你那套基于 duration 的时序协调在真实弹簧面前不可靠。
我个人做动效这几年下来,最大的体会是:Spring 动画的“时长问题”本质上不是一个技术参数问题,而是产品预期与物理模型的匹配问题。你想让动画像弹簧,就别要求它最后一定踩在你设定的秒数上;你想让动画严格在秒数内完成,就别苛求它保留完整的弹簧物理特性。搞清楚自己到底要哪一头,API 选型、参数调整、跨端移植都会顺很多。