原生JS實(shí)現(xiàn)慣性滾動,給鼠標(biāo)滾輪增加阻尼感,縱享絲滑
前言
當(dāng)我們在移動終端上滑動頁面,手指離開屏幕后,頁面的滾動并不會馬上停止,而是在一段時(shí)間內(nèi)繼續(xù)保持慣性滾動,并且滑動阻尼感和持續(xù)時(shí)間與滑動手勢的幅度成正比。
這種物理學(xué)效果的應(yīng)用在移動端普及后,大部分筆記本觸控板也都支持同樣的效果。
然而鼠標(biāo)滾輪的傳感器通常采用光電或機(jī)械的方式運(yùn)作,由一個(gè)旋轉(zhuǎn)軸和一個(gè)傳感器組成,旋轉(zhuǎn)軸通常無法做出細(xì)微的距離控制,使得距離檢測更像是段落式的,這些信號在傳輸?shù)接?jì)算機(jī)后,并不能實(shí)現(xiàn)絲滑的滾動。
本文將教會你如何讓鼠標(biāo)滾輪也能夠絲滑地操作網(wǎng)頁,帶來更舒適的頁面慣性滾動體驗(yàn),同時(shí)講解其中技術(shù)原理與細(xì)節(jié),用最少量的代碼實(shí)現(xiàn) JS 鼠標(biāo)慣性滾動。
使用插件
要實(shí)現(xiàn)平滑的慣性滾動可以引入 lenis 這個(gè)庫,使用非常簡單:
npm i @studio-freight/lenis
const lenis = new Lenis()
function raf(time) {
lenis.raf(time)
requestAnimationFrame(raf)
}
requestAnimationFrame(raf)
演示效果可在官方 Demo 中體驗(yàn):https://lenis.studiofreight.com/
當(dāng)然本文不會這么簡單就結(jié)束,接下來我將帶你深入其中原理,動手來造一造這個(gè)輪子,代碼并不復(fù)雜,一起往下看吧。
實(shí)現(xiàn)原理
首先需要利用 DOM 事件禁止鼠標(biāo)滾動,轉(zhuǎn)為 JS 控制。通過滾輪事件中的 deltaY、deltaX 值獲取到最終滾動距離,瀏覽器幀繪制函數(shù) requestAnimationFrame 來逐幀設(shè)置頁面的 scrollTop 達(dá)到模擬滾動的效果,并利用線性插值或緩動函數(shù)等數(shù)學(xué)方法來計(jì)算變化過程中的值,最終達(dá)到平滑地滾動效果。
滾輪事件
滾輪事件(wheel) 取代了已被棄用的非標(biāo)準(zhǔn) mousewheel 事件,代碼如下。
const onWeel = (e) => {
e.preventDefault(); // 阻止默認(rèn)事件,停止?jié)L動
}
const el = document.documentElement
el.addEventListener('wheel', onWeel); // { passive: false }
幀繪制函數(shù)
window.requestAnimationFrame() 告訴瀏覽器——你希望執(zhí)行一個(gè)動畫,并且要求瀏覽器在下次重繪之前調(diào)用指定的回調(diào)函數(shù)更新動畫。
通過 JS 模擬頁面滾動實(shí)際可以看做是在執(zhí)行一個(gè)連續(xù)的動畫,這時(shí)候肯定就離不開與瀏覽器動畫息息相關(guān)的 requestAnimationFrame 函數(shù)了,我們需要知道它的回調(diào)函數(shù)會傳入一個(gè) DOMHighResTimeStamp 參數(shù),該參數(shù)與 performance.now() 返回值相同,表示開始執(zhí)行回調(diào)函數(shù)的時(shí)間。
const silky = new Silky()
function raf(time) {
silky.raf(time);
requestAnimationFrame(raf);
}
requestAnimationFrame(raf);
通過接收函數(shù)傳入的參數(shù) time,我們可以計(jì)算出每一幀持續(xù)時(shí)間,代碼如下。
class Silky {
timeRecord = 0 // 回調(diào)時(shí)間記錄
constructor({ content }) {
this.content = content || document.documentElement
const onWeel = (e) => {
e.preventDefault(); // 阻止默認(rèn)事件,停止?jié)L動
}
this.content.addEventListener('wheel', onWeel, { passive: false });
}
raf(time) {
const deltaTime = time - (this.timeRecord || time);
this.timeRecord = time;
console.log(deltaTime * 0.001) // 單位轉(zhuǎn)化為秒,該值后面計(jì)算時(shí)會用到
}
}
監(jiān)聽事件的第三個(gè)參數(shù)需設(shè)置為非被動模式,保證 preventDefault 可觸發(fā)。
虛擬滾動
添加如下一些參數(shù),并在類中定義 onVirtualScroll 方法,用于設(shè)置動畫更新。
class Silky {
timeRecord = 0 // 回調(diào)時(shí)間記錄
targetScroll = 0 // 當(dāng)前滾動位置
animatedScroll = 0 // 動畫滾動位置
from = 0 // 記錄起始位置
to = 0 // 記錄目標(biāo)位置
........
onVirtualScroll(target) {
this.to = target;
this.onUpdate = (value) => {
this.animatedScroll = value; // 記錄動畫距離
this.content.scrollTop = this.animatedScroll; // 設(shè)置滾動
this.targetScroll = value; // 記錄滾動后的距離
}
}
}
在滾動事件中調(diào)用 onVirtualScroll:
const onWeel = (e) => {
e.preventDefault(); // 阻止默認(rèn)事件,停止?jié)L動
this.onVirtualScroll(this.targetScroll + e.deltaY);
}
定義一個(gè) advance 方法在每一幀計(jì)算并執(zhí)行 onUpdate 更新視圖,不過我們現(xiàn)在還未進(jìn)行緩動計(jì)算,所以只需要把目標(biāo)位置賦值即可。
raf(time) {
......
this.advance()
}
advance() {
const value = this.to
this.onUpdate?.(value);
}
此時(shí)頁面就可以像往常一樣滾動了,并且是不依賴系統(tǒng)默認(rèn)事件的,由 JS 代理滾動效果,接下來我們繼續(xù)往方法里處理如何平滑過渡。
線性插值實(shí)現(xiàn)阻尼感
線性插值是一種簡單的插值方法,它使用線性函數(shù)來計(jì)算過渡過程中的值。簡單來說,它是一種通過直線來連接兩個(gè)點(diǎn),在兩個(gè)點(diǎn)之間按比例計(jì)算中間的數(shù)值。線性插值可以用于各種場景,比如在圖形學(xué)中計(jì)算兩個(gè)點(diǎn)之間的中間點(diǎn),或者在動畫中實(shí)現(xiàn)平滑的過渡效果,代碼實(shí)現(xiàn):
const lerp = (start, end, amt) => (1 - amt) * start + amt * end; // 對兩個(gè)值進(jìn)行線性插值 (0 <= amt <= 1)
我們將該方法用于每一幀計(jì)算當(dāng)中,默認(rèn)差值強(qiáng)度為 0.1:
advance() {
const value = lerp(this.targetScroll, this.to, this.lerp);
this.onUpdate?.(value);
}
這樣就實(shí)現(xiàn)了一個(gè)平滑的慣性滾動效果,但實(shí)際上由于幀率是可變的(受屏幕刷新率影響),每幀之間的插值距離也會有所不同,要進(jìn)一步優(yōu)化阻尼效果還需要在線性插值的基礎(chǔ)上增加阻尼系數(shù)和時(shí)間步長,目前大部分顯示器在 60 FPS 左右就能讓人眼的感受流暢不卡頓了,修改代碼如下:
const damp = (x, y, lambda, dt) => lerp(x, y, 1 - Math.exp(-lambda * dt)) // 阻尼效果
advance(deltaTime) {
const value = damp(this.targetScroll, this.to, this.lerp * 60, deltaTime)
this.onUpdate?.(value);
}
deltaTime 在前面講 requestAnimationFrame 已經(jīng)計(jì)算過了,只需要在調(diào)用時(shí)傳入 advance 當(dāng)中,單位需轉(zhuǎn)化為秒。
修改后可能你并不會感覺到有明顯的差異,如果在高刷新率的顯示器上兩者的流暢度差異就會很明顯了。關(guān)于 damp 函數(shù)的具體原理較為復(fù)雜,lenis 的作者參考了一篇2016年的文章來實(shí)現(xiàn)的,鏈接我放在了文末。
緩動函數(shù)
除了使用線性插值來實(shí)現(xiàn)平滑滾動,還可以使用常見的緩動函數(shù)來計(jì)算。
const clamp = (min, input, max) => Math.max(min, Math.min(input, max)) // 獲取一個(gè)中間值
class Silky {
........
currentTime = 0 // 記錄當(dāng)前時(shí)間
duration = 0 // 滾動動畫的持續(xù)時(shí)間
........
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 表示一個(gè)從 0 到 1 的線性進(jìn)度值,通過代入緩動函數(shù)計(jì)算得出 easedProgress 緩動進(jìn)度,最后將緩動進(jìn)度乘以起始值和目標(biāo)值之間的差,加上起始值而得到當(dāng)前幀應(yīng)該推進(jìn)的值。
不同的緩動函數(shù)會有不同的效果,可以傳入不同的 easing 函數(shù)來改變。
// 緩入緩出函數(shù)(ease-in-out)慢快慢
let easing = (t) => Math.min(1, 1.001 - Math.pow(2, -10 * t))
// 指數(shù)反向緩動函數(shù)(easeOut)先快后慢
let easing = (t) => 1 - Math.pow(1 - t, 2)
例子
以上代碼核心的部分就都已經(jīng)實(shí)現(xiàn)了,除 lenis 官方的演示 Demo 外,本文也舉兩個(gè)應(yīng)用慣性滾動的例子看看實(shí)際效果如何。
視頻滾動
在該例子中我使用了 scrolly-video 這個(gè)庫,它能將視頻每一幀解析繪制到 Canvas 上,然后基于滾動控制進(jìn)度,實(shí)現(xiàn)效果如下:
Gif 圖幀率有限,可以前往在線體驗(yàn)效果,視頻加載需要一點(diǎn)時(shí)間。
在線查看:https://code.juejin.cn/pen/7272280679629946939
scrolly-video 插件:https://www.npmjs.com/package/scrolly-video
年終總結(jié)
去年我做了一個(gè)掘金 2022 年終總結(jié)網(wǎng)頁,采用的是滾動控制動畫的交互,但效果在鼠標(biāo)操作時(shí)體驗(yàn)并不好,之前的卡頓感強(qiáng)烈,動畫細(xì)節(jié)也容易丟失:
現(xiàn)在加上這個(gè)慣性滾動,體驗(yàn)明顯就好很多了,在線查看演示:https://code.juejin.cn/pen/7178839138609659959
完整代碼
下面貼出文章的完整代碼,整個(gè) demo 的代碼差不多 50 行左右:
const lerp = (start, end, amt) => (1 - amt) * start + amt * end; // 對兩個(gè)值進(jìn)行線性插值 (0 <= amt <= 1)
const damp = (x, y, lambda, dt) => lerp(x, y, 1 - Math.exp(-lambda * dt)) // 阻尼效果
const clamp = (min, input, max) => Math.max(min, Math.min(input, max)) // 獲取一個(gè)中間值
class Silky {
timeRecord = 0 // 回調(diào)時(shí)間記錄
targetScroll = 0 // 當(dāng)前滾動位置
animatedScroll = 0 // 動畫滾動位置
from = 0 // 記錄起始位置
to = 0 // 記錄目標(biāo)位置
lerp // 插值強(qiáng)度 0~1
currentTime = 0 // 記錄當(dāng)前時(shí)間
duration = 0 // 滾動動畫的持續(xù)時(shí)間
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(); // 阻止默認(rèn)事件,停止?jié)L動
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; // 設(shè)置滾動
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);
實(shí)例化接收參數(shù)說明:
當(dāng)然這只是最基礎(chǔ)的例子,缺少一些邊界處理等,如在實(shí)際生產(chǎn)項(xiàng)目中使用,推薦安裝前面提到的 lenis 這個(gè)庫,它擁有更完善的功能,基礎(chǔ)使用方法和本例是一樣的。
碼上掘金中查看完整代碼及演示:
https://code.juejin.cn/pen/7272935569129209910
參考資料
lenis 開源地址: https://github.com/studio-freight/lenis
使用 LERP 進(jìn)行幀速率獨(dú)立阻尼 FRAME RATE INDEPENDENT DAMPING USING LERP:「鏈接」