在移動(dòng)端 H5 中,時(shí)間選擇器(date-picker
)、省市區(qū)選擇器(area-picker
)等組件經(jīng)常會(huì)使用這樣的交互效果:
這個(gè) gif 是在【微信錢包 - 賬單】中錄制的 ios 原生時(shí)間選擇器吏口。可見(jiàn),當(dāng)用戶手指在選擇器上先是滑動(dòng)再?gòu)钠聊簧弦崎_(kāi)蒲稳,內(nèi)容會(huì)繼續(xù)保持一段時(shí)間的滾動(dòng)效果瞭郑,并且滾動(dòng)的速度和持續(xù)的時(shí)間是與滑動(dòng)手勢(shì)的強(qiáng)烈程度成正比辜御。這種交互思路源于 ios 系統(tǒng)原生元素的滾動(dòng)回彈(momentum-based scrolling
),來(lái)看 H5 的一個(gè)普通列表在 ios 上的滾動(dòng)表現(xiàn):
社區(qū)上大部分的移動(dòng)端組件庫(kù)的選擇器組件都采取了這種交互方式屈张,看看效果:
weui 的選擇器實(shí)現(xiàn)了慣性滑動(dòng)擒权,但滑動(dòng)動(dòng)畫結(jié)束得有點(diǎn)突兀,效果一般阁谆。
vant 的選擇器壓根沒(méi)有做慣性滑動(dòng)碳抄,當(dāng)手指從屏幕上移開(kāi)后,選擇器的滑動(dòng)會(huì)立刻停止场绿∑市В可見(jiàn)這樣的交互體驗(yàn)是比較差的。
接下來(lái)我會(huì)從設(shè)計(jì)層面剖析和模擬慣性滑動(dòng)的交互效果焰盗。
物理學(xué)應(yīng)用
不難想象璧尸,慣性滑動(dòng)非常貼合現(xiàn)實(shí)生活中的一些場(chǎng)景,如汽車剎車等熬拒。除此之外爷光,與物理力學(xué)中的滑塊模型也十分相似,由此我會(huì)參考滑塊模型來(lái)剖析慣性滑動(dòng)的全過(guò)程澎粟。
慣性
來(lái)源于物理學(xué)中的慣性定律(即 牛頓第一定律):一切物體在沒(méi)有受到力的作用的時(shí)候蛀序,運(yùn)動(dòng)狀態(tài)不會(huì)發(fā)生改變,物體所擁有的這種性質(zhì)就被稱為慣性活烙。我們不妨把慣性滑動(dòng)模擬成滑動(dòng)滑塊然后釋放的過(guò)程(以下討論中用戶滑動(dòng)的目標(biāo)皆模擬成 滑塊
)徐裸,主要?jiǎng)澐譃閮蓚€(gè)階段:
- 用戶滑動(dòng)滑塊使其從靜止開(kāi)始做加速運(yùn)動(dòng);
- 用戶釋放滑塊使其只在摩擦力的作用下繼續(xù)滑動(dòng)啸盏,直至靜止倦逐;
慣性滑動(dòng)距離
描述滑塊的慣性滑動(dòng),首先需要求出滑動(dòng)的距離宫补。在上述二階段中檬姥,滑塊受摩擦力作 勻減速直線運(yùn)動(dòng)。假設(shè)滑動(dòng)距離為粉怕,初速度為健民,末速度為。根據(jù)位移公式
加速度公式
可以算出慣性滑動(dòng)距離
由于勻減速運(yùn)動(dòng)的加速度為負(fù)贫贝,不妨設(shè)一個(gè)加速度常量秉犹,使其滿足蛉谜,那么
這里為正數(shù)。也就是說(shuō)崇堵,我們只需要求出初始速度即可型诚。
實(shí)際計(jì)算時(shí),會(huì)導(dǎo)致計(jì)算出的慣性滑動(dòng)距離過(guò)大鸳劳,因此公式調(diào)整為狰贯。
關(guān)注第一個(gè)階段,假設(shè)用戶滑動(dòng)滑塊的距離為赏廓,滑動(dòng)的持續(xù)時(shí)間是涵紊,那么二階段的初速度可以根據(jù)位移公式求得
綜上,求慣性滑動(dòng)的距離我們需要記錄用戶滑動(dòng)滑塊的 距離 和 持續(xù)時(shí)間幔摸,并設(shè)置一個(gè)合理的 加速度常量摸柄。
經(jīng)測(cè)試,加速度常量的合適值為 既忆。
注意驱负,這里的距離和持續(xù)時(shí)間并不是用戶滑動(dòng)滑塊的總距離和時(shí)長(zhǎng),而是觸發(fā)慣性滑動(dòng)范圍內(nèi)的距離和時(shí)長(zhǎng)患雇,詳見(jiàn)【慣性滑動(dòng)的啟動(dòng)條件】电媳。
慣性滑動(dòng)速度曲線
針對(duì)二階段的勻減速直線運(yùn)動(dòng),時(shí)間段產(chǎn)生的位移差庆亡,其中。也就是說(shuō)時(shí)間越往后捞稿,同等時(shí)間間距下通過(guò)的位移越來(lái)越小又谋,也就是動(dòng)畫的推進(jìn)速度越來(lái)越慢。
這與 CSS3 transition-timing-function
中的 ease-out
速度曲線相吻合娱局,ease-out
(即 cubic-bezier(0, 0, .58, 1)
)的貝塞爾曲線為
上圖來(lái)自 在線繪制貝塞爾曲線網(wǎng)站彰亥。圖表中的縱坐標(biāo)是指 動(dòng)畫推進(jìn)的進(jìn)程;橫坐標(biāo)是指 時(shí)間衰齐;原點(diǎn)坐標(biāo)為 (0, 0)
任斋,終點(diǎn)坐標(biāo)為 (1, 1)
,假設(shè)動(dòng)畫持續(xù)時(shí)間為2秒耻涛,(1, 1)
坐標(biāo)點(diǎn)則代表離動(dòng)畫開(kāi)始2秒時(shí)動(dòng)畫執(zhí)行完畢(100%)废酷。根據(jù)圖表可以得出,時(shí)間越往后動(dòng)畫進(jìn)程的推進(jìn)速度越慢抹缕,符合勻減速直線運(yùn)動(dòng)的特性澈蟆。
然而這樣的速度曲線過(guò)于線性平滑,減速效果不明顯卓研。我們基于 ios 滾動(dòng)回彈的效果趴俘,調(diào)整貝塞爾曲線的參數(shù)為 cubic-bezier(.17, .89, .45, 1)
睹簇。
回彈
滑塊滑動(dòng)不是無(wú)邊界的,我們來(lái)考慮這樣的場(chǎng)景:當(dāng)滑塊向下滑動(dòng)寥闪,其頂部正要接觸容器上邊界時(shí)速度還沒(méi)有降到太惠,此時(shí)如果讓滑塊瞬間停止運(yùn)動(dòng),這樣的交互效果是不理想的疲憋。
我們可以把上邊界想象成一條與滑塊緊密貼合的固定彈簧凿渊,當(dāng)滑塊到達(dá)臨界點(diǎn)而速度還沒(méi)有降到時(shí),滑塊會(huì)繼續(xù)滑動(dòng)并拉動(dòng)彈簧使其往下形變柜某,同時(shí)會(huì)受到彈簧的反拉力作減速運(yùn)動(dòng)(動(dòng)能轉(zhuǎn)化為內(nèi)能)嗽元;當(dāng)滑塊速度降為,此時(shí)彈簧的形變量最大喂击,由于彈性特質(zhì)彈簧會(huì)恢復(fù)原狀(內(nèi)能轉(zhuǎn)化成動(dòng)能)剂癌,從而拉動(dòng)滑塊反向運(yùn)動(dòng)。
回彈過(guò)程也可以分為兩個(gè)階段:
- 滑塊拉動(dòng)彈簧作變減速運(yùn)動(dòng)翰绊。此階段滑塊受摩擦力和越來(lái)越大的彈簧反拉力共同作用佩谷,加速度越來(lái)越大,所以速度降為的時(shí)間非常短监嗜;
- 彈簧恢復(fù)原狀谐檀,拉動(dòng)滑塊作先變加速后變減速運(yùn)動(dòng)。此階段滑塊受到的摩擦力和越來(lái)越小的彈簧拉力相互抵消裁奇,剛開(kāi)始桐猬,滑塊作加速度越來(lái)越小的變加速運(yùn)動(dòng);隨之刽肠,滑塊作加速度越來(lái)越大的變減速運(yùn)動(dòng)溃肪,直至靜止。這里為了交互效果我們可以營(yíng)造一個(gè)理想狀態(tài):滑塊靜止時(shí)彈簧剛好恢復(fù)形變音五。
回彈距離
根據(jù)上述分析惫撰,回彈的第一階段作加速度越來(lái)越大的變減速直線運(yùn)動(dòng),設(shè)此階段的初速度為躺涝,可以與建立以下關(guān)系
那么回彈距離為
微積分都來(lái)了厨钻,簡(jiǎn)直沒(méi)法算好吧…
我們可以根據(jù)運(yùn)動(dòng)模型來(lái)簡(jiǎn)化的計(jì)算,由于該階段的加速度大于 非回彈慣性滑動(dòng) 的加速度坚嗜,設(shè) 非回彈慣性滑動(dòng) 的總距離為夯膀,那么
所以可以設(shè)置一個(gè)合理的常量,使其滿足
經(jīng)測(cè)試苍蔬,常量的合理取值為 10棍郎。
回彈速度曲線
整個(gè)觸發(fā)回彈的慣性滑動(dòng)模型包括三個(gè)運(yùn)動(dòng)階段:
然而把 階段a 和 階段b 描繪成 CSS 動(dòng)畫是有一定復(fù)雜度和風(fēng)險(xiǎn)的:
- 階段b 中的變減速運(yùn)動(dòng)難以描繪;
- 兩個(gè)階段運(yùn)動(dòng)方向相同但動(dòng)畫速度曲線不連貫银室,容易造成用戶體驗(yàn)的斷層涂佃;
出于簡(jiǎn)化的考慮励翼,可以將 階段a、b 合并為一個(gè)運(yùn)動(dòng)階段:
對(duì)于合并后的 階段a 末段辜荠,由于反向加速度越來(lái)越大汽抚,因此滑塊減速的效率會(huì)比 非回彈慣性滑動(dòng) 同期更大,對(duì)應(yīng)的貝塞爾曲線末段也會(huì)更陡伯病,參數(shù)調(diào)整為 cubic-bezier(.25, .46, .45, .94)
造烁。
在 階段b 中,滑塊先變加速后變減速午笛,嘗試 ease-in-out
的動(dòng)畫曲線:
可以看出惭蟋,由于 階段b 初始的 ease-in
曲線使 階段a、b 的銜接段稍有停留药磺,效果體驗(yàn)一般告组。所以我們選擇只描繪變減速運(yùn)動(dòng)這一段,調(diào)整貝塞爾曲線為 cubic-bezier(.165, .84, .44, 1)
癌佩。
由于 mp4 轉(zhuǎn) gif 格式會(huì)掉幀木缝,所以示例效果看起來(lái)會(huì)有點(diǎn)卡頓,建議直接體驗(yàn) demo围辙。
動(dòng)畫時(shí)長(zhǎng)
PS:以下取值都是基于對(duì) ios 滾動(dòng)回彈實(shí)例的測(cè)量我碟。
一次慣性滑動(dòng)可能會(huì)出現(xiàn)兩種情況:
沒(méi)有觸發(fā)回彈
滑動(dòng)動(dòng)畫的持續(xù)時(shí)間為2500ms
。觸發(fā)回彈
階段a 中姚建,當(dāng)大于某個(gè)閾值時(shí)矫俺,為強(qiáng)回彈
,動(dòng)畫時(shí)長(zhǎng)設(shè)為400ms
掸冤,反之為弱回彈
厘托,時(shí)長(zhǎng)設(shè)為800ms
;
階段b 持續(xù)時(shí)間為500ms
贩虾;
慣性滑動(dòng)啟停
- 啟動(dòng)條件
慣性滑動(dòng)的啟動(dòng)需要有足夠的動(dòng)量。我們可以簡(jiǎn)單地認(rèn)為沥阱,當(dāng)用戶滑動(dòng)的距離足夠大(大于 15px
)和持續(xù)時(shí)間足夠短(小于 300ms
)時(shí)缎罢,即可產(chǎn)生慣性滑動(dòng)。也就是說(shuō)考杉,最后一次 touchmove
事件觸發(fā)的時(shí)間和 touchend
事件觸發(fā)的時(shí)間間隔小于 300ms
策精,且兩者產(chǎn)生的距離差大于 15px
時(shí)認(rèn)為啟動(dòng)慣性滑動(dòng)。
- 暫停時(shí)機(jī)
當(dāng)慣性滑動(dòng)未結(jié)束(包括處于回彈過(guò)程)崇棠,用戶再次觸碰滑塊時(shí)會(huì)暫脱释啵滑塊的運(yùn)動(dòng)。原理上是通過(guò) getComputedStyle
和 getPropertyValue
方法獲取當(dāng)前的 transform: matrix()
矩陣值枕稀,抽離出水平 y 軸偏移量后重新調(diào)整 translate
的位置询刹。
完整代碼
demo 基于 vuejs 實(shí)現(xiàn)谜嫉,預(yù)覽地址:https://codepen.io/JunreyCen/pen/arRYem
<html>
<head>
<meta name="viewport" content="width=device-width,initial-scale=1.0,maximum-scale=1.0,user-scalable=0">
<style>
body, ul {
margin: 0;
padding: 0;
}
ul {
list-style: none;
}
.wrapper {
position: absolute;
top: 50%;
left: 0;
right: 0;
margin: 0 auto;
height: 80%;
width: 80%;
max-width: 300px;
max-height: 500px;
border: 1px solid #000;
transform: translateY(-50%);
overflow: hidden;
}
.list {
background-color: #70f3b7;
}
.list-item {
height: 40px;
line-height: 40px;
width: 100%;
text-align: center;
border-bottom: 1px solid #ccc;
}
</style>
</head>
<body>
<div id="app"></div>
<template id="tpl">
<div
class="wrapper"
ref="wrapper"
@touchstart.prevent="onStart"
@touchmove.prevent="onMove"
@touchend.prevent="onEnd"
@touchcancel.prevent="onEnd"
@mousedown.prevent="onStart"
@mousemove.prevent="onMove"
@mouseup.prevent="onEnd"
@mousecancel.prevent="onEnd"
@mouseleave.prevent="onEnd"
@transitionend="onTransitionEnd">
<ul
class="list"
ref="scroller"
:style="scrollerStyle">
<li
class="list-item"
v-for="item in list">
{{item}}
</li>
</ul>
</div>
</template>
<script src="https://cdn.jsdelivr.net/npm/vue"></script>
<script>
new Vue({
el: '#app',
template: '#tpl',
computed: {
list() {
const list = [];
for (let i = 0; i < 100; i++) {
list.push(i);
}
return list;
},
scrollerStyle() {
return {
'transform': `translate3d(0, ${this.offsetY}px, 0)`,
'transition-duration': `${this.duration}ms`,
'transition-timing-function': this.bezier,
};
},
},
data() {
return {
wrapper: null,
scroller: null,
minY: 0,
maxY: 0,
wrapperHeight: 0,
offsetY: 0,
duration: 0,
bezier: 'linear',
startY: 0,
pointY: 0,
startTime: 0, // 慣性滑動(dòng)范圍內(nèi)的 startTime
momentumStartY: 0, // 慣性滑動(dòng)范圍內(nèi)的 startY
momentumTimeThreshold: 300, // 慣性滑動(dòng)的啟動(dòng) 時(shí)間閾值
momentumYThreshold: 15, // 慣性滑動(dòng)的啟動(dòng) 距離閾值
isStarted: false, // start鎖
};
},
mounted() {
this.$nextTick(() => {
this.wrapper = this.$refs.wrapper;
this.scroller = this.$refs.scroller;
const { height: wrapperHeight } = this.wrapper.getBoundingClientRect();
const { height: scrollHeight } = this.scroller.getBoundingClientRect();
this.wrapperHeight = wrapperHeight;
this.minY = wrapperHeight - scrollHeight;
});
},
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;
// 浮點(diǎn)數(shù)坐標(biāo)會(huì)影響渲染速度
let offsetY = Math.round(this.startY + deltaY);
// 超出邊界時(shí)增加阻力
if (offsetY < this.minY || offsetY > this.maxY) {
offsetY = Math.round(this.startY + deltaY / 3);
}
this.offsetY = offsetY;
const now = new Date().getTime();
// 記錄在觸發(fā)慣性滑動(dòng)條件下的偏移值和時(shí)間
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;
// 啟動(dòng)慣性滑動(dòng)
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';
// 慣性滑動(dòng)加速度
const deceleration = 0.003;
// 回彈阻力
const bounceRate = 10;
// 強(qiáng)弱回彈的分割值
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],
};
},
// 超出邊界時(shí)需要重置位置
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() {
// 獲取當(dāng)前 translate 的位置
const matrix = window.getComputedStyle(this.scroller).getPropertyValue('transform');
this.offsetY = Math.round(+matrix.split(')')[0].split(', ')[5]);
},
},
});
</script>
</body>
</html>