原生JS实现惯性滚动,给鼠标滚轮增加阻尼感,纵享丝滑

2023年 9月 7日 114.5k 0

前言

当我们在移动终端上滑动页面,手指离开屏幕后,页面的滚动并不会马上停止,而是在一段时间内继续保持惯性滚动,并且滑动阻尼感和持续时间与滑动手势的幅度成正比。

这种物理学效果的应用在移动端普及后,大部分笔记本触控板也都支持同样的效果。

然而鼠标滚轮的传感器通常采用光电或机械的方式运作,由一个旋转轴和一个传感器组成,旋转轴通常无法做出细微的距离控制,使得距离检测更像是段落式的,这些信号在传输到计算机后,并不能实现丝滑的滚动。

本文将教会你如何让鼠标滚轮也能够丝滑地操作网页,带来更舒适的页面惯性滚动体验,同时讲解其中技术原理与细节,用最少量的代码实现 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:「链接」

相关文章

JavaScript2024新功能:Object.groupBy、正则表达式v标志
PHP trim 函数对多字节字符的使用和限制
新函数 json_validate() 、randomizer 类扩展…20 个PHP 8.3 新特性全面解析
使用HTMX为WordPress增效:如何在不使用复杂框架的情况下增强平台功能
为React 19做准备:WordPress 6.6用户指南
如何删除WordPress中的所有评论

发布评论