前言
当我们在移动终端上滑动页面,手指离开屏幕后,页面的滚动并不会马上停止,而是在一段时间内继续保持惯性滚动,并且滑动阻尼感和持续时间与滑动手势的幅度成正比。
这种物理学效果的应用在移动端普及后,大部分笔记本触控板也都支持同样的效果。
然而鼠标滚轮的传感器通常采用光电或机械的方式运作,由一个旋转轴和一个传感器组成,旋转轴通常无法做出细微的距离控制,使得距离检测更像是段落式的,这些信号在传输到计算机后,并不能实现丝滑的滚动。
本文将教会你如何让鼠标滚轮也能够丝滑地操作网页,带来更舒适的页面惯性滚动体验,同时讲解其中技术原理与细节,用最少量的代码实现 JS 鼠标惯性滚动。
使用插件
要实现平滑的惯性滚动可以引入 lenis 这个库,使用非常简单:
npm i @studio-freight/lenis
const lenis = new Lenis()
function raf(time) {
lenis.raf(time)
requestAnimationFrame(raf)
}
requestAnimationFrame(raf)
演示效果可在官方 Demo 中体验:https://lenis.studiofreight.com/
当然本文不会这么简单就结束,接下来我将带你深入其中原理,动手来造一造这个轮子,代码并不复杂,一起往下看吧。
实现原理
首先需要利用 DOM 事件禁止鼠标滚动,转为 JS 控制。通过滚轮事件中的 deltaY、deltaX 值获取到最终滚动距离,浏览器帧绘制函数 requestAnimationFrame 来逐帧设置页面的 scrollTop 达到模拟滚动的效果,并利用线性插值或缓动函数等数学方法来计算变化过程中的值,最终达到平滑地滚动效果。
滚轮事件
滚轮事件(wheel) 取代了已被弃用的非标准 mousewheel 事件,代码如下。
const onWeel = (e) => {
e.preventDefault(); // 阻止默认事件,停止滚动
}
const el = document.documentElement
el.addEventListener('wheel', onWeel); // { passive: false }
帧绘制函数
window.requestAnimationFrame() 告诉浏览器——你希望执行一个动画,并且要求浏览器在下次重绘之前调用指定的回调函数更新动画。
通过 JS 模拟页面滚动实际可以看做是在执行一个连续的动画,这时候肯定就离不开与浏览器动画息息相关的 requestAnimationFrame 函数了,我们需要知道它的回调函数会传入一个 DOMHighResTimeStamp 参数,该参数与 performance.now() 返回值相同,表示开始执行回调函数的时间。
const silky = new Silky()
function raf(time) {
silky.raf(time);
requestAnimationFrame(raf);
}
requestAnimationFrame(raf);
通过接收函数传入的参数 time,我们可以计算出每一帧持续时间,代码如下。
class Silky {
timeRecord = 0 // 回调时间记录
constructor({ content }) {
this.content = content || document.documentElement
const onWeel = (e) => {
e.preventDefault(); // 阻止默认事件,停止滚动
}
this.content.addEventListener('wheel', onWeel, { passive: false });
}
raf(time) {
const deltaTime = time - (this.timeRecord || time);
this.timeRecord = time;
console.log(deltaTime * 0.001) // 单位转化为秒,该值后面计算时会用到
}
}
监听事件的第三个参数需设置为非被动模式,保证 preventDefault 可触发。
虚拟滚动
添加如下一些参数,并在类中定义 onVirtualScroll 方法,用于设置动画更新。
class Silky {
timeRecord = 0 // 回调时间记录
targetScroll = 0 // 当前滚动位置
animatedScroll = 0 // 动画滚动位置
from = 0 // 记录起始位置
to = 0 // 记录目标位置
........
onVirtualScroll(target) {
this.to = target;
this.onUpdate = (value) => {
this.animatedScroll = value; // 记录动画距离
this.content.scrollTop = this.animatedScroll; // 设置滚动
this.targetScroll = value; // 记录滚动后的距离
}
}
}
在滚动事件中调用 onVirtualScroll:
const onWeel = (e) => {
e.preventDefault(); // 阻止默认事件,停止滚动
this.onVirtualScroll(this.targetScroll + e.deltaY);
}
定义一个 advance 方法在每一帧计算并执行 onUpdate 更新视图,不过我们现在还未进行缓动计算,所以只需要把目标位置赋值即可。
raf(time) {
......
this.advance()
}
advance() {
const value = this.to
this.onUpdate?.(value);
}
此时页面就可以像往常一样滚动了,并且是不依赖系统默认事件的,由 JS 代理滚动效果,接下来我们继续往方法里处理如何平滑过渡。
线性插值实现阻尼感
线性插值是一种简单的插值方法,它使用线性函数来计算过渡过程中的值。简单来说,它是一种通过直线来连接两个点,在两个点之间按比例计算中间的数值。线性插值可以用于各种场景,比如在图形学中计算两个点之间的中间点,或者在动画中实现平滑的过渡效果,代码实现:
const lerp = (start, end, amt) => (1 - amt) * start + amt * end; // 对两个值进行线性插值 (0 Math.max(min, Math.min(input, max)) // 获取一个中间值
class Silky {
........
currentTime = 0 // 记录当前时间
duration = 0 // 滚动动画的持续时间
........
onVirtualScroll(target) {
this.currentTime = 0;
this.from = this.animatedScroll;
.........
}
advance(deltaTime) {
let value = 0
if (this.lerp) {
value = damp(this.targetScroll, this.to, this.lerp * 60, deltaTime)
} else {
this.currentTime += deltaTime
const linearProgress = clamp(0, this.currentTime / this.duration, 1)
const easedProgress = this.easing ? this.easing(linearProgress) : 1
value = this.from + (this.to - this.from) * easedProgress
}
this.onUpdate?.(value);
}
}
上面代码中 linearProgress 表示一个从 0 到 1 的线性进度值,通过代入缓动函数计算得出 easedProgress 缓动进度,最后将缓动进度乘以起始值和目标值之间的差,加上起始值而得到当前帧应该推进的值。
不同的缓动函数会有不同的效果,可以传入不同的 easing 函数来改变。
// 缓入缓出函数(ease-in-out)慢快慢
let easing = (t) => Math.min(1, 1.001 - Math.pow(2, -10 * t))
// 指数反向缓动函数(easeOut)先快后慢
let easing = (t) => 1 - Math.pow(1 - t, 2)
例子
以上代码核心的部分就都已经实现了,除 lenis 官方的演示 Demo 外,本文也举两个应用惯性滚动的例子看看实际效果如何。
视频滚动
在该例子中我使用了 scrolly-video 这个库,它能将视频每一帧解析绘制到 Canvas 上,然后基于滚动控制进度,实现效果如下:
Gif 图帧率有限,可以前往在线体验效果,视频加载需要一点时间。
在线查看:https://code.juejin.cn/pen/7272280679629946939
scrolly-video 插件:https://www.npmjs.com/package/scrolly-video
年终总结
去年我做了一个掘金 2022 年终总结网页,采用的是滚动控制动画的交互,但效果在鼠标操作时体验并不好,之前的卡顿感强烈,动画细节也容易丢失:
现在加上这个惯性滚动,体验明显就好很多了,在线查看演示:https://code.juejin.cn/pen/7178839138609659959
完整代码
下面贴出文章的完整代码,整个 demo 的代码差不多 50 行左右:
const lerp = (start, end, amt) => (1 - amt) * start + amt * end; // 对两个值进行线性插值 (0 Math.max(min, Math.min(input, max)) // 获取一个中间值
class Silky {
timeRecord = 0 // 回调时间记录
targetScroll = 0 // 当前滚动位置
animatedScroll = 0 // 动画滚动位置
from = 0 // 记录起始位置
to = 0 // 记录目标位置
lerp // 插值强度 0~1
currentTime = 0 // 记录当前时间
duration = 0 // 滚动动画的持续时间
constructor({ content, lerp, duration, easing = (t) => Math.min(1, 1.001 - Math.pow(2, -10 * t)) } = {}) {
this.lerp = isNaN(lerp) ? 0.1 : lerp
this.content = content || document.documentElement
this.duration = duration || 1;
this.easing = easing;
const onWeel = (e) => {
e.preventDefault(); // 阻止默认事件,停止滚动
this.onVirtualScroll(this.targetScroll + e.deltaY);
}
this.content.addEventListener('wheel', onWeel, { passive: false });
}
raf(time) {
if (!this.isRunning) return;
const deltaTime = time - (this.timeRecord || time);
this.timeRecord = time;
this.advance(deltaTime * 0.001)
}
onVirtualScroll(target) {
this.isRunning = true
this.to = target;
this.currentTime = 0;
this.from = this.animatedScroll;
this.onUpdate = (value) => {
this.animatedScroll = value; // 记录动画距离
this.content.scrollTop = this.animatedScroll; // 设置滚动
this.targetScroll = value; // 记录滚动后的距离
}
}
advance(deltaTime) {
let completed = false
let value = 0
if (this.lerp) {
value = damp(this.targetScroll, this.to, this.lerp * 60, deltaTime)
if (Math.round(this.value) === Math.round(this.to)) {
completed = true
}
} else {
this.currentTime += deltaTime
const linearProgress = clamp(0, this.currentTime / this.duration, 1)
completed = linearProgress >= 1
const easedProgress = completed ? 1 : this.easing(linearProgress)
value = this.from + (this.to - this.from) * easedProgress
}
this.onUpdate?.(value);
if (completed) this.isRunning = false
}
}
基本使用:
const silky = new Silky()
function raf(time) {
silky.raf(time);
requestAnimationFrame(raf);
}
requestAnimationFrame(raf);
实例化接收参数说明:
当然这只是最基础的例子,缺少一些边界处理等,如在实际生产项目中使用,推荐安装前面提到的 lenis 这个库,它拥有更完善的功能,基础使用方法和本例是一样的。
码上掘金中查看完整代码及演示:
https://code.juejin.cn/pen/7272935569129209910
参考资料
lenis 开源地址: https://github.com/studio-freight/lenis
使用 LERP 进行帧速率独立阻尼 FRAME RATE INDEPENDENT DAMPING USING LERP:「链接」