本文轉載于稀土掘金技術社區——小霖家的混江龍
最近我需要做一個下拉刷新的功能,實現功能后我發現,它需要處理的情況還蠻多,于是我整理了這篇文章。
下圖是我實現的效果,分為三步:開始下拉時,屏幕頂部會出現加載動畫;加載過程中,屏幕頂部高度保持不變;加載完成后,加載動畫隱藏。
首先我會講解下拉的原理、根據原理寫出初始代碼;然后我會說明代碼存在的缺陷、解決缺陷并做些額外優化;最后我會給出完整代碼,并做一個總結。
拳打 H5,腳踢小程序。我是「小霖家的混江龍」,關注我,帶你了解更多實用的前端武學。
下拉的原理
如圖所示,藍色框代表視口,綠色框代表容器,橙色框代表加載動畫。最開始時,加載動畫處于視口外;開始下拉之后,容器向下移動,加載動畫從上方進入視口;結束下拉后,容器又開始向上移動,加載動畫也從上方退出視口。
下拉基礎代碼
知道原理,我們現在開始寫實現代碼,首先是布局的代碼:
布局代碼
我們把 box 元素當作容器,把 loader-box,loader-box + loading 元素當作動畫,至于 h1 元素不需要關注,我們只把它當作操作提示。
<div id="box">
<div class="loader-box">
<div id="loading"></div>
</div>
<h1>下拉刷新 ↓</h1>
</div>
loader-box 的高度是 80px,按上一節原理中的分析,初始時我們需要讓 loader-box 位于視口上方,因此 CSS 代碼中我們需要把它的位置向上移動 80px。
.loader-box {
position: relative;
top: -80px;
height: 80px;
}
loader-box 中的 loader 是純 CSS 的加載動畫。我們利用 border 畫出的一個圓形邊框,左、上、右邊框是淺灰色,下邊框是深灰色:
#loader {
width: 25px;
height: 25px;
border: 3px solid #ddd;
border-radius: 50%;
border-bottom: 3px solid #717171;
transform: rotate(0deg);
}
開始刷新時,我們給 loader 元素增加一個動畫,讓它從 0 度到 360 度無限旋轉,就實現了加載動畫:
#loader.loading {
animation: loading 1s linear infinite;
}
@keyframes loading {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
邏輯代碼
看完布局代碼,我們再看邏輯代碼。邏輯代碼中,我們要監聽用戶的手指滑動、實現下拉手勢。我們需要用到三個事件:
從 touchstart
和 touchmove
事件中我們可以獲取手指的坐標,比如 event.touches[0].clientX
是手指相對視口左邊緣的 X 坐標,event.touches[0].clientY
是手指相對視口上邊緣的 Y 坐標;從 touchend
事件中我們則無法獲得 clientX
和 clientY
。
我們可以先記錄用戶手指 touchstart 的 clientY 作為開始坐標,記錄用戶最后一次觸發 touchmove 的 clientY 作為結束坐標,二者相減就得到手指移動的距離 distanceY。
設置手指移動多少距離,容器就移動多少距離,就得到了我們的邏輯代碼:
const box = document.getElementById('box')
const loader = document.getElementById('loader')
let startY = 0, endY = 0, distanceY = 0
function start(e) {
startY = e.touches[0].clientY
}
function move(e) {
endY = e.touches[0].clientY
distanceY = endY - startY
box.style = `
transform: translateY(${distanceY}px);
transition: all 0.3s linear;
`
}
function end() {
setTimeout(() => {
box.style = `
transform: translateY(0);
transition: all 0.3s linear;
`
loader.className = 'loading'
}, 1000)
}
box.addEventListener('touchstart', start)
box.addEventListener('touchmove', move)
box.addEventListener('touchend', end)
邏輯代碼實現一個簡陋的下拉效果,當然現在還有很多缺陷。
簡陋下拉效果的 6 個缺陷
之前我們實現了簡陋的下拉效果,它還需要解決 6 個缺陷,才能算一個完善的功能。
沒有最小、最大距離限制
第一個缺陷是,下拉沒有做最小、最大距離的限制。
通常來說,我們下拉屏幕時,距離太小應該不能觸發刷新,距離太大也不行,下滑到一定距離后,就應該無法繼續下滑。
因此我們可以給下拉設置最小距離限制 DISTANCE_Y_MIN_LIMIT
、最大距離限制 DISTANCE_Y_MAX_LIMIT
。如果 touchend 中發現下拉距離小于最小距離,直接不觸發加載;如果 touchmove 中下拉距離超過最大距離,頁面只向下移動最大距離。
解決缺陷關鍵代碼如下:
const DISTANCE_Y_MAX_LIMIT = 150
DISTANCE_Y_MIN_LIMIT = 80
function move(e) {
endY = e.touches[0].clientY
distanceY = endY - startY
if (distanceY > DISTANCE_Y_LIMIT) {
distanceY = DISTANCE_Y_LIMIT
}
box.style = `
transform: translateY(${distanceY}px);
transition: all 0.3s linear;
`
}
function end() {
if (distanceY < DISTANCE_Y_MIN_LIMIT) {
box.style = `
transform: translateY(0px);
transition: all 0.3s linear;
`
return
}
...
}
加載動畫沒有停留在視口頂部
第二個缺陷是,下拉沒有讓加載動畫停留在視口頂部。
我們可以把 end 函數加以改造,在數據還沒有加載完成時(用 setTimeout 模擬的),讓加載動畫 style 的 translateY
一直是 80px,translateY(80px)
可以和 初始 CSS 的 top: -80px;
相互抵消,讓動畫在未刷新完成前停留在視口頂部。
function end() {
...
box.style = `
transform: translateY(80px);
transition: all 0.3s linear;
`
loader.className = 'loading'
setTimeout(() => {
box.style = `
transform: translateY(0px);
transition: all 0.3s linear;
`
loader.className = ''
}, 1000)
}
重復觸發
第三個缺陷是,下拉可以重復觸發。
正常來說,如果我們已經下拉過,數據正在加載中時,我們不能繼續下拉。
我們可以增加一個加載鎖 loadLock。當加載鎖開啟時,start,move 和 end 事件都不會觸發。
let loadLock = false
function start(e) {
if (loadLock) { return }
...
}
function move(e) {
if (loadLock) { return }
...
}
function end(e) {
if (loadLock) { return }
...
setTimeout(() => {
...
loadLock = true
...
}, 1000)
}
沒有限制方向
第四個缺陷是,沒有限制方向。
目前我們的代碼,用戶上拉也能觸發。我們可以增加判斷,當 endY - startY
小于 0 時,阻止 touchmove
和 touchend
的邏輯。
function move(e) {
...
if (endY - startY < 0) { return }
...
}
function end() {
if (endY - startY < 0) { return }
...
}
你可能會疑惑,為什么我寧愿寫多個判斷攔截,也不取消監聽事件。這是因為一旦取消監聽事件,我們需要考慮在一個合適的時間重新監聽,這會把問題變得更復雜。
沒有阻止原生滾動
第五個缺陷時,我們在加載數據時沒有阻止原生滾動。
雖然我們已經阻止了重復下拉,touchmove 和 touchend 事件被攔截了,但是 H5 原生滾動還能用。
我們可以在刷新時給 body 設置一個 overflow: hidden;
屬性,刷新結束后清除 overflow: hidden
,這樣就可以阻止原生滾動。
body.overflowHidden {
overflow: hidden;
}
const body = document.body
function end() {
...
box.style = `
transform: translateY(80px);
transition: all 0.3s linear;
`
loader.className = 'loading'
body.className = 'overflowHidden'
setTimeout(() => {
...
box.style = `
transform: translateY(0px);
transition: all 0.3s linear;
`
loader.className = ''
body.className = ''
}, 1000)
}
沒有阻止 iOS 橡皮筋效果
第 6 個缺陷是,沒有阻止 iOS 的橡皮筋效果。
iOS 瀏覽器默認滑動時有一個橡皮筋效果,我們需要阻止它,避免影響我們的下拉手勢。阻止方式就是給監聽器設置 passive: false
。
function addTouchEvent() {
box.addEventListener('touchstart', start, { passive: false })
box.addEventListener('touchmove', move, { passive: false })
box.addEventListener('touchend', end, { passive: false })
}
addTouchEvent()
解決完 6 個缺陷后,我們已經得到無缺陷的下拉刷新功能,但離絲滑的下拉刷新還有一段距離。我們還可以做一些優化,讓下拉刷新更完善。
優化
我們可以做兩個優化,第一個優化是添加阻尼效果:
增加阻尼效果
所謂阻尼效果,就是下拉過程我們可以感受到一股阻力的存在,雖然我們下拉力度是一樣的,但距離的增加速度變慢了。用物理術語表示的話,就是加速度變小了。
體現到代碼上,我們可以設置一個百分比,百分比會隨著下拉距離增加而減少,把百分比乘以距離當作最后的距離。
代碼中百分比 percent
設為 (100 - distanceY * 0.5) / 100
,當 distanceY
越來越大時,百分比 percent
越來越小,最后再把 distanceY * percent
賦值給 distanceY
。
function move(e) {
...
distanceY = endY - startY
let percent = (100 - distanceY * 0.5) / 100
percent = Math.max(0.5, percent)
distanceY = distanceY * percent
if (distanceY > DISTANCE_Y_MAX_LIMIT) {
distanceY = DISTANCE_Y_MAX_LIMIT
}
...
}
利用角度判斷用戶下拉意圖
第二個優化是利用角度判斷用戶下拉意圖。
下圖展示了兩種用戶下拉的情況,β 角度比 α 角度小,角度越小用戶下拉意圖越明顯、誤觸的可能性更小。
我們可以利用反三角函數求出角度來判斷下拉意圖。
JavaScript 中,反正切函數是 Math.atan()
,需要注意的是,反正切函數算出的是弧度,我們還需要將它乘以 180 / π
才能獲取角度。
下面的代碼中,我們做了一個限制,只有角度小于 40 時,我們才認為用戶的真實意圖是想要下拉刷新。
const DEG_LIMIT = 40
function move(e) {
...
distanceY = endY - startY
distanceX = endX - startX
const deg = Math.atan(Math.abs(distanceX) / distanceY)
* (180 / Math.PI)
if (deg > DEG_LIMIT) {
[startY, startX] = [endY, endX]
return
}
...
}
代碼示例
你可以在 codepen[4] 中查看效果,web 端需要按 F12 用手機瀏覽器打開。
總結
本文講解了下拉的原理、并根據原理寫出初始代碼。在初始代碼的基礎上,我解決了 6 個缺陷、做了 2 個優化,實現了一個完善的下拉刷新效果
該文章在 2024/3/30 0:12:59 編輯過