做 iOS 这几年,动效这块我被问得最多的一句话就是:“这个回弹效果怎么做的?”问的人里既有刚入行的新人,也有做了三四年、平时 UI 全靠设计稿切图的老手。弹簧动画这东西的尴尬之处在于:API 就那么几个,写起来三行代码就能跑,但要调到“好看”,靠的完全是手感加物理直觉,文档里基本不会告诉你。这篇就把我自己这几年在项目里攒下来的东西摊开讲一遍——从弹簧模型的参数到底代表什么、四套 API 各自适合什么场景、交互式拖拽里怎么做到不抽搐,到我们踩过的那些“最后一帧跳一下”“列表复用得满屏乱弹”的具体坑。内容偏实战,代码都是能直接拽进工程里跑的,适合已经写过 UIView 动画、但总觉得自己的动画“差点意思”的开发者,也适合刚开始接触交互式动画、想把体验做细一点的同学。
1. 弹簧动画到底解决了什么问题
1.1 从“线性的、生硬的”说起
先把问题摆清楚。iOS 上最基础的动画是UIView.animate(withDuration:),它的插值曲线默认是 ease-in-out,本质是一条数学上的三次贝塞尔。这种曲线的问题在于:它太“规矩”了。物体从 A 点到 B 点,速度先加后减,末速度精确归零,整个过程没有一丝多余的动作。
问题在于,真实世界里几乎没有东西是这样运动的。你用手指弹一下桌上的橡皮筋,它会冲过头、再荡回来、再冲过去,振幅越来越小,最后停下。你把一个球扔到墙上,它撞上去的那一瞬间速度是最大的,反弹回来的方向是突变的。人对“物理正确”的运动是有本能预期的,一旦动画不符合这个预期,大脑就会判定“这玩意儿是假的、是电子屏幕里的”。
这就是弹簧动画存在的理由:它不是一种“更好看的曲线”,而是一套基于阻尼谐振子(damped harmonic oscillator)的物理模型。你给它质量、刚度、阻尼,它按照真实物理规律算出每一帧的位置。带来的直接收益有三点:末速度不必归零(可以直接接力下一个动画)、天然支持中断和反转(因为它是状态驱动的,不是时间驱动的)、手感可调(同一套 API,改参数就能从“果冻”调到“钢铁”)。
我印象最深的一次是做一个卡片列表的展开收起。最初用的是 ease-in-out,写完之后自己看着还行,拿去给设计同学过,人家的评价是“像 PPT 换页”。后来换成弹簧,dampingFraction给到 0.78,同样的位移、几乎同样的时长,观感立刻从“程序在播放动画”变成了“这个东西被推开了”。差别就这么大。
1.2 弹簧模型的三个物理量与直觉映射
要说清参数,得先把那个高中物理公式搬出来。一个带阻尼的弹簧系统,运动方程是:
m * x'' + c * x' + k * x = 0m是质量,c是阻尼系数,k是刚度。真正决定“看起来是什么样”的,其实是两个派生量:
- 自然角频率
ω₀ = sqrt(k / m),决定振动快慢,值越大,来回摆得越快。 - 阻尼比
ζ = c / (2 * sqrt(k * m)),决定振动衰减的快慢,这是手感的灵魂参数。
阻尼比的三个区间必须记住:
| 阻尼比范围 | 物理状态 | 视觉表现 | 典型用途 |
|---|---|---|---|
| ζ < 1 | 欠阻尼 | 有回弹,来回摆动几次后停 | 按钮点击、卡片弹出、Toast 出现 |
| ζ = 1 | 临界阻尼 | 不回弹,最快速度到达且不超调 | 面板展开、导航推入 |
| ζ > 1 | 过阻尼 | 不回弹,但比临界阻尼更“肉”、更慢 | 大面积的、需要稳重感的位移 |
这张表建议直接贴在显示器边上。我见过太多人调参调不出来,根源就是没搞清楚自己在调“快慢”还是在调“回弹”,把stiffness和damping一起乱改,结果参数之间互相抵消,越调越乱。
一个很实用的排查习惯:如果你觉得动画“太软”,先看阻尼比是不是偏小(低于 0.7 就很容易有果冻感);如果你觉得动画“太慢”,先看自然频率是不是偏低,而不是去动阻尼。这两个维度是可以解耦的,别搅在一起。
1.3 四套 API 的演进路线与选型
iOS 上能写弹簧动画的 API 有四套,很多人只知道其中一两套,遇到具体场景就硬凑,其实各有各的地盘:
第一套,UIView.animate的 spring 家族。从 iOS 7 就有了,签名是usingSpringWithDamping:加initialSpringVelocity:。优点是门槛最低,闭包式写法,改属性就行,不用管 layer。缺点是可控性差——你没有mass和stiffness,只有阻尼比和初速度,而且动画时长是系统估算的,你传的duration只影响一个大概的尺度,实际结束时间可能对不上,做精确接力的时候很坑。
第二套,CASpringAnimation。iOS 9 引入,是 Core Animation 层面的,能直接操作 layer 的任意可动画属性。好处是它真的把mass、stiffness、damping、initialVelocity全暴露给你了,而且有一个只读的settlingDuration,能提前知道这条动画会在多久之后停稳。做需要精确编排的复杂动效(比如多个 layer 错峰启动)时,这是唯一能算准的。缺点是写起来啰嗦,fromValue/toValue要手动包装,而且必须处理好modelLayer的最终值,否则动画一结束就跳回去。
第三套,UIViewPropertyAnimator+UISpringTimingParameters。iOS 10 起可用,这是我个人目前最推荐的默认方案。它把弹簧抽象成一个UITimingCurveProvider,你既可以只传阻尼比,也可以传完整的mass/stiffness/damping/initialVelocity。关键在于它支持fractionComplete、isReversed、pausesOnCompletion,天生适合手势驱动的交互式动画。
第四套,SwiftUI 的 spring。iOS 13 起是.spring(response:dampingFraction:blendDuration:),iOS 17 起换成了更符合直觉的.spring(duration:bounce:)和.spring(.bouncy)这类预设。SwiftUI 的弹簧和 UIKit 的底层模型是同一套,但参数语义做了重新包装,response近似于“一次完整振荡的周期”,bounce则是一个 -1 到 1 的直观刻度。写 SwiftUI 的时候没必要自己算物理量。
选型上一句话总结:纯 UIKit、简单的属性变化用UIView.animate;要精确控制结束时间或多 layer 编排用CASpringAnimation;要拖拽、要中断、要可反转用UIViewPropertyAnimator;SwiftUI 里就用原生.spring,别自己造轮子。
2. 核心参数解析:从物理量到可调手感
2.1 mass / stiffness / damping 三元组的实际手感
先把这个最“原教旨”的组合拆开。CASpringAnimation和UISpringTimingParameters(mass:stiffness:damping:initialVelocity:)用的都是这套。
mass在视觉上的意义是“惯性”。质量越大,同一个推力下启动越慢,但一旦动起来就越难刹住。默认值是 1,我实际操作下来的建议是:保持 1 不动。改 mass 的效果和同时改 stiffness、damping 是等效的,但会让参数之间失去可比性,团队协作时别人看不懂你为什么设成 2.5。除非你要做“轻重不一”的物体(比如一个小球和一块木板掉落),否则别碰它。
stiffness是“弹簧有多硬”。它直接决定自然频率ω₀ = sqrt(k/m),也就是摆动的快慢。经验值范围:120 到 300 属于偏慢、有分量的感觉,适合大面积面板;300 到 800 是常用的中段,交互反馈基本都在这里;800 以上会非常迅捷,适合小控件的状态切换。超过 1500 之后视觉上就接近“瞬间到位 + 一点点抖”了,意义不大。
damping是“阻力有多大”。它是唯一一个有绝对物理单位(N·s/m)的参数,但也正因为如此,单独看它没有意义——必须结合stiffness和mass算出的阻尼比才有参考价值。这就是为什么很多人照着网上抄了一组stiffness: 300, damping: 20,换个stiffness值就完全走样了。
我把项目里常用的几组配置整理了一下,都是我实际用过的,区间内的值可以直接抄:
| 场景 | mass | stiffness | damping | 阻尼比约 | 观感 |
|---|---|---|---|---|---|
| 按钮按压缩放 | 1 | 500 | 20 | 0.45 | 明显回弹,俏皮 |
| 弹窗出现 | 1 | 300 | 22 | 0.64 | 轻回弹,主流审美 |
| 底部面板展开 | 1 | 260 | 26 | 0.81 | 几乎不回弹,稳 |
| 导航推入 | 1 | 400 | 40 | 1.0 | 临界阻尼,干脆 |
| 拖拽松手归位 | 1 | 220 | 24 | 0.81 | 跟手,收尾利落 |
怎么快速估阻尼比?记住一个近似:ζ ≈ damping / (2 * sqrt(stiffness))(mass 取 1)。比如stiffness: 300, damping: 22,2 * sqrt(300) ≈ 34.6,22 / 34.6 ≈ 0.64。心算到这个精度,调参就足够了。
2.2 dampingRatio 与 response:更贴近设计师语言的参数
如果mass/stiffness/damping是给工程师用的,那dampingRatio(阻尼比)+response(响应时间)就是给设计师用的。UIKit 的UISpringTimingParameters(dampingRatio:)和 SwiftUI 的.spring(response:dampingFraction:)都是这个思路。
dampingRatio就是上一节算出来的 ζ,取值范围 0 到 1(超过 1 不报错,但会变成过阻尼,SwiftUI 里dampingFraction超过 1 是允许的)。它的优点是跨参数可比:0.6 在任何 stiffness 下的“回弹程度”都是一样的,只是摆动的快慢不同。这一点在做设计规范的时候非常关键——你可以给团队定“所有弹性反馈一律dampingRatio: 0.7”,而不需要大家各自算物理量。
response(在 Core Animation 语境里对应的是settlingDuration的估算,在 SwiftUI 里是“一次振荡周期的近似值”)则是时间维度的参数。SwiftUI 里response: 0.4的意思大概是“这个弹簧的自然周期是 0.4 秒”,换算成角频率是ω₀ ≈ 2π / 0.4 ≈ 15.7,对应stiffness ≈ ω₀² ≈ 246(mass=1 时)。这个换算我经常用,尤其是把设计给的response值翻译成 UIKit 参数的时候。
注意:SwiftUI 的
response和CASpringAnimation的settlingDuration不是一个东西。前者是振荡周期,后者是“从开始到振幅衰减到几乎为零所需的时间”,一般后者是前者的两倍以上。别把这两个数字互相套用。
从 iOS 17 起 SwiftUI 换了bounce参数,这个更好理解:bounce为 0 就是临界阻尼(不回弹),为正值就是欠阻尼(回弹,越大越夸张),为负值就是过阻尼(不回弹且更慢)。它和dampingRatio的换算关系大致是dampingRatio = 1 - bounce(bounce 在 0 到 1 之间时)。这套参数我觉得比dampingFraction更符合直觉,因为它把“回弹”这个唯一的变量单独拎出来了,时长和它解耦。
2.3 initialVelocity 的单位陷阱
这个参数踩坑率极高,我几乎每次 code review 都能看到有人写错。
initialVelocity在UIView.animate的 spring 版本里,单位是“每秒走完多少倍的动画距离”。也就是说,如果是 0 到 100 的位移,initialVelocity: 1表示初始速度是 100 pt/s,initialVelocity: 10表示 1000 pt/s。很多人下意识按“像素每秒”去填,写个initialVelocity: 300,结果位移只有 100pt,等于一秒钟要走 300 倍距离,动画瞬间就飞出去了,看起来像闪屏。
UIViewPropertyAnimator的UISpringTimingParameters(dampingRatio:initialVelocity:)里,initialVelocity是一个CGVector,单位同样是归一化的:(1, 0)表示“初始速度等于一秒走完整个animator要走的位移”。所以如果你从手势里拿到的是一个 pt/s 的实际速度,必须先除以总位移量再传进去。
代码里我是这么做的:
// 手势结束时的实际速度,单位 pt/s let velocity = panGesture.velocity(in: view) // 总位移量(从当前手指位置到目标位置) let distance = targetY - currentY // 归一化 let normalizedVelocity = CGVector( dx: 0, dy: velocity.y / distance ) let params = UISpringTimingParameters( dampingRatio: 0.8, initialVelocity: normalizedVelocity )这里有一个细节:分母要用带符号的位移量。如果distance是负的(目标在当前位置上方),而速度也是负的(往上滑),除出来就是正的,方向才是对的。如果图省事用了abs(distance),那快速反向甩动的时候动画会朝反方向冲一下,那个诡异感非常明显。这个 bug 我差不多花了半天才定位到。
还有一点:如果distance接近 0(手指几乎已经停在目标位置了),这个除法会炸出天文数字。我的做法是加一层保护——abs(distance) < 1的时候直接给initialVelocity: .zero,用默认弹簧收尾就行,这时候反正位移极小,视觉上看不出差别。
2.4 参数调试的正确工作流
很多人调弹簧是“改一个值 → 跑一遍 → 再改一个值”,效率极低。我推荐的工作流是固定两个、只动一个:
第一步,先把dampingRatio定死在你要的手感区间(比如 0.7,轻回弹)。第二步,只调stiffness(或 SwiftUI 的response),从慢到快扫一遍,找到你觉得节奏合适的那个点。第三步,微调dampingRatio,在 0.05 的步长内找最舒服的位置。
为什么是这个顺序?因为人对“快慢”的敏感度远高于对“回弹幅度”的敏感度。你先定快慢,观感就定了八成;回弹幅度只是最后的调味。反过来先定回弹再定快慢,你会一直觉得“哪里不对”,因为节奏感没解决。
另外一个实操建议:在开发期做一个隐藏的调试面板,用UISlider实时改参数,配合UIViewPropertyAnimator的fractionComplete手动拖动进度条看曲线。这比反复改代码重新编译快十倍。我们内部有个小工具,一个UISlider拖stiffness、一个拖damping,旁边实时渲染一个小方块做演示,三分钟就能定下一组参数,然后把数值抄回代码里。这个东西我在每个项目里都会重写一遍,成本不到 100 行,收益巨大。
3. 实操:四套 API 的落地写法
3.1 UIView.animate 的 spring 版本:简单场景的最优解
先看最常用的这版。假设我要做一个卡片点击放大再复原的反馈:
// 按下时缩小 UIView.animate( withDuration: 0.35, delay: 0, usingSpringWithDamping: 0.5, initialSpringVelocity: 0.5, options: [.allowUserInteraction, .beginFromCurrentState], animations: { self.cardView.transform = CGAffineTransform(scaleX: 0.94, y: 0.94) }, completion: nil )有几个细节值得单独拎出来说。
usingSpringWithDamping的取值范围是 0 到 1,注意它不允许超过 1。想要过阻尼(更“肉”的收尾)在这个 API 里是做不到的,必须换CASpringAnimation或UIViewPropertyAnimator。这是个很容易被忽略的硬限制。
initialSpringVelocity就是我上一节说的归一化速度。上面这个例子里,如果cardView的缩放距离是 0.06(从 1 到 0.94),“速度等于 1 秒走完全程”就太慢了,实际手感是微微一弹。这类小的形变动画,initialSpringVelocity给 0.3 到 1 之间就够,超过 3 会明显“甩”一下。
withDuration这个参数在这个 API 里语义很模糊。它不是动画的实际时长,系统的做法是:以duration为基准,结合阻尼比推算出一个实际的 settling 时长。实测下来,阻尼比 0.5、duration 0.35 的动画,实际大概跑 0.5 秒左右;阻尼比 0.9 的时候甚至会更接近 0.35。这就导致如果你在 completion 里做接力,时序是不可控的。所以只要涉及多段动画的编排,我就不会用这套 API。
options里两个标志非常关键。.beginFromCurrentState必须加,它保证动画从当前正在呈现的状态(presentation layer)开始,而不是从 model layer 的终点值开始。少了它,快速连点的时候会看到明显的“跳一下”。.allowUserInteraction则是在动画期间保留交互,做列表滚动时的元素动画基本都靠它。
3.2 UIViewPropertyAnimator:可中断、可反转的主力方案
这套 API 是我目前的主力。它最大的价值是把动画变成了一个有状态的对象,你可以暂停、可以反向、可以手动驱动进度。
最基础的用法:
let params = UISpringTimingParameters(dampingRatio: 0.75) let animator = UIViewPropertyAnimator(duration: 0.5, timingParameters: params) animator.addAnimations { self.panelView.transform = CGAffineTransform(translationX: 0, y: -320) } animator.startAnimation()注意duration在传了timingParameters之后,其实只用于一些边缘计算,弹簧本身的实际时长由params决定。所以这里的 0.5 你可以随便填一个大致值,不影响观感——这一点和UIView.animate那版很像,但UIViewPropertyAnimator好在你可以读animator.duration拿回系统估算出来的实际时长,做后续编排。
真正的杀手锏是fractionComplete。配合手势:
@objc private func handlePan(_ gesture: UIPanGestureRecognizer) { let translation = gesture.translation(in: view) let progress = min(max(translation.y / -320, 0), 1) switch gesture.state { case .began: animator.pauseAnimation() case .changed: animator.fractionComplete = progress case .ended, .cancelled: // 速度归一化后决定是继续还是回退 let velocity = gesture.velocity(in: view).y / -320 animator.isReversed = (velocity < -0.5 || progress < 0.3) animator.continueAnimation( withTimingParameters: UISpringTimingParameters( dampingRatio: 0.8, initialVelocity: CGVector(dx: 0, dy: velocity) ), durationFactor: 1.0 ) default: break } }这段代码里有几个要点,值得逐条拆。
第一,fractionComplete赋值前必须调用pauseAnimation(),或者在 animator 创建时就设pausesOnCompletion = true并startAnimation()后立刻暂停。如果 animator 还在 running 状态下改fractionComplete,系统不会报错,但动画会表现出一种很奇怪的“半速运行”状态,非常难查。我第一次遇到这个问题时以为是时序 bug,查了半天。
第二,continueAnimation(withTimingParameters:durationFactor:)是让“半路接手”变平滑的关键。它会把当前的速度和位置作为新的弹簧动画的初始条件,durationFactor用来缩放剩余时长。如果你在.ended分支里直接startAnimation(),会出现明显的速度断裂——松手瞬间动画会“顿”一下再加速。这个细节几乎是“跟手”和“不跟手”的分水岭。
第三,isReversed要在continueAnimation之前设。顺序错了的话,反向不生效。
第四,fractionComplete的语义是“动画总进度的百分比”,它是基于整个动画的,不是基于手势位移的。如果动画的位移是 -320,用户拖了 -160,那就是 0.5。但如果用户拖了 -400(超出去了),这个值会大于 1,会导致动画跑到终点之外。上面代码里的min(max(..., 0), 1)就是为了夹住它。
3.3 CASpringAnimation:需要精算时长时的选择
有些场景必须用CASpringAnimation,最典型的是多图层错峰动画——你要在 t=0、t=0.05、t=0.1 分别启动三条弹簧,而且要知道它们各自什么时候停,好安排下一步。这种时候只有它能给你settlingDuration。
let spring = CASpringAnimation(keyPath: "transform.scale") spring.mass = 1 spring.stiffness = 300 spring.damping = 22 spring.initialVelocity = 0 spring.fromValue = 0.85 spring.toValue = 1.0 // settlingDuration 是只读的估算值,必须在 add 之前读 spring.duration = spring.settlingDuration layer.transform = CATransform3DMakeScale(1.0, 1.0, 1.0) // 先把 modelLayer 的终值设好 layer.add(spring, forKey: "popIn")几个坑必须提前说。
settlingDuration必须在动画被添加到 layer 之前读取。添加之后它会变成 0(或者说,值就不再是那条动画的了)。很多人写layer.add(spring, forKey: nil)之后才发现spring.settlingDuration是 0,然后duration变成 0,动画瞬间完成,看起来就是“没动画”。这个 bug 报错都没有,纯粹看现象。
modelLayer的终值一定要单独设置。Core Animation 的机制是:动画只在呈现层播放,model layer 的属性值不会跟着变。如果你只 add 了动画不设layer.transform,动画播完的那一瞬间,图层会“啪”地弹回原状。这是CASpringAnimation最高频的问题,没有之一。我有一次排查一个“小方块闪回”的问题,查了两个小时,最后发现就是漏了这一行。
fromValue/toValue的类型要匹配 keyPath。transform.scale接受的是 NSNumber,position接受的是NSValue(cgPoint:),opacity接受 NSNumber。类型不对不会崩,但动画不生效,非常隐蔽。
用keyPath还是直接用CABasicAnimation的子类替换。我一般会给CALayer写个扩展,把常用的“弹出”“浮现”封装成方法,内部统一处理 model layer 赋值,避免每次写都漏。
3.4 SwiftUI 里的弹簧:别自己算物理量
SwiftUI 这块简单得多,因为系统把参数语义重新设计过了。
struct ContentView: View { @State private var isExpanded = false var body: some View { RoundedRectangle(cornerRadius: 16) .fill(.blue.opacity(0.2)) .frame( width: isExpanded ? 280 : 120, height: isExpanded ? 200 : 60 ) .onTapGesture { withAnimation(.spring(duration: 0.45, bounce: 0.3)) { isExpanded.toggle() } } } }iOS 17 之后Spring(duration:bounce:)是我最推荐的形式。duration就是感知上的时长,bounce是回弹强度,两个维度正交,非常好调。
如果项目要兼容 iOS 16 及以下,用.spring(response:dampingFraction:blendDuration:)。response约等于振荡周期,dampingFraction是阻尼比。blendDuration这个参数比较特殊,它控制的是“多个属性同时动画时,不同属性的时长差异如何混合”,一般给 0 就行,除非你的动画里既有位置变化又有缩放变化,且希望它们节奏一致。
如果是多属性混合、需要精确控制,用.interpolatingSpring(mass:stiffness:damping:initialVelocity:),这套参数和CASpringAnimation完全一致,好处是你从 UIKit 那边迁过来的参数可以原样复用,不用重新调。
还有一个 SwiftUI 特有的注意点:withAnimation的弹簧只对“会改变布局或视觉属性”的 state 生效。如果你在withAnimation闭包里改的 state 没有直接驱动视图变化,动画不会发生,也不会报错。这个坑在那种“改一个中间状态、再由 computed property 推导”的写法里特别容易出现。
4. 复杂场景:交互式、可中断、可接力
4.1 手势跟随:弹簧不能用来“跟手”
这是个概念性的误区,值得单独讲。很多人第一次做拖拽面板,会想“我直接把手指位置作为目标值,用弹簧动画追过去,不就又跟手又弹了吗”。这么做的结果一定是:面板像泡在水里一样,永远慢手指一拍,而且抖。
原因很简单:弹簧是“追赶型”的动力学系统,它需要时间来加速。手指是瞬时的、无惯性的输入。这两个东西直接对接就是错配。
正确的做法是分阶段处理:
- 手指按下到松手之前,关闭动画,直接设置
transform跟随手指。这一阶段要的是零延迟的贴合感。 - 手指松手的瞬间,开启弹簧,目标值是“根据当前速度和位移推断出的终点”,初速度用手势的速度。
// 跟手阶段:直接赋值,不带动画 UIView.performWithoutAnimation { self.panelView.transform = CGAffineTransform(translationX: 0, y: currentY) }performWithoutAnimation在这个场景里是必须的,否则外面套着的动画上下文会污染这次赋值。
至于“松手时预测终点”,有两种策略。一种是按速度阈值二分类:速度超过某个值就判定为“要开”,否则判定为“要关”。另一种是按位移比例 + 速度加权:progress + velocity * 0.2 > 0.5就开。我实测下来,后者的手感明显更好,因为它对“慢慢拖到一半再松手”这种犹豫操作更宽容。
4.2 中断接力:为什么弹簧特别擅长这个
这是弹簧动画相对其他曲线最大的结构性优势,值得展开讲。
贝塞尔曲线动画是时间驱动的:给定起点、终点、时长、曲线,每一帧的位置由“已经过了多少时间”算出来。你没法在 t=0.3 的时候告诉它“现在改成往反方向去”,因为整条曲线是预先确定的。你能做的只有“取消旧的、从头开始一个新的”,观感上必然有速度断裂。
弹簧是状态驱动的:每一帧的位置由“当前位置、当前速度”加物理参数积分出来。所以中断的时候,你只要把当前的velocity作为新动画的初速度传进去,轨迹天然是连续的。
在UIViewPropertyAnimator体系下,这件事被封装得很好。但即使不用它,只用手写的方式,思路也是一样的:中断时读出 presentation layer 的当前值 + 估算当前速度,作为新动画的输入。
读呈现层的当前值:
let currentTransform = panelView.layer.presentation()?.affineTransform()这里有个必须强调的点:layer.presentation()返回的是动画过程中的中间状态,它是一个快照,而且只在动画运行期间有值。如果你在动画结束后去读,拿到的是 nil 或者 model layer 的值。所以要在中断的那一刻立刻读。
估算速度比较麻烦,Core Animation 没有直接暴露当前速度的 API。我的做法是用两次采样的位置差除以时间差——在中断前的两帧记录presentation().position,配合CACurrentMediaTime()算。这个精度对动画来说足够了。
4.3 链式动画与容器布局里的弹簧
还有一种常见需求:一个元素先弹出来,弹到一半的时候触发另一个动作,或者多个子元素依次弹入。
多元素错峰,我一般这么写:
func animateIn(cards: [UIView]) { for (index, card) in cards.enumerated() { card.alpha = 0 card.transform = CGAffineTransform(translationX: 0, y: 24) let delay = Double(index) * 0.04 UIView.animate( withDuration: 0.5, delay: delay, usingSpringWithDamping: 0.72, initialSpringVelocity: 0.4, options: [.beginFromCurrentState], animations: { card.alpha = 1 card.transform = .identity }, completion: nil ) } }delay的步长很关键。0.04 到 0.06 秒之间是“能感觉到有先后,但不觉得在等”的甜点区。低于 0.03 就感觉不出来,像是同时出现;高于 0.08 就会觉得后面的卡顿,节奏拖沓。这个数值我调过很多次,最后固定在 0.045。
用UIView.animate的delay参数而不是DispatchQueue.asyncAfter,好处是延迟期间如果视图被移除或者动画被取消,系统会一并处理,不会留个野闭包在后面执行。而且delay的动画是可以被beginFromCurrentState正确打断的。
至于“链式”,也就是 A 播完触发 B,我强烈建议不要依赖 completion 的时序。UIView.animate的 spring 版本实际时长不可控,CASpringAnimation的settlingDuration虽然是估算的,但至少是确定的。所以链式场景我一律用CASpringAnimation,读完settlingDuration再用DispatchQueue.main.asyncAfter安排下一步,时序可控。
5. 常见问题与排查实录
5.1 动画结束的“最后一帧跳变”
这是出现频率最高的问题,现象是:动画播到最后一刻,元素“啪”地跳到另一个位置,幅度很小但很扎眼。
三个原因,按概率排序:
第一个,用了CASpringAnimation但没设 model layer 终值。前面提过,动画只在呈现层做,model layer 没变。系统在动画结束时把呈现层切回 model layer 的旧值,视觉上就是“弹回去”。解法是 add 动画之前先把layer.xxx = 终值设好,并且fromValue要和当前 model layer 值一致(或者用fillMode = .backwards配合)。
第二个,fromValue和实际当前值不一致。比如 layer 现在的 scale 是 0.9,你把fromValue写成 1.0,动画一开始就会先跳到 1.0 再走。这种情况下应该用fromValue = nil,让系统自动取当前值。
第三个,动画被removeAnimation或者视图被复用。列表滑动的时候尤其常见:cell 被复用,旧的动画还在 key 上挂着,新的 cell 一出现就继承了那条动画的中间状态。解法是在prepareForReuse里layer.removeAllAnimations(),并且把 layer 的 model 属性重置。这一条下面单独展开。
5.2 列表复用导致的“满屏乱弹”
做列表入场动画,最常见的翻车场景是:快速滑动,一堆 cell 在屏幕上乱冲乱撞。
根因是 cell 复用机制和动画的状态残留。一个 cell 滑出屏幕,它的入场动画可能还在跑;下一秒它被复用成另一条数据,prepareForReuse里如果没清干净,新 cell 就会带着旧动画的残余状态出现。
我的处理模板:
override func prepareForReuse() { super.prepareForReuse() contentView.layer.removeAllAnimations() contentView.transform = .identity contentView.alpha = 1 // 如果有子视图带动画,也要一并清理 badgeView.layer.removeAllAnimations() badgeView.transform = .identity }关键点是递归清理所有带过动画的 layer,不只是根 view。很多人只清了contentView.layer,子视图的动画还在跑。
第二个策略是只给“首次出现”的 cell 加动画。给 cell 一个hasAnimated标志,cellForRowAt里判断,播过就不再播。这样快速滚动的时候不会每次都触发,观感干净很多。代价是往回滚动时新复用的 cell 也得补一个动画,但那时候用户注意力在滚动上,简化处理反而不突兀。
第三,入场动画的触发时机不要放在cellForRowAt(那是布局之前),放在willDisplay里更合适,此时 cell 已经完成布局,动画起点是准确的。
5.3 掉帧与性能排查
弹簧动画本身的计算量极小,每条动画只是解一个二阶微分方程,CPU 开销可以忽略。真正掉帧的原因基本都在动画的动画属性上。
transform和opacity属于合成属性,走 GPU,代价低。frame、bounds.size、center会触发布局,如果动画的 view 内部有复杂的 Auto Layout 约束(尤其是嵌套的 Stack),每一帧都会重算布局,帧率立刻掉下去。我遇到过一次,一个带六层嵌套 Stack 的卡片,用frame做弹簧展开,iPhone 上看还行,老机型直接掉到 20 帧。
解法很简单:动画一律只改transform和opacity。需要改尺寸的时候,用CGAffineTransform(scaleX:y:),如果里面有文字会变形,那就把文字部分单独拎出来不跟着缩放,或者预先做好两套布局,用alpha做交叉淡入。
还有一个容易被忽略的点:shadowPath的实时计算。如果动画的 layer 有shadowOpacity但没设shadowPath,每一帧系统都要根据当前的 shape 重新光栅化阴影,这个开销非常大。给阴影设一个静态的shadowPath,或者干脆在动画期间把阴影关掉、动画结束再打开,帧率立刻回来。
5.4 常见问题速查表
| 现象 | 最可能原因 | 处理方式 |
|---|---|---|
| 动画最后一帧跳变 | model layer 终值没设 | add 动画前先设 layer 属性终值 |
| 动画完全不生效 | fromValue/toValue类型不匹配 keyPath | 按 keyPath 要求包装 NSValue / NSNumber |
| 动画瞬间完成 | settlingDuration在 add 之后才读,值为 0 | 在 add 之前读取并赋给 duration |
| 快速点击时抖动 | 缺少.beginFromCurrentState | 在 options 中加入该标志 |
| 松手瞬间动画顿一下 | 中断时没传初速度 | 用continueAnimation(withTimingParameters:) |
| 列表滚动时元素乱弹 | cell 复用未清理动画 | prepareForReuse里递归removeAllAnimations |
| 老机型掉帧 | 动画了 frame 或触发了布局 | 改用transform+opacity |
| 进度条拖动无反应 | animator 处于 running 状态 | 先pauseAnimation()再改fractionComplete |
| 反向不生效 | isReversed设置时机在continueAnimation之后 | 调到之前设置 |
| 弹簧“太软”但没有回弹 | stiffness偏低而非damping偏高 | 先提stiffness,再微调damping |
这张表里的每一条我都至少真机验证过一次,可以直接当排查清单用。遇到问题先从表里对一遍,能省下大量试错时间。
6. 我在实际项目里的一些体会
最后聊几件不太“技术”、但我觉得比参数更值得分享的事。
弹簧动画最怕的是“到处都在弹”。我见过一个版本,列表入场弹、按钮点击弹、页面切换弹、连滚动到顶部那个回弹都要单独加一个自制弹簧——整个 App 看下来像果冻做的,非常廉价。后来我们把规范收了一下:全局只保留三档弹簧,弱反馈(按钮、开关)阻尼比 0.6 左右、周期 0.35 秒;中反馈(卡片、弹窗)阻尼比 0.75、周期 0.45 秒;强反馈(全屏面板)阻尼比 0.85、周期 0.6 秒。所有地方只能从这三档里选,不许自由发挥。这么一收,反而整体质感提升明显——因为节奏统一了,用户形成预期之后,动画就变成了“看不见的顺畅”。
时长超过 0.6 秒的弹簧基本都会显慢。尤其在大屏设备上,位移距离长,视觉上会觉得卡。如果你觉得动画不够“有分量”,第一反应不该是拉长时长,而是压小阻尼比让回弹更明显——人对“有回弹”的感知是强于“有停留”的。这是一个反直觉的点,但调了这么多次,结论很稳定。
真机永远比模拟器看起来快。这句话我第一次听的时候不信,后来发现是因为模拟器的刷新渲染和真机有细微差异,加上真机屏幕更大,同样的位移在真机上视觉速度更快。所以参数一定要在至少两代不同尺寸的真机上各看一遍,我一般是拿一台小屏、一台大屏对照,取一个都不难看的中间值。
动画的可访问性别忘了。UIAccessibility.isReduceMotionEnabled打开的时候,系统期望你把所有大幅度的位移、旋转、缩放动画降级成淡入淡出。这不是可选项,很多审核反馈里会提。我的做法是封一个animateIfMotionAllowed的辅助方法,内部判断这个开关,打开就直接做alpha过渡,弹簧一律不走。代码量不大,但能省掉不少麻烦。
调试期留个开关。我在自己的项目里一直保留一个AnimationDebugOverlay,长按任意位置能调出当前页面所有动画的参数,实时改。这个东西在跟设计同学对齐的时候价值极高——不用来回改代码重新装包,直接把手机递过去,“你自己拖一下看喜欢哪个”,三分钟定方案。我建议每个做动效比较多的项目都做一个,成本一天,回报是整个项目周期。
这些经验没什么体系,都是在具体项目里被磨出来的。参数表可以抄,结论可以背,但真正的判断力还是得靠自己把同一段动画在真机上反复看几十遍——看到你能闭着眼睛说出“这个阻尼比大概 0.65”的时候,基本上就出师了。