1. 效果拆解:你要实现的是哪一种“到位后波纹”
先聊一个现象。我们见过不少 App 的列表在滚动到顶部或底部时,继续拖拽会有一段橡皮筋式的拉伸,松手后弹回来。很多团队的实现方式很直接:把contentOffset的越界值映射成列表自身的位移动画,松手后再用弹簧曲线还原。这样手感是对了,但视觉上总觉得少了点什么——列表像一块硬纸板,回弹就回弹了,没有“余韵”。
我最初接手这个改造时,产品只给了一句话:“滚动到头之后,松手要有一圈水波漾开的感觉。”这句话听起来简单,落地时其实要拆成两件事。第一件是拖拽越界时的表面形变,也就是列表边缘被拉扯出一个弧度;第二件是松手回弹瞬间的涟漪扩散,从手指按住的位置或列表底部边缘,向四周扩散一圈波纹。
这两种效果都不是简单改contentOffset能实现的。它们都需要一个动态变化的几何轮廓参与绘制,而这个轮廓最顺手的数学工具就是贝塞尔曲线。本文我会把两种效果的原理、触发时机判断、绘制细节和调试经验完整过一遍,给同样在做移动端动效的同事一个可复用的参考。
这套东西不挑技术栈,核心思路在 iOS、Android 上都能落地。区别只在于绘制 API 和滚动回调的获取方式。你只要理解了“贝塞尔控制点如何跟随滚动越界量变化”这一件事,剩下的就是平台 API 的翻译工作。适合正在做自定义下拉刷新、底部回弹、或者想给自己的列表加入“手感魔法”的移动端开发者阅读。
2. 为什么偏偏是贝塞尔:波纹形变的几何直觉与控制点策略
2.1 从一条弧线开始:三次贝塞尔能描述什么形状
先回到数学基础。三次贝塞尔曲线由四个点定义:起点 P0、终点 P3、以及两个控制点 P1、P2。曲线公式是:
B(t) = (1-t)³·P0 + 3(1-t)²·t·P1 + 3(1-t)·t²·P2 + t³·P3
其中 t 从 0 走到 1,每一个 t 对应曲线上一个点。关键点是:曲线本身不经过 P1、P2,但 P1、P2 决定了曲线在靠近两端时的“甩动方向”。
用生活化类比来理解。把 P0 和 P3 想象成两个图钉,控制点 P1、P2 是你按在桌面上的两根手指。你往某个方向搓动手指,夹在中间的细绳就会朝那个方向鼓起;手指力度越大,鼓起越明显。波纹效果本质就是一张被“搓动”的弹性表面,所以我们只需要动态挪动控制点,就能得到一块自然起伏的曲面边缘。
2.2 波纹轮廓的两段式拼接:不是一整条曲线,是若干条曲线接力
画一个完整的水波边界,一幅画布上通常需要不止一条贝塞尔曲线。常见的做法是把波纹轮廓拆成左半弧和右半弧,各自用一条三次贝塞尔描述。
假设波纹中心在 (cx, cy),当前扩散半径为 r。左半弧的起点是 (cx - r, cy),终点是 (cx, cy),控制点可以取 (cx - r·k, cy - h) 和 (cx - r·(1-k), cy - h),这里 h 是波峰高度,k 是控制点偏移比例。实际项目中我更习惯用四段拼接:上、下、左、右各一段弧,这样波纹看起来更接近圆形,而不会在两端出现明显的“尖角”。
每次刷新时,只重新计算控制点的坐标,路径本身(如 iOS 的UIBezierPath或 Android 的Path)复用同一个实例,只更新点坐标。这样能减少对象创建,也为性能优化留出余地。这一点后面第五章展开讲。
2.3 控制点随滚动越界量变化的映射关系
光会画静态波纹没有意义,我们要的是波纹高度随拖拽力度变化。这里我定义了两个关键变量:
overdrag:此次滚动越界量,具体指contentOffset超出边界的那部分绝对值。progress:由overdrag归一化得到的 0~1 之间的值,表示当前“拉了多少”。
波纹高度 h 可以表达为:
h = maxHeight · easeOutBack(progress)
这里easeOutBack是一种带回弹效果的缓动函数,它会让波纹在小位移时迅速跟上手指,在大位移时逐渐饱和,不会无限拉伸。实现上可以用一个带 overshoot 的三次缓动,比如:
function easeOutBack(t) { const c1 = 1.70158 const c3 = c1 + 1 return 1 + c3 * Math.pow(t - 1, 3) + c1 * Math.pow(t - 1, 2) }maxHeight根据列表宽度设定,我习惯取屏幕宽度的 1/8 左右,太大显得假,太小感觉不明显。控制点的水平偏移量 k 则固定取 0.55 附近,不要轻易改,k 太接近 0 和 1 都会让曲线在端点处出现直角感。
3. 真正决定成败的触发时机:滚动边界判断与状态流设计
3.1 “到位”是个窗口,不是一个时间点
标题里写的是“滚动到位后”,但这个“到位”不是didEndDragging那一瞬间,而是一段连续过程。用户从开始越界拖拽,到松手回弹,再到涟漪扩散结束,整个过程可能只有 0.6 秒,但内部至少经历了四个状态:
| 状态 | 触发时机 | 视觉重点 |
|---|---|---|
| 拉扯中 | contentOffset 越界且手指按住 | 边缘贝塞尔弧线抬高 |
| 悬停 | 拉扯但未松手 | 弧线稳定在峰值 |
| 回弹 | 松手,系统弹性动画开始 | 弧线快速回落 |
| 涟漪 | 弹性动画越过平衡点瞬间 | 波纹扩散 + 淡出 |
我早期犯过的错误是只处理“松手”状态,结果拖拽过程中列表没有任何反馈,松手时突然出现一段波纹动画。视觉上是割裂的,用户并不会觉得这是同一个交互。正确做法是把“拉扯”阶段和“松手”阶段的形变连续起来,贝塞尔控制点的动画从拉扯阶段就开始跑了。
3.2 获取滚动越界事件的三种途径
不同平台、不同滚动容器获得越界量的方式不一样,我把常用的三类列出来,你根据现有架构选用。
第一种是UIScrollView 系的scrollViewDidScroll:回调(iOS)。这里要注意,越界时contentOffset可能被系统钳制,无法直接读到负值。保险做法是读取contentInset与adjustedContentInset的组合值,或者用panGestureRecognizer的translation换算。我在某项目中用 iOS 17 的scrollViewDidChangeContentOffset:新回调,读到的越界值准确很多。
第二种是Android 的onOverScrolled+NestedScrollingParent。手写嵌套滚动时,越界量可以从dispatchNestedPreScroll的返回值中拿到。如果用的是RecyclerView,建议在onScrolled中同时判断canScrollVertically(1)和canScrollVertically(-1)的状态差,间接推算越界量。最稳妥的场景是系统已经替你做完了橡皮筋,你只需要监听。
第三种是通用的手势层判断,适用于 Flutter 或自绘引擎。监听手势位移在边界之外的累积值,自己维护一段“虚拟偏移量”。这也是最灵活的一类,因为它不受容器到底允不允许越界影响,你可以随时让波纹出现或消失。
3.3 用状态机管理动画打断
这几个状态之间还可能有互相打断的情况。用户在回弹动画进行到一半时再次按住列表,此时必须立刻取消涟漪动画,把控制点“拉回”手指对应的位置上。没有状态机的话,会出现波纹和回弹动画同时跑、互相叠加的鬼影效果。
我维护状态时用的是四个枚举值:
enum RippleState { IDLE, // 静止 PULLING, // 拉扯中 SETTLING, // 回弹中 RINGING // 涟漪扩散中 }任何一次新的事件进来,先检查当前状态再决定如何处理。比如PULLING状态下收到新的拖动位移,就更新控制点;SETTLING状态下收到touch down,就立刻 kill 掉所有动画,回到PULLING并重置起点。这套状态机的逻辑很朴素,但能挡住大部分动画并发问题。
4. 波纹绘制的实现细节:从路径生成到涟漪叠加
4.1 拉扯阶段的单弧形变
先说拖拽越界时的表面形变。以列表顶部下拉为例,需要让列表边缘“鼓起来”。此时我会取列表当前可见区域的顶部两个角作为 P0、P3,然后用一条贝塞尔曲线把顶部边缘替换成下凹或上凸的弧线。
代码思路大致是这样(以类 Swift 语法为例):
let path = UIBezierPath() path.move(to: leftCorner) let midX = (leftCorner.x + rightCorner.x) / 2 let controlY = leftCorner.y + maxHeight * easeOutBack(progress) path.addCurve( to: rightCorner, controlPoint1: CGPoint(x: midX - controlOffset, y: controlY), controlPoint2: CGPoint(x: midX + controlOffset, y: controlY) )controlOffset我取 60~90 pt,宽度小的设备取小值。关键判断是progress越大,controlY越低(下拉场景),弧线越陡。但在视觉上,弧线最高点其实略偏向起始端,所以两个控制点的x不要严格对称分布,让靠近手指按入的一侧控制点x外移一点,看起来更像是被“拎起来”。
4.2 松手回弹瞬间的涟漪扩散
回弹瞬间我采用的是中心圆波扩散方案。触发点是本次拖拽开始时手指按下的位置(或列表边缘中点,看交互预期)。
每帧更新“涟漪半径”和“波纹透明度”:
- 半径 r:从 0 线性插值到列表对角线的一半,时长约 0.35~0.5 秒。
- 透明度:线性从 0.6 降到 0。
- 波峰高度:与半径呈正比,但用
sin(r · frequency)调制出起伏。
一条标准的涟漪轮廓由两层路径叠加:内层波峰和外层波谷。内层路径使用贝塞尔弧线建立,外层的控制点偏移方向相反。两层绘制时使用不同的 alpha,能形成水波的光影感。
照射区域的填充我有时不用路径封闭,而是用径向渐变包裹路径边缘。这样既能看到贝塞尔轮廓,又不会因为 alpha 抖动出现锯齿。在移动端,渐变的绘制成本高于纯色填充,所以我在涟漪数量多时改用预渲染的一层CAGradientLayer(iOS)或RadialGradient(Android 带缓存)作为底,只保留一条贝塞尔描边,渲染开销一下子降下来。
4.3 双平台绘制 API 对照
| 环节 | iOS 方案 | Android 方案 |
|---|---|---|
| 贝塞尔路径 | UIBezierPath | Path.cubicTo / quadTo |
| 实时刷新 | CADisplayLink / UIView animation | ValueAnimator + Choreographer |
| 渐变色填充 | CAGradientLayer / UIGraphics | RadialGradient / SweepGradient |
| 动画曲线 | UIView spring / CASpringAnimation | SpringAnimation / ObjectAnimator |
iOS 上最省事的方式是继承UIView,重写draw(_:)或使用CAShapeLayer的path属性。CAShapeLayer能自动做边缘抗锯齿,但注意默认会开启离屏渲染,数量一多会有额外开销。Android 上我习惯重写View.onDraw,通过invalidate()每帧刷新,用HardwareAccelerated默认开启的画布做贝塞尔填充,抗锯齿直接设置Paint.ANTI_ALIAS_FLAG。
5. 手感与性能:调试波纹动效时最容易翻车的三个地方
5.1 弹性回弹与涟漪动画的“接力”节奏
回弹动画从松手到静止,时长一般是 0.3~0.5 秒。涟漪动画如果等回弹完全结束再开始,用户会觉得“顿了一下”。我的经验是:在回弹动画过了弹性 overshoot 的最大点之后立刻启动涟漪,而不是等它 settle。
判断 overshoot 最大点,可以用CASpringAnimation的settlingDuration配合 currentValue 对比目标值,或者直接在自定义动画里缓存“上一次偏移量”,当偏移量从大于目标变为小于目标的那一帧,就是启动涟漪的时机。这样做的效果是:列表回弹到最远处正好是波纹开始扩散的时刻,视觉上是“回弹的力传到水面上”,非常连贯。
5.2 滚动区域边缘的锯齿与圆角冲突
很多 App 的列表不是直角的,有圆角。把贝塞尔弧线直接画在圆角上方时,会出现两条不同曲率的边线相接,边缘一秒破功。解决方案:绘制波纹用独立图层,并把这个图层裁到列表的圆角范围内,让波纹只出现在圆角之内。
// iOS 示例:将 rasterization 和 mask 结合 rippleLayer.mask = cornerMaskLayer rippleLayer.shouldRasterize = true rippleLayer.rasterizationScale = UIScreen.main.scaleAndroid 上用outlineProvider把裁剪轮廓设置成圆角矩形,再在clipToOutline开启后绘制波纹。不用剪裁的话,波纹会从直角边穿出来,看起来像屏幕上多了一道条状色带,非常丑。
5.3 绘制频率:永远不要每帧创建新路径对象
这个问题我在早期踩过。第一次实现时每帧addCurve新建一个UIBezierPath,低端机型上滚动列表时掉帧明显。后面改成复用路径对象,每帧只更新点坐标,再调用path.removeAllPoints()重建几何。配合CATransaction的批量提交,CG 层的压力小了很多。
Android 那边更明显,Path对象的reset()再重新 add 比反复 new 更安全,避免 GC 频繁触发。波纹出现时如果瞬时创建 6~8 条路径,低端机的 GC 卡顿足够吃掉整个动画的流畅度。
性能调优的方向我总结成一张表:
| 手段 | 生效场景 | 注意事项 |
|---|---|---|
| 复用路径实例 | 拉扯/涟漪高频刷新 | 每次填充前removeAllPoints |
| 开启光栅化 | 静止或慢速滑动 | 动画中关闭,结束再开启 |
| 控制波纹数量 | 涟漪扩散 | 同时存在波纹不超过 2 个 |
| 降低刷新区域 | 只刷新受影响区域 | setNeedsDisplayInRect/invalidate(dirtyRect) |
| 预计算缓动函数表 | 大量波纹同时动画 | 512 项查表即可,拟合误差 < 0.01 |
6. 完整代码骨架:一个可复用的波纹动画控制器
实操部分我给出一个伪代码级的状态控制器,它可以把“拉扯–回弹–涟漪”串成一个完整闭环。你可以直接翻译成目标平台的 API,关键逻辑不需要改动。
final class RippleEffectController { private(set) var state: RippleState = .idle private var overdrag: CGFloat = 0 private var lastSignificantOffset: CGFloat = 0 // 滚动容器回调里调用 func scrollViewDidScroll(_ offset: CGFloat) { let boundary = computeBoundary() let rawOverdrag = max(boundary - offset, 0) switch state { case .idle: if rawOverdrag > 0 { state = .pulling overdrag = rawOverdrag } case .pulling: overdrag = rawOverdrag updateWaveHeight(overdrag) case .settling, .ringing: // 手指按住,打断回弹或涟漪 cancelAnimations() state = .pulling overdrag = rawOverdrag updateWaveHeight(overdrag) } } func scrollViewDidEndDragging() { guard state == .pulling else { return } state = .settling springBack { [weak self] overshotPoint in self?.startRipple(at: overshotPoint) } } private func startRipple(at center: CGPoint) { state = .ringing let maxRadius = max(view.bounds.width, view.bounds.height) * 0.71 let duration: TimeInterval = 0.42 animate( from: 0, to: maxRadius, duration: duration, curve: .easeOut ) { [weak self] radius in self?.drawRippleProgress(radius / maxRadius) } completion: { [weak self] in self?.state = .idle self?.clearRipple() } } }对应的视图层只需要三个接口:
updateWaveHeight(_ value: CGFloat):更新拉扯阶段的贝塞尔控制点并重绘。drawRippleProgress(_ progress: CGFloat):绘制涟漪扩散进度。clearRipple():清除全部波纹图层。
我建议把动画参数全部收敛到一个配置结构体里,方便后续根据机型、屏幕大小调整。尤其的maxHeight和涟漪持续时间,两者需要配合缩放:屏幕越大,最大波高越大,同时涟漪需要更长的时间来跑完更大半径,否则大屏上波纹会看起来很“赶”。
7. 边界情况处理:这些场景最容易让波纹效果穿帮
7.1 列表内容不足一屏时
内容不满一屏时,contentSize本身小于视口高度,很多滚动容器会直接不允许滚动,越界手势可能压根不回调。此时需要自己用手势层判断:从顶部下拉或从底部上拉时,仍然计算虚拟的overdrag,并让波纹效果正常触发。这种情况下不能依赖contentOffset,要用手势的translationY换算。
7.2 波纹与系统下拉刷新冲突
如果列表本身嵌套了系统下拉刷新(比如 iOS 的 UIRefreshControl 或 Android 的 SwipeRefreshLayout),松开手时系统的刷新控件也会做一次弹性下降。此时如果我们的涟漪同时启动,会出现“两个东西都在下拉”的混乱。建议做一个开关,检测到系统刷新控件处于refreshing状态时,只在拉扯阶段显示贝塞尔形变,松手时不启动涟漪,把视觉反馈让位给刷新进度动画。
7.3 连续快滑导致的动画堆积
用户高速滑动列表撞到边缘,可能触发多次涟漪。如果不做节流,会看到波纹一个接一个叠在同一个位置,透明度叠加后变成一坨亮斑。我在控制器里加了时间戳判断:上次涟漪开始距今不足 80 毫秒时,直接丢弃新的触发请求,或者用后一个波纹“覆盖”前一个,而不是继续叠加。测试下来,80 毫秒是比较舒适的阈值,太短仍然会叠出噪点,太长手指感觉迟钝。
8. 收尾前的实操补充:一些调参方向与验收清单
波纹动效的“高级感”很大程度来自调参,不完全是公式写对就行。我总结出几个优先调整的方向,按影响力排序:
- 回弹弹簧的阻尼:阻尼系数越小回弹越夸张,但太小的阻尼会让波纹看起来轻飘飘的。我通常取阻尼比在 0.82~0.9 之间,回弹 1~2 次就结束。
- 贝塞尔波峰高度与拖拽量的比例:最忌线性等比。用
easeOutBack这类曲线让前 60% 的拖拽迅速拉起波峰,后 40% 几乎停滞,能塑造出“粘性表面被极限拉扯”的感觉。 - 涟漪的透明度衰减曲线:透明度从 1 到 0 用
linear或easeIn差别很大。我最终选了easeIn,波纹一开始淡出较快,后半程有一种“水流慢慢渗透”的残留感,和回弹的节奏更匹配。
验收时,多关注三个机器状态:中端 Android、低端 iOS、以及开启“减弱动态效果”辅助功能后的兼容表现。系统开启了减弱动态效果时,宜直接关闭涟漪与拉扯形变,只保留普通回弹,否则会被认定为诱发眩晕的动画。这一点做无障碍适配时尤其需要提前考虑。
最后分享一个调试小技巧:波纹动画的贝塞尔控制点很难用肉眼直接看出曲线形状,我通常在开发阶段把每个控制点用一个小圆点标记出来,连成辅助线,逐帧观察控制点运动轨迹是否符合直觉。调参时多画辅助线,比反复真机试手感效率高很多。等曲线形态满意了,再把这些辅助圆点关掉就可以发布了。