做者:凹凸曼-吖偉javascript
咱們在平時編程開發時,除了須要關注技術實現、算法、代碼效率等因素以外,更要把所學到的學科知識(如物理學、理論數學等等)靈活應用,畢竟理論和實踐相輔相成、密不可分,這不管是對於咱們的方案選型、仍是技術實踐理解都有很是大的幫助。今天就讓咱們一塊兒來回顧中學物理知識,並靈活運用到慣性滾動的動效實現當中。css
慣性滾動
(也叫 滾動回彈
,momentum-based scrolling
)最先是出如今 iOS 系統中,是指 當用戶在終端上滑動頁面而後把手指挪開,頁面不會立刻停下而是繼續保持必定時間的滾動效果,而且滾動的速度和持續時間是與滑動手勢的強烈程度成正比。抽象地理解,就像高速行駛的列車制動後依然會往前行駛一段距離纔會最終停下。並且在 iOS 系統中,當頁面滾動到頂/底部時,還有可能觸發 「回彈」 的效果。這裏錄製了微信 APP 【帳單】頁面中的 iOS 原生時間選擇器的慣性滾動效果:html
熟悉 CSS 開發的同窗或許會知道,在 Safari 瀏覽器中有這樣一條 CSS 規則:前端
-webkit-overflow-scrolling: touch;
複製代碼
當其樣式值爲 touch
時,瀏覽器會使用具備回彈效果的滾動, 即「當手指從觸摸屏上移開,內容會繼續保持一段時間的滾動效果」。除此以外,在豐富多姿的 web 前端生態中,不少經典組件的交互都必定程度地沿用了慣性滾動的效果,譬以下面提到的幾個流行 H5 組件庫中的例子。vue
爲了方便對比,咱們先來看看一個 H5 普通長列表在 iOS 系統下(開啓了滾動回彈)的滾動表現:java
weui 的 picker 組件git
明顯可見,weui 選擇器的慣性滾動效果很是弱,基本上手從屏幕上移開後滾動就很快中止了,體驗較爲很差。github
vant 的 picker 組件web
相比之下,vant 選擇器的慣性滾動效果則明顯清晰得多,可是因爲觸頂/底回彈時依然維持了普通滾動時的係數或持續時間,致使總體來講回彈的效果有點脫節。算法
慣性
一詞來源於物理學中的慣性定律(即 牛頓第必定律):一切物體在沒有受到力的做用的時候,運動狀態不會發生改變,物體所擁有的這種性質就被稱爲慣性。可想而知,慣性滾動的本質就是物理學中的慣性現象,所以,咱們能夠恰當利用中學物理上的 滑塊模型
來描述慣性滾動全過程。
爲了方便描述,咱們把瀏覽器慣性滾動效果中的滾動目標(如瀏覽器中的頁面元素)模擬成滑塊模型中的 滑塊
。並且分析得出,慣性滾動的全過程能夠模擬爲(人)使滑塊滑動必定距離而後釋放的過程,那麼,全流程能夠拆解爲如下兩個階段:
第一階段,滑動滑塊使其從靜止開始作加速運動;
在此階段,滑塊受到的 F拉
大於 F摩
使其從左到右勻加速前進。
須要注意的是,對於瀏覽器的慣性滾動來講,咱們通常關注的是用戶即將釋放手指前的一小階段,而非滾動的全流程(全流程意義不大),這一瞬間階段能夠簡單模擬爲滑塊均衡受力作 勻加速運動。
第二階段,釋放滑塊使其在只受摩擦力的做用下繼續滑動,直至最終靜止;
在此階段,滑塊只受到反向的摩擦力,會維持從左到右的運動方向減速前進而後停下。
基於滑塊模型,咱們須要找到適合的量化指標來創建慣性滾動的計算體系。結合模型和具體實現,咱們須要關注 滾動距離
、速度曲線
以及 滾動時長
這幾個關鍵指標,下面會一一展開解析。
對於滑動模型的第一階段,滑塊作勻加速運動,咱們不妨設滑塊的滑動距離爲 s1
,滑動的時間爲 t1
,結束時的臨界點速度(末速度)爲 v1
,根據位移公式
能夠得出速度關係
對於第二階段,滑塊受摩擦力 F拉
作勻減速運動,咱們不妨設滑動距離爲 s2
,滑動的時間爲 t2
,滑動加速度爲 a
,另外初速度爲 v1
,末速度爲 0m/s
,結合位移公式和加速度公式
能夠推算出滑動距離 s2
因爲勻減速運動的加速度爲負(即 a < 0
),不妨設一個加速度常量 A
,使其知足 A = -2a
的關係,那麼滑動距離
然而在瀏覽器實際應用時,v1
算平方會致使最終計算出的慣性滾動距離太大(即對滾動手勢的強度感應過於靈敏),咱們不妨把平方運算去掉:
因此,求慣性滾動的距離(即 s2
)時,咱們只須要記錄用戶滾動的 距離 s1
和 滾動時長 t1
,並設置一個合適的 加速度常量 A
便可。
經大量測試得出,加速度常量
A
的合適值爲0.003
。
另外,須要注意的是,對於真正的瀏覽器慣性滾動效果來講,這裏討論的滾動距離和時長是指可以做用於慣性滾動的範圍內的距離和時長,而非用戶滾動頁面元素的全流程,詳細的能夠看【啓停條件】這一節內容。
針對慣性滾動階段,也就是第二階段中的勻減速運動,根據位移公式能夠獲得位移差和時間間距 T
的關係
不可貴出,在同等時間間距條件下,相鄰兩段位移差會愈來愈小,換句話說就是慣性滾動的偏移量增長速度會愈來愈小。這與 CSS3 transition-timing-function
中的 ease-out
速度曲線很是吻合,ease-out
(即 cubic-bezier(0, 0, .58, 1)
)的貝塞爾曲線爲
曲線圖來自 在線繪製貝塞爾曲線網站。
其中,圖表中的縱座標是指 動畫推動的進程,橫座標是指 時間,原點座標爲 (0, 0)
,終點座標爲 (1, 1)
,假設動畫持續時間爲 2 秒,(1, 1)
座標點則表明動畫啓動後 2 秒時動畫執行完畢(100%)。根據圖表能夠得出,時間越日後動畫進程的推動速度越慢,符合勻減速運動的特性。
咱們試試實踐應用 ease-out
速度曲線:
很明顯,這樣的速度曲線過於線性平滑,減速效果不明顯。咱們參考 iOS 滾動回彈的效果重複測試,調整貝塞爾曲線的參數爲 cubic-bezier(.17, .89, .45, 1)
:
調整曲線後的效果理想不少:
接下來模擬慣性滾動時觸碰到容器邊界觸發回彈的狀況。
咱們基於滑塊模型來模擬這樣的場景:滑塊左端與一根彈簧鏈接,彈簧另外一端固定在牆體上,在滑塊向右滑動的過程當中,當滑塊到達臨界點(彈簧即將發生形變時)而速度尚未降到 0m/s
時,滑塊會繼續滑動並拉動彈簧使其發生形變,同時滑塊會受到彈簧的反拉力做減速運動(動能轉化爲內能);當滑塊速度降爲 0m/s
時,此時彈簧的形變量最大,因爲彈性特質彈簧會恢復原狀(內能轉化成動能),並拉動滑塊反向(左)運動。
相似地,回彈過程也能夠分爲下面兩個階段:
滑塊拉動彈簧往右作變減速運動;
此階段滑塊受到摩擦力 F摩
和愈來愈大的彈簧拉力 F彈
共同做用,加速度愈來愈大,致使速度降爲 0m/s
的時間會很是短。
彈簧恢復原狀,拉動滑塊向左作先變加速後變減速運動;
此階段滑塊受到的摩擦力 F摩
和愈來愈小的彈簧拉力 F彈
相互抵消,剛開始 F彈 > F摩
,滑塊作加速度愈來愈小的變加速運動;隨後 F彈 < F摩
,滑塊作加速度愈來愈大的變減速運動,直至最終靜止。這裏爲了方便實際計算,咱們不妨假設一個理想狀態:當滑塊靜止時彈簧恰好恢復形變。
根據上面的模型分析,回彈的第一階段作加速度愈來愈大的變減速直線運動,不妨設此階段的初速度爲 v0
,末速度爲 v1
,那麼能夠與滑塊位移創建關係:
其中 a
爲加速度變量,這裏暫不展開討論。那麼,根據物理學的彈性模型,第二階段的回彈距離爲
微積分都來了,簡直無法計算……
然而,咱們能夠根據運動模型適當簡化 S回彈
值的計算。因爲 回彈第二階段的加速度
是大於 非回彈慣性滾動階段的加速度
(F彈 + F摩 > F摩
)的,不妨設非回彈慣性滾動階段的總距離爲 S滑
,那麼
所以,咱們能夠設置一個較爲合理的常量 B
,使其知足這樣的等式:
經大量實踐得出,常量
B
的合理值爲 10。
觸發回彈的整個慣性滾動軌跡能夠拆分紅三個運動階段:
然而,若是要把階段 a
和階段 b
準確描繪成 CSS 動畫是有很高的複雜度的:
b
中的變減速運動難以準確描繪;爲了簡化流程,咱們把階段 a
和 b
合併成一個運動階段,那麼簡化後的軌跡就變成:
鑑於在階段 a
末端的反向加速度會愈來愈大,因此此階段滑塊的速度驟減同比非回彈慣性滾動更快,對應的貝塞爾曲線末端就會更陡。咱們選擇一條較爲合理的曲線 cubic-bezier(.25, .46, .45, .94)
:
對於階段 b
,滑塊先變加速後變減速,與 ease-in-out
的曲線有點相似,實踐嘗試:
仔細觀察,咱們發現階段 a
和階段 b
的銜接不夠流暢,這是因爲 ease-in-out
曲線的前半段緩入致使的。因此,爲了突出效果咱們選擇只描繪變減速運動的階段 b
末段。貝塞爾曲線調整爲 cubic-bezier(.165, .84, .44, 1)
實踐效果:
因爲 gif 轉格式致使部分掉幀,示例效果看起來會有點卡頓,建議直接體驗 demo
咱們對 iOS 的滾動回彈效果作屢次測量,定義出體驗良好的動效時長參數。在一次慣性滾動中,可能會出現下面兩種狀況,對應的動效時間也不同:
沒有觸發回彈
慣性滾動的合理持續時間爲 2500ms
。
觸發回彈
對於階段 a
,當 S回彈
大於某個關鍵閾值時定義爲 強回彈,動效時長爲 400ms
;反之則定義爲 弱回彈,動效時長爲 800ms
。
而對於階段 b
,反彈的持續時間爲 500ms
較爲合理。
前文中有提到,若是把用戶滾動頁面元素的整個過程都歸入計算範圍是很是不合理的。不難想象,當用戶以很是緩慢的速度使元素滾動比較大的距離,這種狀況下元素動量很是小,理應不觸發慣性滾動。所以,慣性滾動的觸發是有條件的。
啓動條件
慣性滾動的啓動須要有足夠的動量。咱們能夠簡單地認爲,當用戶滾動的距離足夠大(大於 15px
)和持續時間足夠短(小於 300ms
)時,便可產生慣性滾動。換成編程語言就是,最後一次 touchmove
事件觸發的時間和 touchend
事件觸發的時間間隔小於 300ms
,且二者產生的距離差大於 15px
時認爲可啓動慣性滾動。
暫停時機
當慣性滾動未結束(包括處於回彈過程),用戶再次觸碰滾動元素時,咱們應該暫停元素的滾動。在實現原理上,咱們須要經過 getComputedStyle
和 getPropertyValue
方法獲取當前的 transform: matrix()
矩陣值,抽離出元素的水平 y 軸偏移量後從新調整 translate
的位置。
基於 vuejs 提供了部分關鍵代碼,也能夠直接訪問 codepen demo 體驗效果(完整代碼)。
<html>
<body>
<div id="app"></div>
<template id="tpl">
<div ref="wrapper" @touchstart.prevent="onStart" @touchmove.prevent="onMove" @touchend.prevent="onEnd" @touchcancel.prevent="onEnd" @transitionend="onTransitionEnd">
<ul ref="scroller" :style="scrollerStyle">
<li v-for="item in list">{{item}}</li>
</ul>
</div>
</template>
<script> new Vue({ el: '#app', template: '#tpl', computed: { list() {}, scrollerStyle() { return { 'transform': `translate3d(0, ${this.offsetY}px, 0)`, 'transition-duration': `${this.duration}ms`, 'transition-timing-function': this.bezier, }; }, }, data() { return { minY: 0, maxY: 0, wrapperHeight: 0, duration: 0, bezier: 'linear', pointY: 0, // touchStart 手勢 y 座標 startY: 0, // touchStart 元素 y 偏移值 offsetY: 0, // 元素實時 y 偏移值 startTime: 0, // 慣性滑動範圍內的 startTime momentumStartY: 0, // 慣性滑動範圍內的 startY momentumTimeThreshold: 300, // 慣性滑動的啓動 時間閾值 momentumYThreshold: 15, // 慣性滑動的啓動 距離閾值 isStarted: false, // start鎖 }; }, mounted() { this.$nextTick(() => { this.wrapperHeight = this.$refs.wrapper.getBoundingClientRect().height; this.minY = this.wrapperHeight - this.$refs.scroller.getBoundingClientRect().height; }); }, methods: { onStart(e) { const point = e.touches ? e.touches[0] : e; this.isStarted = true; this.duration = 0; this.stop(); this.pointY = point.pageY; this.momentumStartY = this.startY = this.offsetY; this.startTime = new Date().getTime(); }, onMove(e) { if (!this.isStarted) return; const point = e.touches ? e.touches[0] : e; const deltaY = point.pageY - this.pointY; this.offsetY = Math.round(this.startY + deltaY); const now = new Date().getTime(); // 記錄在觸發慣性滑動條件下的偏移值和時間 if (now - this.startTime > this.momentumTimeThreshold) { this.momentumStartY = this.offsetY; this.startTime = now; } }, onEnd(e) { if (!this.isStarted) return; this.isStarted = false; if (this.isNeedReset()) return; const absDeltaY = Math.abs(this.offsetY - this.momentumStartY); const duration = new Date().getTime() - this.startTime; // 啓動慣性滑動 if (duration < this.momentumTimeThreshold && absDeltaY > this.momentumYThreshold) { const momentum = this.momentum(this.offsetY, this.momentumStartY, duration); this.offsetY = Math.round(momentum.destination); this.duration = momentum.duration; this.bezier = momentum.bezier; } }, onTransitionEnd() { this.isNeedReset(); }, momentum(current, start, duration) { const durationMap = { 'noBounce': 2500, 'weekBounce': 800, 'strongBounce': 400, }; const bezierMap = { 'noBounce': 'cubic-bezier(.17, .89, .45, 1)', 'weekBounce': 'cubic-bezier(.25, .46, .45, .94)', 'strongBounce': 'cubic-bezier(.25, .46, .45, .94)', }; let type = 'noBounce'; // 慣性滑動加速度 const deceleration = 0.003; // 回彈阻力 const bounceRate = 10; // 強弱回彈的分割值 const bounceThreshold = 300; // 回彈的最大限度 const maxOverflowY = this.wrapperHeight / 6; let overflowY; const distance = current - start; const speed = 2 * Math.abs(distance) / duration; let destination = current + speed / deceleration * (distance < 0 ? -1 : 1); if (destination < this.minY) { overflowY = this.minY - destination; type = overflowY > bounceThreshold ? 'strongBounce' : 'weekBounce'; destination = Math.max(this.minY - maxOverflowY, this.minY - overflowY / bounceRate); } else if (destination > this.maxY) { overflowY = destination - this.maxY; type = overflowY > bounceThreshold ? 'strongBounce' : 'weekBounce'; destination = Math.min(this.maxY + maxOverflowY, this.maxY + overflowY / bounceRate); } return { destination, duration: durationMap[type], bezier: bezierMap[type], }; }, // 超出邊界時須要重置位置 isNeedReset() { let offsetY; if (this.offsetY < this.minY) { offsetY = this.minY; } else if (this.offsetY > this.maxY) { offsetY = this.maxY; } if (typeof offsetY !== 'undefined') { this.offsetY = offsetY; this.duration = 500; this.bezier = 'cubic-bezier(.165, .84, .44, 1)'; return true; } return false; }, // 中止滾動 stop() { const matrix = window.getComputedStyle(this.$refs.scroller).getPropertyValue('transform'); this.offsetY = Math.round(+matrix.split(')')[0].split(', ')[5]); }, }, }); </script>
</body>
</html>
複製代碼
歡迎關注凹凸實驗室博客:aotu.io
或者關注凹凸實驗室公衆號(AOTULabs),不定時推送文章: