ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

贝塞尔曲线+Canvas:列表滚动到位的水波纹动效实现

贝塞尔曲线+Canvas:列表滚动到位的水波纹动效实现 做移动端 H5 项目的时候经常要给列表滚动加一点仪式感。单纯滑到底就停住视觉上太生硬尤其是横滑 banner、Tab 联动列表、或者图文详情这种卡点式的滚动场景用户滑到位了界面却一点反馈都没有。后来我在一个项目里给列表滚动到位后的反馈做成了水波纹效果——滑到位瞬间目标位置泛起一圈涟漪扩散、变淡、消失。核心的动画节奏用贝塞尔曲线控制效果出来比我想象中自然得多。这篇博文就把这个效果的完整实现思路、贝塞尔曲线在其中的作用、列表滚动到位检测的方案、以及我在实际项目中踩过的坑整整齐齐梳理一遍。不管你是做 web 端 H5还是用小程序、甚至 React Native 里套 WebView这套思路都能直接迁移。文章后面会给出完整的 Canvas 叠加层实现代码和一套参数调节指南照着抄就能在项目里跑起来。1. 先把需求和效果形态定义清楚1.1 波纹效果到底解决什么体验问题先说清楚列表滚动到位后的波纹不是那种点击按钮的 Material Design 水波纹。那个是用户手指按下去瞬间的触摸反馈触发源是 touch 事件。我们要做的是滚动到达某个位置之后自动触发的一圈涟漪触发源是滚动结束这个状态。这个效果解决的核心问题是列表到达目的地之后用户缺乏到位感。比如横滑 banner 自动吸附到第 3 屏如果没有任何过渡反馈用户会怀疑是不是自己操作没生效比如长列表滑到底部如果直接硬停视觉上就是啪一下没了。波纹效果的作用就是给这个终止状态打一个视觉标记让用户潜意识里确认我已经到了这个位置。我做这个项目的时候还有个具体的背景banner 的页面指示器做得比较小用户横滑切页后往往注意不到页码变化交互评审的时候被提了个需求——切页完成时给个明显的动效反馈。当时也考虑过给指示器做放大动画后来觉得不如直接在当前屏中心起一个水波纹反馈面积大、指向明确同时也能覆盖滚动停止这个事件的视觉提示。1.2 波纹长什么样从水波纹到反馈涟漪实现之前先把波纹的形态定清楚。常见的做法有两种第一种是真实水波纹就是投石子进水面的那种多圈扩散。计算上要叠加多个环的相位差和振幅衰减渲染成本略高但视觉上很有质感。第二种是反馈涟漪只有一圈圆环从原点向外扩散透明度同步衰减扩散过程中线宽可以变细。视觉上简洁、干净性能开销也小。我最终选了第二种单圈涟漪 透明度线性衰减配合贝塞尔曲线控制扩散速度。原因很简单移动端列表滚动过程中本身就有 scroll 事件在频繁触发波纹动画如果再叠加高成本渲染低端机很容易掉帧。单圈涟漪用 Canvas 绘制成本极低还能保证在 60fps 下稳定运行。涟漪的扩散节奏我没有用线性而是用贝塞尔曲线做了非匀速化波纹刚出现的 15% 时间里快速撑开中间阶段略微放缓最后阶段再加速扩散并淡出。这个节奏跟到位感的心理预期很匹配——你刚看到它出现它已经完成了大部分扩散不会觉得拖沓。1.3 技术选型Canvas、CSS 还是 GIF确定形态之后技术选型也要过一遍。我实际对比过三种方案CSS 方案最快直接给列表加scroll-behavior: smooth再配合::after伪元素画一个圆用animation让它放大淡出。但问题很明显scroll-behavior的缓动节奏在 iOS Safari 上不可控而且伪元素的触发点固定拿不到滚动到位时目标元素的实际位置波纹位置只能写死。这种方案只适合极简的场景比如波纹永远出现在列表正中央。GIF 方案就算了波纹位置动态变化GIF 没法跟着实时计算坐标而且 GIF 撑不起透明度的平滑渐变。Canvas 是最靠谱的方案。原因有三个第一可以任意指定波纹圆心跟随滚动位置动态计算第二所有的动画参数都在 JavaScript 里控制贝塞尔曲线、扩散半径、透明度衰减全部可调第三Canvas 2D 画一个圆环的代价很低一次arcstroke而已完全不影响列表本身的滚动性能。2. 贝塞尔曲线波纹动效的引擎2.1 贝塞尔在动画里扮演的角色贝塞尔曲线在这个效果里不是画出来的曲线而是作为缓动函数easing function存在。缓动函数的作用是把动画的线性时间t映射成非线性进度e。线性时间是匀速流逝的物理时间非线性进度是现在应该走到哪儿的计算值。举个例子波纹持续时间是 800ms在第 400ms 的时候线性时间是 0.5。如果你直接让波纹半径 最大半径 × 0.5那就是线性扩散视觉上平静但没啥张力。而用贝塞尔缓动400ms 对应的进度可能是 0.35也可能 0.7取决于你选的曲线形状。这个映射关系就是贝塞尔曲线在动画里的核心价值。我们可以把贝塞尔缓动理解成一条时间-进度的路径。横轴是时间从 0 到 1纵轴是动画进度从 0 到 1。你拖动这条曲线的控制点就改变了动画在各个时间阶段的快慢。曲线越陡的地方进度跑得越快曲线越平的地方进度停滞感越强。2.2 从 CSS cubic-bezier 到自定义求解器很多前端同学平时就在用贝塞尔只是没意识到——CSS 里的cubic-bezier(0.25, 0.1, 0.25, 1)就是一条三次贝塞尔曲线。transition-timing-function和animation-timing-function都支持这个写法。浏览器内部会解析这条曲线把动画时间映射为动画进度。但到了 Canvas 里浏览器不再帮你做这个映射。你要自己写一个贝塞尔缓动求解器。核心问题变成已知贝塞尔曲线由两个控制点决定输入t0 到 1 的时间比例求曲线上对应的y值动画进度。这里的难点在于三次贝塞尔曲线的公式里x和y都同时依赖参数u。也就是说给定时间t你不能直接代入公式算出y必须先解出曲线走到哪个参数位置u时x(u)恰好等于t再用这个u去算y(u)。求解u的标准方法是牛顿迭代法或者更稳妥的二分法。牛顿法收敛快但个别曲线形态下可能震荡不收敛所以我习惯直接二分最多迭代 12 次就能达到肉眼完全感知不到的精度。代码里我通常会把求解器缓存起来同一个贝塞尔曲线只初始化一次函数闭包后续直接调用。// 三次贝塞尔求值P0(0,0), P3(1,1) function bezierX(u, x1, x2) { const v 1 - u; return 3 * v * v * u * x1 3 * v * u * u * x2 u * u * u; } function bezierY(u, y1, y2) { const v 1 - u; return 3 * v * v * u * y1 3 * v * u * u * y2 u * u * u; } function solveBezier(t, x1, x2) { // 二分法t 是 0~1 的时间比例 let lo 0, hi 1, u (lo hi) / 2; for (let i 0; i 12; i) { const xCur bezierX(u, x1, x2); if (Math.abs(xCur - t) 1e-6) break; if (xCur t) lo u; else hi u; u (lo hi) / 2; } return u; } function createCubicBezierEasing(x1, y1, x2, y2) { return function(t) { const u solveBezier(t, x1, x2); return bezierY(u, y1, y2); }; }这段代码的关键在于solveBezier里的二分区间不断缩小的过程。每当xCur t说明当前参数u对应的曲线横坐标偏小需要往右移动搜索区间反之则往左。12 次二分之后精度已经到2^-12约 0.0002动画上完全够用。2.3 为什么波纹扩展也要贝塞尔控制实现之前我试过用线性时间驱动波纹半径效果是出现——均匀扩大——消失。视觉上的问题是波纹一出现就匀速往外撑没有爆发感也没有收尾的干脆感。换上贝塞尔曲线之后我选了cubic-bezier(0.22, 0.61, 0.36, 1)这条曲线。它在起始阶段斜率适中波纹以较快速度从原点撑开中段几乎匀速尾段放缓后快速收尾。从测试效果看波纹撑开的前半程有力量感后半程淡出时又很干净不会拖着一截残影。另一条我试过而且也不错的曲线是cubic-bezier(0.12, 0.71, 0.33, 1)它的特点是前段更陡波纹几乎刚出现就冲到最大半径 70% 的位置适合需要极强即时反馈的场景。用在哪条曲线上取决于你对反馈速度的要求如果你的列表滚动到位后有 200ms 的 settle 时间波纹前段应该温和一些如果滚动直接进入静止状态波纹前段陡一点反馈感更明显。3. 列表滚动到位的检测3.1 判断滚动静默的关键代码波纹触发的第一步是先判断滚动到位了。“到位”不是指滚动距离为 0而是指滚动动作停止、位置保持稳定。在浏览器里scroll 事件会连续触发不能直接用scroll事件的最后一次触发作为依据因为惯性滚动会让 scroll 在停止前继续触发多次。我用的是静默窗口方案每次 scroll 事件触发时记录当前时间戳并清除之前的延时器重新设定一个 120ms 的延时器。如果 120ms 内没有新的 scroll 事件就认为滚动已经静默触发波纹。let scrollTimer null; element.addEventListener(scroll, () { clearTimeout(scrollTimer); scrollTimer setTimeout(() { triggerRippleAtTarget(); }, 120); }, { passive: true });120ms 这个值不是随手指定的。在 iOS Safari 上惯性滚动结束后系统会自动补一段肉眼几乎不可见的微调滚动这段微调的间隔通常在 60~100ms 之间。如果静默窗口设得太短比如 50ms波纹会在微调滚动完成前就触发位置偏移设得太长比如 200ms反馈延迟感明显。实测 120ms 既能避开惯性尾巴也能保证反馈不过时。3.2 惯性滚动和 iOS 回弹的影响做移动端滚动的同学都会遇到一个烦人的点iOS 原生滚动自带回弹rubber band。列表在到达底部边缘继续拉的时候scrollTop 会变成负值或者超出scrollHeight - clientHeight。这种回弹状态下列表其实还没到位如果这时候触发波纹位置会显得很怪异。处理方案是在静默回调里再加一层边界判断。判断条件是scrollTop是否落在合法范围内scrollTop -1 scrollTop scrollHeight - clientHeight 1。这个 1 容差是兼容部分 WebView 在小数像素上的误差。超出边界就不触发波纹等回弹动画结束、滚动重新稳定后再判断。另外惯性滚动在部分 Android WebView 里表现不稳定滚动停止的手感各家 ROM 都不同。我在一个低端 Android 机器上遇到过惯性滚动结束后 scrollTop 还会跳 2~3 个像素的情况这里静默窗口要配合边界判断用不能只靠延时。function isScrollStable() { const maxScroll element.scrollHeight - element.clientHeight; const minScroll 0; return element.scrollTop minScroll - 1 element.scrollTop maxScroll 1; }3.3 坐标归一化波纹在哪触发滚动到位后波纹的圆心位置需要从滚动坐标换算成屏幕坐标再做一次 devicePixelRatioDPR适配。我项目里的场景是横滑 banner每一屏宽度等于容器宽度滚动到位后波纹的圆心放在当前屏的正中心。计算公式很简单const pageIndex Math.round(element.scrollLeft / element.clientWidth); const centerX pageIndex * element.clientWidth element.clientWidth / 2;但注意这里的centerX是相对于滚动容器内部的坐标。波纹绘制在 Canvas 覆盖层上而 Canvas 覆盖层的坐标系原点在屏幕左上角所以还要减去容器相对页面的偏移。如果 Canvas 直接用position: fixed覆盖全屏还需要用getBoundingClientRect()拿到容器相对视口的left值。这时候还有一个视觉归属的细节波纹到底应该出现在目标屏的中心还是出现在用户手指点击/释放的位置。我做的时候两个都测了——目标屏中心的表现更稳定因为用户横滑的释放位置可能很偏波纹出现在偏位会让页面看起来脏中心点则无论从哪个方向滑过来波纹都在屏幕正中视觉上很舒服。4. 核心实现贝塞尔驱动的波纹绘制4.1 Canvas 叠加层搭建波纹效果需要一层覆盖在列表上面的透明 Canvas。搭建时要注意几个细节Canvas 的pointer-events必须设置为none否则这层 Canvas 会拦截列表的 touch 事件导致滚动失效Canvas 的宽高要乘上 DPR 做像素级适配否则在高分屏上看起来是糊的。const canvas document.createElement(canvas); canvas.style.cssText position: absolute; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; z-index: 10; ; container.appendChild(canvas); function resizeCanvas() { const dpr window.devicePixelRatio || 1; const rect container.getBoundingClientRect(); canvas.width rect.width * dpr; canvas.height rect.height * dpr; canvas.style.width rect.width px; canvas.style.height rect.height px; ctx.setTransform(dpr, 0, 0, dpr, 0, 0); }这里的setTransform(dpr, 0, 0, dpr, 0, 0)很重要。它把 Canvas 的绘图坐标系从物理像素映射回 CSS 像素。后续所有绘制代码里的坐标、半径都直接写 CSS 像素值不用在每条绘制逻辑里手动乘 DPR代码清晰很多也不会因为忘记换算出现位置偏移。4.2 贝塞尔缓动解析器实现这一节把第 2 章的缓动求解器封装成可复用的模块并且为了性能做了缓存。因为同一场景下波纹动画的每个帧都要调用缓动函数如果每帧都重新创建闭包或者重新计算控制点会造成不必要的 GC 压力。我的做法是初始化时把缓动函数创建好绘制循环里只调easing(progress)。class BezierEasing { constructor(x1, y1, x2, y2, samples 40) { this.cache new Array(samples 1); for (let i 0; i samples; i) { const u i / samples; this.cache[i] bezierY(u, y1, y2); } this.x1 x1; this.y1 y1; this.x2 x2; this.y2 y2; } solve(t) { const u solveBezier(t, this.x1, this.x2); return bezierY(u, this.y1, this.y2); } // 先用查表粗定位再精确求解适合多次调用 get(t) { const idx Math.floor(t * 40); const approx this.cache[idx]; const exact this.solve(t); return Math.abs(approx - exact) 0.01 ? approx : exact; } }查表 精确求解的组合是顺手加的优化。动画每帧多次走 Newton 迭代其实也扛得住但移动端 JS 主线程还要处理列表滚动事件能省则省。实测这个优化在低端 Android 上的掉帧率降低了大概 5% 左右不算明显但聊胜于无。4.3 波纹实体与动画主循环波纹实体是一个对象持有位置、创建时间、持续时长、最大半径等参数。每帧根据贝塞尔缓动计算当前半径和透明度然后把圆环画到 Canvas 上。class RippleRipple { constructor({ x, y, duration 900, maxRadius 60, ...beizier }) { this.x x; this.y y; this.birthTime performance.now(); this.duration duration; this.maxRadius maxRadius; this.easing new BezierEasing(beizier.x1, beizier.y1, beizier.x2, beizier.y2); this.alive true; } updateAndDraw(ctx) { const t (performance.now() - this.birthTime) / this.duration; if (t 1) { this.alive false; return; } const e this.easing.get(t); // 贝塞尔映射后的进度 0~1 const radius 2 e * this.maxRadius; const opacity 0.65 * (1 - e) * (1 - e); // 透明度二次衰减视觉更柔和 ctx.beginPath(); ctx.arc(this.x, this.y, radius, 0, Math.PI * 2); ctx.strokeStyle rgba(255, 255, 255, ${opacity}); ctx.lineWidth 1.5 (1 - e) * 1.5; ctx.stroke(); } }透明度我用的是(1 - e)^2二次衰减而不是线性衰减。线性衰减在波纹接近消失时透明度仍然偏高视觉上会出现一个明显的外圈轮廓突然啪地消失二次衰减让波纹在尾部迅速变得极淡消失更自然。动画主循环用requestAnimationFrame驱动每帧遍历当前存活的波纹实体绘制完毕后清掉已经结束的。如果没有任何存活波纹就取消 RAF等下次触发时再启动避免无意义的常驻渲染。class RippleManager { constructor(canvas) { this.ctx canvas.getContext(2d); this.ripples []; this.rafId null; } spawn(params) { this.ripples.push(new RippleRipple(params)); if (!this.rafId) { this.loop(); } } loop () { const { ctx } this; ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height); this.ripples this.ripples.filter(r { r.updateAndDraw(ctx); return r.alive; }); if (this.ripples.length 0) { this.rafId requestAnimationFrame(this.loop); } else { this.rafId null; } } }这里有个容易忽略的细节clearRect使用的宽度高度必须用 Canvas 物理像素而不是 CSS 像素。如果直接clearRect(0, 0, canvas.clientWidth, canvas.clientHeight)在 DPR 1 的屏幕上会残留上一帧的部分像素波纹画出来会拖着上一个位置的半透明残影。我在调试时吃过这个亏后来干脆存了一份canvas.width和canvas.height待用一劳永逸。4.4 完整集成代码把前面的部分拼到一起再配合列表滚动检测就是一个完整可跑的效果。下面这段代码我尽量保持了生产可用状态注释直接写在代码里。class ScrollRipple { constructor(scrollEl) { this.scrollEl scrollEl; // 创建 Canvas 覆盖层 this.canvas document.createElement(canvas); scrollEl.appendChild(this.canvas); // 初始化绘制上下文 this.ctx this.canvas.getContext(2d); this.dpr window.devicePixelRatio || 1; this.initSize(); this.manager new RippleManager(this.canvas); this.scrollTimer null; scrollEl.addEventListener(scroll, this.onScroll, { passive: true }); window.addEventListener(resize, this.initSize.bind(this)); } initSize() { const rect this.scrollEl.getBoundingClientRect(); this.canvas.width rect.width * this.dpr; this.canvas.height rect.height * this.dpr; this.ctx.setTransform(this.dpr, 0, 0, this.dpr, 0, 0); } onScroll () { clearTimeout(this.scrollTimer); this.scrollTimer setTimeout(() { if (!this.isStable()) return; this.trigger(); }, 120); }; isStable() { const el this.scrollEl; const maxScroll el.scrollHeight - el.clientHeight; return el.scrollTop -1 el.scrollTop maxScroll 1; } trigger() { // 当前屏的视觉中心作为波纹圆心 const pageIndex Math.round(this.scrollEl.scrollLeft / this.scrollEl.clientWidth); const x pageIndex * this.scrollEl.clientWidth this.scrollEl.clientWidth / 2; const y this.scrollEl.clientHeight / 2; this.manager.spawn({ x, y, duration: 900, maxRadius: 72, x1: 0.22, y1: 0.61, x2: 0.36, y2: 1 }); } } const ripple new ScrollRipple(document.querySelector(.banner-container));这个集成代码以横滑 banner 为基准改成竖滑列表时只需要调整trigger里x、y的计算方式。竖滑列表的波纹圆心通常放在列表中心或顶部到达的锚点位置具体看产品交互定义。5. 参数调优与项目实战记录5.1 控制点怎么挑贝塞尔曲线的两个控制点决定波纹的节奏这块非常值得单独说。我给不同项目的控制点组合做了记录整理成了一张表供参考场景控制点特点横幅切页到位反馈(0.22, 0.61, 0.36, 1)快起缓收节奏自然长列表到底部反馈(0.12, 0.71, 0.33, 1)爆发感强即时反馈下拉刷新成功提示(0.25, 0.1, 0.25, 1)经典 ease 曲线温和Tab 切换后反馈(0.4, 0, 0.2, 1)前段极快后段干脆挑控制点有个通用经验第一个控制点x1, y1主导动画前段的斜率x1 越小前段越早开始加速y1 越大前段越陡第二个控制点x2, y2主导尾段x2 越大尾段越晚减速y2 越小尾段越平缓。你想要爆发感就把第一个控制点的 y1 调大想要柔收尾就把第二个控制点的 y2 调小。这个规律我建议记下来调曲线的时候会很有用。5.2 波纹半径与透明度的配合波纹半径和透明度直接决定了视觉冲击力配合不好容易出现两种问题半径太大透明度也高波纹像一圈实线环砸在屏幕上喧宾夺主半径太小透明度太低用户感知不到反馈白做。我的经验值波纹最大半径取列表容器短边宽度的 20%~25%。横滑 banner 容器高度如果 200px半径 50~60px 合适竖滑列表容器是整屏半径不要超过 100px否则波纹会出画。透明度峰值控制在 0.55~0.7 之间配合二次衰减公式。线宽从 2.5px 开始线性减到 0.5px环扩散过程中线越来越细视觉上更像真水波纹的边缘。波纹持续时长我推荐 800~1000ms。低于 600ms 会感觉像闪光弹砰一下就没高于 1200ms 用户会开始等它消失反而拖慢交互节奏。900ms 是一个比较稳的中庸值大部分场景直接用。5.3 性能细节DPR、离屏与 RAF 合并性能这部分我踩了几个真坑直接列出来第一DPR 适配虽然简单但如果你忘了在resize时重新设置ctx的 transform旋转屏幕后波纹坐标会错位。resize事件里要同时改 canvas 宽高和 transform缺一不可。第二如果同一屏上有多个列表都需要这个效果——比如首页有 3 个横滑模块——千万不要每个列表单独创建 Canvas。我最初就是每个模块一个 Canvas结果低端机上滑动时同时跑 3 个 RAF 循环肉眼可见掉帧。后来改成单例管理器共享一个全屏 Canvas坐标换算时用每个模块的getBoundingClientRect()计算相对偏移。性能立刻好了。第三RAF 合并且按需启动。波纹不出现的时候不跑循环这一点我在代码里已经实现了。不要在constructor里直接requestAnimationFrame(this.loop)那样即使没有任何波纹Canvas 也会每帧执行clearRect白白消耗 GPU。5.4 真实项目中的效果反馈这个效果上线后我们团队内部评审和用户反馈有几个直观结论横滑 banner 切页的位置感明显提升了用户不需要再盯着页码小圆点确认自己滑到第几页波纹出现时给人这个页面是活的的感觉交互反馈的粘度更高。不过也有个意外收获——波纹对误触操作有掩盖作用。有的用户手指快滑边缘导致切页位置不稳波纹出现后会分散一部分对位置偏差的关注虽然这是心理层面的但确实降低了误触投诉率。还有一次测试反馈说波纹的白色在深色背景上很好看但切到浅色背景时几乎看不见。我做了个简单方案给波纹颜色增加一个可配置项默认白色浅色背景下切换成深灰色rgba(0, 0, 0, 0.18)。判断逻辑用容器背景的亮度值来自适应代码就几行但体验差异很大。6. 常见问题排查表6.1 波纹不触发这种问题 80% 出在滚动稳定判断上。首先检查是不是isStable()返回值一直为 false。常见原因是列表容器本身没有形成滚动比如内容高度小于容器高度或者scrollHeight取到的值是0。还有一个隐蔽的坑CSS 里如果设置了scroll-snap-type浏览器会在滚动结束后做一次微调对齐这个微调也会触发 scroll 事件相当于无限重置了缓存计时器波纹永远不触发。解决方案是把scroll-snap-stop: always和scroll-snap-align: start配合好让吸附对齐在单次滚动事件内完成。如果isStable()没问题再看trigger()里圆心坐标算得对不对。打印一下pageIndex和clientWidth确认取到的是滚动容器而不是小层的尺寸。6.2 波纹出现但卡顿卡顿优先检查是不是多个 Canvas 叠加层都在跑 RAF。如果是单例方案还卡重点看低端机上ctx.arc的半径是否过大。半径 200px 以上时 Canvas 的填充覆盖面积大GPU 压力增加。我测试发现半径 100px 内的圆环绘制成本几乎可以忽略200px 以上会开始出现肉眼可感知的掉帧。如果你必须画大半径考虑用离屏 Canvas 把小半径的环先画好再通过drawImage放大成本比实时arc低。有一个容易被忽略的点ctx.strokeStyle用了rgba(255, 255, 255, ${opacity})每次字符串拼接都会触发颜色解析和缓存操作。循环里可以预生成几档透明度对应的颜色字符串缓存减少字符串反复构造的开销。6.3 波纹位置偏移波纹圆心偏移的根源几乎都是坐标系不一致。排查顺序先确认 Canvas 覆盖层的定位父级是不是滚动容器本身如果是position: absolute而滚动容器没有position: relativeCanvas 会定位到更上层坐标自然偏。再确认ctx的 transform 是否已经用 DPR 设置过没设置 transform 的话物理像素和 CSS 像素 1:1 绘制DPI 越大偏移越大。最后检查getBoundingClientRect()的动态值——如果在滚动过程中调用而滚动还没有停止返回的坐标是瞬时值波纹会跟手偏移。正确做法是等滚动静默后再统一取一次坐标。6.4 其他诡异问题有一个 Windows 触屏设备上的怪问题手指滑动列表后滚动结束会触发两次 scroll 事件间隔约 50ms导致波纹触发两次。解决方法是给RippleRipple加一个最小间隔保护如果创建时间与上一次创建时间之差小于 150ms就丢弃新创建的涟漪。这种保护代码不多但能避免很多机型的重复触发场景。还有一个 Android 上常见的显示问题ctx.setTransform设置后如果 Canvas 的width在resize时被重新赋值初始化状态会被重置直接导致 transform 丢失、坐标错乱。所有 Canvas 尺寸变化都要走同一个initSize方法在里面统一处理尺寸和 transform不要分散到多处。写在最后的个人调试习惯我在真机调试这个效果的时候习惯把 Canvas 的pointer-events临时改成auto并加一个半透明背景色这样能直接看到波纹的触发位置是否准确。确认无误后再撤掉调试代码。这个习惯帮我排查了不少坐标偏移问题比单纯看代码要快得多。做动效调试眼见为实永远比脑内推演靠谱。
返回列表