ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

贝塞尔曲线驱动RecyclerView滚动到位波纹动效的工程实践

贝塞尔曲线驱动RecyclerView滚动到位波纹动效的工程实践 做列表滚动结束后的波纹效果这件事起初不是我自己想出来的。当时有个产品需求在分类列表里滚动到指定位置也就是自动吸附到某个分组的锚点希望在停下来的那一瞬间锚点位置冒出一圈像水面波纹一样扩散的光圈用来告诉用户“你已定位”。第一反应是简单一个动画的事但真做起来才意识到这个效果要想做到位关键不在波纹本身而在“怎么让波纹看起来像是从滚动惯性里自然长出来的”这就要用到贝塞尔曲线去控制波纹的扩散节奏和滚动到位的过程。这篇就完整记录一下我落地这个功能的过程包括贝塞尔曲线到底用在哪、怎么监听滚动到位、波纹层怎么设计才不会挡触摸以及调试中踩到的那些坑。1. 先把问题拆开滚动到位通知 波纹渲染1.1 场景复盘这个效果解决的是什么问题列表滚动到位的波纹效果本质上解决的是“状态变更的可感知性”。用户操作列表从位置A滚到位置B如果B是一个有意义的分组节点比如通讯录的字母索引、电商分类页的品类锚点那么到达B的动作本身值得被强调波纹就是一个很好的视觉强调手段。但这里有个很容易被误导的方向波纹不只是“画一个圈然后放大消失”它需要和列表的滚动行为产生关联。用户快速甩动列表列表由于惯性继续滑动最终速度降为0并停在某个位置波纹应该在这个“真正停下来”的瞬间出现而不是在手指离开屏幕的时候就出现。所以第一步要解决的是准确监听“滚动到位”这个时机而不是仅仅监听滚动状态变化。这里我踩过第一个坑RecyclerView 的 OnScrollListener 里 onScrollStateChanged 传入 SCROLL_STATE_IDLE 时并不代表列表已经停在了目标位置它只表示滚动动画结束了。如果之前调用了 smoothScrollToPositionIDLE 回调会在动画结束后触发这时候位置是准的但如果是手指惯性滑动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 * P3t 从 0 到 1。P0 和 P3 分别是起点和终点P1 和 P2 是控制点。在动画插值场景里P0 通常是 0P3 通常是 1我们只需要调 P1 和 P2 的坐标就能改变整条曲线的形态。注意这里有个容易混淆的地方贝塞尔曲线在动画里有两个截然不同的用途。一种是把贝塞尔曲线当作 Path 来画波纹本身的形状比如画一圈带波浪纹路的涟漪另一种是把贝塞尔曲线当作“时间-进度”映射函数控制动画属性的取值节奏。我这个效果主要用的是第二种也就是它作为插值函数的能力同时波纹的边缘形状也用到了第一种。2.2 用贝塞尔控制波纹扩散节奏的具体设计我定的扩散节奏是这样的动画总时长 600ms半径从锚点位置向外扩散到 160dp透明度从 0.6 衰减到 0。如果用线性插值半径 600ms 均匀涨到 160dp看起来就像从圆心画一个圆平淡无奇。用贝塞尔后我把半径曲线设置成 P1(0.2, 0.8)P2(0.7, 0.3)这样 t0.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:idid/rippleOverlay android:layout_widthmatch_parent android:layout_heightmatch_parent android:clickablefalse android:focusablefalse android:pointerEventsnone / /FrameLayoutpointerEvents 在 Android 原生布局里不是标准属性实际控制触摸穿透靠的是 clickablefalse 和 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 的融合度远高于任意选色。
返回列表