做列表滚动结束后的波纹效果,这件事起初不是我自己想出来的。当时有个产品需求:在分类列表里滚动到指定位置,也就是自动吸附到某个分组的锚点,希望在停下来的那一瞬间,锚点位置冒出一圈像水面波纹一样扩散的光圈,用来告诉用户“你已定位”。第一反应是简单,一个动画的事,但真做起来才意识到,这个效果要想做到位,关键不在波纹本身,而在“怎么让波纹看起来像是从滚动惯性里自然长出来的”,这就要用到贝塞尔曲线去控制波纹的扩散节奏和滚动到位的过程。这篇就完整记录一下我落地这个功能的过程,包括贝塞尔曲线到底用在哪、怎么监听滚动到位、波纹层怎么设计才不会挡触摸,以及调试中踩到的那些坑。
1. 先把问题拆开:滚动到位通知 + 波纹渲染
1.1 场景复盘:这个效果解决的是什么问题
列表滚动到位的波纹效果,本质上解决的是“状态变更的可感知性”。用户操作列表从位置A滚到位置B,如果B是一个有意义的分组节点,比如通讯录的字母索引、电商分类页的品类锚点,那么到达B的动作本身值得被强调,波纹就是一个很好的视觉强调手段。
但这里有个很容易被误导的方向:波纹不只是“画一个圈然后放大消失”,它需要和列表的滚动行为产生关联。用户快速甩动列表,列表由于惯性继续滑动,最终速度降为0并停在某个位置,波纹应该在这个“真正停下来”的瞬间出现,而不是在手指离开屏幕的时候就出现。所以第一步要解决的,是准确监听“滚动到位”这个时机,而不是仅仅监听滚动状态变化。
这里我踩过第一个坑:RecyclerView 的 OnScrollListener 里 onScrollStateChanged 传入 SCROLL_STATE_IDLE 时,并不代表列表已经停在了目标位置,它只表示滚动动画结束了。如果之前调用了 smoothScrollToPosition,IDLE 回调会在动画结束后触发,这时候位置是准的;但如果是手指惯性滑动,IDLE 只是速度归零的标志。所以“滚动到位”的判定需要结合业务上下文。
1.2 技术选型:为什么用贝塞尔曲线而不是普通插值器
波纹效果看起来是个动画,常规做法是写一个 Animation 或者 Animator,定义一个半径从 0 到最大值的插值,配上透明度从 1 到 0。不做贝塞尔曲线时,最常见的就是线性插值或内置的 AccelerateDecelerateInterpolator。那为什么还要自己折腾贝塞尔曲线?
原因有两点。第一,波纹扩散的“手感”太重要了。真实水面波纹扩散并不是匀速的,它在初始阶段有一个快速的激发过程,然后才慢慢向外推开并逐渐衰减。内置插值器的曲线形态是固定的,AccelerateDecelerate 是中间快两头慢,这和波纹扩散需要的“先爆一下再缓缓散开”完全不匹配。第二,贝塞尔曲线可以同时作用于多个属性。波纹半径是一个属性,波纹透明度是另一个属性,如果用同一个插值器套在不同属性上,它们的时间关系是绑死的;但我需要实现的是:半径扩散到大约 60% 时,透明度才开始明显衰减。这种多属性错峰,只靠一个插值器做不出来,必须各自用独立曲线控制。
所以我的做法是:用三阶贝塞尔曲线定义波纹半径随时间的变化,再用另一组控制点定义透明度衰减曲线,两条曲线独立控制,最后在 onDraw 里合成。这样做出来的波纹才有人眼熟悉的“扩散感”。
2. 贝塞尔曲线在波纹效果里的两个落点
2.1 先给不熟悉的朋友补三分钟数学课
贝塞尔曲线听起来唬人,其实可以理解为:给定几个控制点,描述一条从起点到终点的路径。一阶贝塞尔就是直线,二阶是抛物线型的弧线,三阶有两个控制点,能形成更复杂的 S 形曲线。波纹效果里用的是三阶,因为它能描述“先快后慢”或“先慢后快再平缓”这类复杂节奏。
三阶贝塞尔公式是 B(t) = (1-t)^3 * P0 + 3*(1-t)^2 * t * P1 + 3*(1-t) * t^2 * P2 + t^3 * P3,t 从 0 到 1。P0 和 P3 分别是起点和终点,P1 和 P2 是控制点。在动画插值场景里,P0 通常是 0,P3 通常是 1,我们只需要调 P1 和 P2 的坐标,就能改变整条曲线的形态。
注意,这里有个容易混淆的地方:贝塞尔曲线在动画里有两个截然不同的用途。一种是把贝塞尔曲线当作 Path 来画波纹本身的形状,比如画一圈带波浪纹路的涟漪;另一种是把贝塞尔曲线当作“时间-进度”映射函数,控制动画属性的取值节奏。我这个效果主要用的是第二种,也就是它作为插值函数的能力,同时波纹的边缘形状也用到了第一种。
2.2 用贝塞尔控制波纹扩散节奏的具体设计
我定的扩散节奏是这样的:动画总时长 600ms,半径从锚点位置向外扩散到 160dp,透明度从 0.6 衰减到 0。如果用线性插值,半径 600ms 均匀涨到 160dp,看起来就像从圆心画一个圆,平淡无奇。用贝塞尔后,我把半径曲线设置成 P1=(0.2, 0.8),P2=(0.7, 0.3),这样 t=0.3 时半径已经达到了 67% 左右,先快速激发,后半段逐渐放缓,波纹就有了“冲出去再缓下来”的物理感。
透明度曲线则是另一个形态。我用的控制点是 P1=(0.8, 0.6),P2=(0.9, 0.1),这意味着透明度在 0.7 之前都保持在一个相对高的水平,直到扩散快结束时才急剧衰减。这样做的好处是波纹在扩散过程中始终保持清晰的轮廓,不会在半路就淡得看不见;而最后半程的快速衰减,又能模拟出波纹能量消散的感觉。
如果你在代码里用 ValueAnimator,可以这样写:
val radiusAnimator = ValueAnimator.ofFloat(0f, maxRadius).apply { duration = 600L interpolator = PathInterpolator( Path().apply { moveTo(0f, 0f) cubicTo(0.2f, 0.8f, 0.7f, 0.3f, 1f, 1f) } ) addUpdateListener { radius = it.animatedValue as Float invalidate() } } val alphaAnimator = ValueAnimator.ofFloat(0.6f, 0f).apply { duration = 600L interpolator = PathInterpolator( Path().apply { moveTo(0f, 0f) cubicTo(0.8f, 0.6f, 0.9f, 0.1f, 1f, 1f) } ) addUpdateListener { alpha = it.animatedValue as Float invalidate() } }PathInterpolator 是 Android 5.0 之后提供的类,直接把三阶贝塞尔的 Path 传进去就能当插值器用,不用自己算公式。但要注意:Path 的起点必须是(0,0),终点必须是(1,1),控制点可以超出这个范围,超出部分会产生过冲效果,比如波纹半径最后一下突然涨过头再回弹,这个看需求,有时是加分项,有时是干扰项。
2.3 滚动过程也用了贝塞尔:让滚动“冲”到目标位置
波纹只是最后一下反馈,真正让用户觉得“顺滑”的,其实是滚动到位的过程。RecyclerView 的 smoothScrollToPosition 默认用的是内部插值器,实测下来有种“匀速冲过去然后急停”的生硬感。我在这个项目里给滚动过程单独做了贝塞尔插值。
思路是:不让 RecyclerView 自己滚动,而是监听滚动的每一帧,自己控制偏移量。具体做法是用 ValueAnimator 模拟滚动进度,每次动画回调时调用 recyclerView.scrollBy(dx, 0),其中 dx 由贝塞尔曲线把“剩余距离”映射出来。常见做法是动画初始时计算当前列表头部与目标位置的距离差 totalScroll,然后按贝塞尔曲线计算当前进度对应的已完成距离,每一步用“已完成距离 - 上次位置”作为本次 scrollBy 的增量。
val scrollAnimator = ValueAnimator.ofFloat(0f, 1f).apply { duration = 400L interpolator = PathInterpolator( Path().apply { moveTo(0f, 0f) cubicTo(0.1f, 0.6f, 0.3f, 1f, 1f, 1f) } ) addUpdateListener { val progress = it.animatedValue as Float val current = totalScroll * progress recyclerView.scrollBy((current - lastScroll).toInt(), 0) lastScroll = current } }这里 P1=(0.1, 0.6),P2=(0.3, 1),曲线几乎是“前段迅速推进,后段逐渐放缓”,模拟的是列表被甩过去然后慢慢停下来的感觉。控制点这么设之后,动画启动的头几十毫秒就已经走了全程的一半,后面在目标位置附近会有一段很轻微的“蠕行”,正好配合波纹在停止瞬间弹出。到位判定写在动画的 onAnimationEnd 里,此时滚动位置已知,波纹触发。
这一段的前提是 RecyclerView 的布局方向是横向或纵向都可以,scrollBy 的方向根据朝向取正负号。用这个方法要注意一个边界条件:目标位置可能超出当前总滚动范围,计算 totalScroll 时要先判断列表内容高度,避免滚出边界空转。
3. 波纹层与列表的层级设计
3.1 波纹画在哪里:一个覆盖在列表上层的透明 View
波纹不能画在列表的 item 里面,因为滚动过程中 item 会回收复用,波纹画在 item 上,滚出屏幕波纹就跟着没了。正确做法是准备一个覆盖层 View,尺寸和列表一样,透明背景,只负责画波纹。
我用的是自定义 FrameLayout 包住 RecyclerView,在 FrameLayout 里贴一个自定义 View。这个 View 的 onDraw 只做一件事:如果当前有波纹动画在跑,就把波纹按当前 radius 和 alpha 画出来;否则什么都不画,完全透明。层级关系如下:
<FrameLayout> <RecyclerView /> <RippleOverlayView android:id="@+id/rippleOverlay" android:layout_width="match_parent" android:layout_height="match_parent" android:clickable="false" android:focusable="false" android:pointerEvents="none" /> </FrameLayout>pointerEvents 在 Android 原生布局里不是标准属性,实际控制触摸穿透靠的是 clickable=false 和 View 默认不消费事件。自定义 View 不重写 onTouchEvent 时,触摸事件会直接漏给下面的 RecyclerView,实测不会影响滚动手势。
3.2 用 Canvas 画波纹:单圈扩散还是多圈涟漪
波纹的绘制本身不复杂。单圈效果就是 drawCircle:圆心是锚点坐标,半径由动画值决定,颜色由主题色决定,透明度由动画值决定。
但单圈太单调,我做了多圈涟漪。每圈之间错开启动时间,比如第二圈比第一圈晚启动 120ms。每圈独立计算自己的 radius 和 alpha,然后叠加绘制。由于它们的颜色相同且透明度都低于 0.5,叠加后同一个位置可能会有 Alpha 重叠导致颜色加深,所以每圈的颜色要预先用乘法把 Alpha 叠进 ARGB 里,避免 drawCircle 内部做混合影响性能。
绘制代码大致是:
override fun onDraw(canvas: Canvas) { super.onDraw(canvas) if (!isRunning) return for (ripple in ripples) { val color = (ripple.alpha.toInt() shl 24) or (baseColor and 0xFFFFFF) paint.color = color canvas.drawCircle(anchorX, anchorY, ripple.radius, paint) } if (SystemClock.elapsedRealtime() < animEndTime) { invalidate() } }这里 invalidate 是在动画未结束时每帧请求重绘。实测中发现用 ValueAnimator 的 addUpdateListener 驱动 invalidate 和直接在 onDraw 里根据时间判断是否继续刷新,两种方式都可以。后者更节省资源,因为它只在动画进行中请求重绘,动画一结束自动停止。
3.3 波纹扩散的形状:要不要用贝塞尔 Path 画水纹边缘
如果你不满足于一个边缘干净的圆形波纹,想做出真实水波那种带起伏的环形轮廓,那就需要用到贝塞尔曲线画 Path。
思路是以圆心为基准,把圆环拆成若干段,每段用二次贝塞尔曲线描述半径的轻微起伏。比如把 360 度拆成 12 段,每段的角度范围是 30 度,半径是 baseRadius + amplitude * sin(phase + angle * frequency),再用 cubicTo 连接。这样画出来的波纹边缘会有一种自然的“波动感”,比 drawCircle 的硬边缘更贴近水波。
但这里有个性能代价:12 段贝塞尔曲线拼接意味着 Path 上的点很多,再加上多圈叠绘,实际测试中低端手机上掉帧明显。我最后的结论是:稳妥方案用 drawCircle,追求视觉效果才上贝塞尔 Path,而且只对第一圈用。博文代码示例里的默认实现是带贝塞尔 Path 版本,但我在线上版本里通过开关切回了 drawCircle,因为列表滚动本来就是一个高频交互场景,动画期间不该让 UI 线程花太多时间在 Path 计算上。
3.4 波纹的锚点计算:怎么知道波纹该从哪个坐标爆开
波纹不是从屏幕中心爆开的,而是从“当前列表的锚点位置”爆开。锚点一般是某个目标 item 的中心坐标。计算方式不复杂,但容易出错。
第一种方式,如果滚动目标是用 scrollToPosition 完成的,那可以在滚动前先拿到目标 item 的位置,用 LayoutManager 的 getChildAt 找到 ViewHolder,再通过 getDecoratedBoundsInParent 或 getDecoratedBoundsInRecyclerView 换算坐标。这种方式的坑是:滚动是渐进的,item 位置在滚动过程中会变化,必须等滚动完全停止后再去拿坐标,否则拿到的坐标是滚动前的。
第二种方式,如果滚动是靠自定义动画完成的,那在动画结束时目标 item 已经停在目标位置,此时可以直接从 LayoutManager 拿坐标。我的做法是在 onAnimationEnd 里用 findViewHolderForAdapterPosition 拿到 ViewHolder,然后取 itemView 的中心点:
val holder = recyclerView.findViewHolderForAdapterPosition(targetPosition) val centerX = holder?.itemView?.left?.plus(holder.itemView.width / 2f) ?: recyclerView.width / 2f val centerY = holder?.itemView?.top?.plus(holder.itemView.height / 2f) ?: recyclerView.height / 2f这里要补一个坐标系换算:holder.itemView 的 left/top 是相对于 RecyclerView 的,波纹覆盖层 View 与 RecyclerView 是兄弟节点,坐标原点都是 FrameLayout,所以只要波纹层也是全屏铺满、位置重合,坐标可以直用。如果波纹层外部有 padding,或者 RecyclerView 不是从原点开始,需要额外加上偏移量。
如果找不到 ViewHolder,说明目标位置在屏幕外或者 item 被回收了,这时退化为用 RecyclerView 中心的 60% 位置作为锚点,至少保证动画能跑起来。这个兜底逻辑很关键,我第一次实现时没加,结果快速滚动出边界时崩溃过一次。
4. 踩坑记录:影响成败的四个细节
4.1 滚动到位判定不能只看 scrollState
前面说过 SCROLL_STATE_IDLE 不代表“业务上的到位”。我调试时发现,手指在快速滑动时松手,列表还会惯性滚很久才停,如果手指在滑动过程中点了一下,列表可能被 setScrollState 强制打断。这种情况下,IDLE 回调虽然触发了,但列表其实停在了一个中途位置,波纹如果在这个点爆开,效果就非常奇怪。
我的解决方式是给滚动动画加一个“目标位置校验”:动画结束时,读取当前 RecyclerView 第一个可见 item 的 position,和 targetPosition 对比,如果差值小于等于 1 才触发波纹。如果差距大于 1,说明滚动被打断了,此时仍然把列表慢慢地 smoothScroll 过去,等真正到位再触发。这样波纹始终只在视觉上“真正到位”时才出现。
4.2 波纹层挡住 RecyclerView 的触摸问题
这个坑很隐蔽。自定义 View 默认是不消费触摸事件的,但如果你在波纹层里用了 invalidate 频繁重绘,某些系统版本上触摸事件会先经过该层,出现几十毫秒的触摸延迟。表现为:滚动列表时偶尔会有一种“肉”的感觉,不跟手。
排查后发现问题出在波纹层的 View 配置上。修复方案是自定义 View 时显式在 onTouchEvent 里返回 false,同时设置 setWillNotDraw(false) 保证它能正常走绘制但绝不拦截事件。
class RippleOverlayView(context: Context) : View(context) { override fun onTouchEvent(event: MotionEvent): Boolean = false }另外,波纹层的绘制区域其实只有波纹扩散那一小部分,动画结束前可以用 clipBounds 控制 invalidate 的区域,减少全屏重绘的负担。具体做法是 invalidate(rippleRect),把重绘矩形限制在波纹所在范围,实测可以让低端机帧率提升约 12%。
4.3 滚动过程中要取消波纹,防止闪烁
如果用户刚触发了波纹,然后迅速再次滚动列表,这时候波纹还没播放完,它仍然在屏幕上扩散,与新的滚动画面叠加在一起会显得很脏。正确的行为是:列表一旦进入滚动状态,立即取消当前波纹动画,清空波纹状态。
我的实现是在 OnScrollListener 的 onScrollStateChanged 里判断:如果新状态不是 IDLE,就调用 rippleOverlay.cancelAnimation(),同时把波纹的 radius 和 alpha 归零。这里要注意 cancelAnimation 不能直接调用 animator.cancel(),因为 ValueAnimator 的 cancel 会触发 end 回调,而 end 回调里可能又带出波纹逻辑。我在 cancelAnimation 里设置了一个 isCancelled 标志,end 回调看到这个标志就直接返回。
4.4 硬件加速导致 Path 波纹渲染异常
贝塞尔 Path 画波纹时,在某些 Android 9 以下的机型上出现了波纹边缘锯齿异常放大,甚至 Path 形状撕裂的问题。这是因为 Path 在高阶贝塞尔曲线点数多的时候,硬件加速渲染的 gl 层做了快速近似,把曲线拉直了。
解决方法有两个:一是给波纹层 disable 硬件加速,canvas 用软件渲染,动画时长只有 600ms,性能损失可接受;二是减少 Path 的点数,把 12 段降为 8 段,锯齿感几乎看不到。我实测下来,8 段 + 硬件加速的效果最好,既不撕裂也不卡顿。
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.HONEYCOMB) { rippleOverlay.setLayerType(View.LAYER_TYPE_NONE, null) }LAYER_TYPE_NONE 是强制软件渲染,注意如果波纹层同时做了其他硬件层动画,这个设置会互相影响,最好在动画结束后再恢复硬件加速。
5. 实测参数与调参建议
5.1 我最终使用的参数表
| 参数 | 数值 | 说明 |
|---|---|---|
| 波纹动画总时长 | 600ms | 出厂手感,偏轻快 |
| 最大值半径 | 160dp | 覆盖一个 item 纬度的视觉范围 |
| 波纹启动延迟 | 60ms | 滚动停止后留一点肉眼感知缓冲 |
| 第二圈延迟 | 120ms | 相对第一圈晚启动 |
| 透明度初始值 | 0.6 | 太高会遮挡列表内容 |
| 透明度衰减曲线控制点 | P1=(0.8, 0.6),P2=(0.9, 0.1) | 后半程快速淡出 |
| 半径扩散曲线控制点 | P1=(0.2, 0.8),P2=(0.7, 0.3) | 前段快速激发 |
| 滚动动画时长 | 400ms | 短促有冲劲 |
| 滚动贝塞尔控制点 | P1=(0.1, 0.6),P2=(0.3, 1) | 前快后慢 |
这套参数在我的测试机上(骁龙 8 Gen 系列和一台低端骁龙 660 机型)表现都稳定,660 上帧率略降但无感。如果你的列表 item 比较大,比如每项高度超过 100dp,建议把最大半径提到 200dp 以上,否则波纹还没散开就撞出边界,视觉上像是被硬截断。
5.2 怎么判断波纹节奏“对味”
技术参数之外,波纹效果有一个很主观的“对味”标准。我的经验是:完成后录屏,闭着眼睛播放十遍,如果每一遍都让你觉得“列表到位了”,说明节奏对了;如果觉得“有点突”,说明波纹启动前的延迟太短,或者透明度太高。
一个反向验证技巧:故意把波纹透明度改成 0.9,你会立即发现波纹在抢占视觉焦点,把列表内容本身衬成了背景。把透明度压到 0.6 后,波纹和列表的关系就协调了,波纹像是一个“从列表内容上浮起来的影子”,而不是一个独立弹窗。
5.3 关于生命周期与内存的补充
波纹动画持有 Activity 引用,如果不做处理,页面退出时动画还在跑,持有了已销毁的 View,可能触发内存泄漏。我在 Fragment 的 onDestroyView 里调用 rippleOverlay.destroy(),里面取消所有 Animator 并置空画笔和 Path。
补充一点小技巧:波纹动画不要在列表的 adapter 的 onBindViewHolder 里触发,尤其是快速滑动时,onBindViewHolder 会频繁被调,极易造成波纹叠弹。触发逻辑统一放在滚动状态回调里管理,这样并发风险最小。
6. 常见问题排查实录
6.1 波纹没有出现
首先检查波纹 View 是否在 RecyclerView 的上层,可以用一个临时背景色验证层级;其次检查滚动到位判定是否真的走到了触发条件,我在代码里加了日志 tag=“RippleTrigger”,打印 targetPosition 和当前 firstVisiblePosition,排查时直接 logcat 过滤;最后排查是否被滚动取消逻辑误杀,比如手指微动触发了 onScrollStateChanged,动画直接取消了。常见原因就是最后一种。
6.2 波纹出现但卡顿
优先检查波纹 View 的绘制区域是否正确。如果波纹半径是 160dp,但每次都全屏 invalidate,低端机会卡顿。改成 invalidate(rippleRect) 后问题基本消失。还有一个可能:波纹层开了硬件层 LayerType,但 Path 内容又在软件层绘制,导致每帧把整个 View 的 bitmap 重传 GPU,那必然卡。去除 setLayerType 或者统一用软件层,按机型测再决定。
6.3 滚动目标位置计算偏移
列表滚动到 target 之后,锚点坐标偏差超过半个 item 高度。这个多半是坐标系换算失误。RecyclerView 里的 itemView 坐标是相对 RecyclerView 的,如果波纹层外层还有 RecyclerView 之外的偏移,就把偏移量减掉。我这边踩过的具体问题是 RecyclerView 是横向布局且带 paddingStart,锚点 X 坐标需要额外加上 RecyclerView 的 paddingLeft 才能对正。
6.4 波纹在部分机型上撕裂
如上所述,把 Path 段数从 12 降为 8,并配合硬件加速渲染后解决。如果仍然不行,就切回 drawCircle,毕竟多数用户看不出 8 段和圆形波纹的区别,但撕裂一眼就能发现。
6.5 快速连续触发导致波纹叠加异常
加互斥锁:如果前一个波纹还在播放,新触发请求直接忽略。注意用原子布尔量控制,不要在动画回调里做判断,因为回调顺序可能乱。
7. 最后再分享一个小经验
贝塞尔曲线控制插值的时候,最忌讳的就是只看曲线不看最终视觉效果。控制点的数值没有绝对标准,同一个 P1=(0.3, 0.7) 放在不同尺寸的列表、不同大小的锚点上,手感完全不同。我自己的方法是:先定一个感觉差不多的曲线,然后只在小范围内微调 P1 的 y 值,每次只改 0.1,观察三遍效果,找到“刚刚好”就停手。波纹这种反馈型动画,过度设计反而会让用户觉得界面“吵”。控制点宁可收敛,不要夸张。另外,波纹颜色最好从列表主题色里取,而不是单独定一个高亮色,用主题色透明度叠出来的波纹和整体 UI 的融合度远高于任意选色。