使用RxJS做一個Pull-to-Refresh的例子
本文將用一個 Pull-to-Refresh 的例子來介紹如何使用RxJS進行高度抽象的復雜DOM事件處理。
文中所開發的完整demo代碼可以在 github 找到,在線demo在 這里 (需要使用手機或開啟touch模擬,未作瀏覽器兼容)。
這個程序將會用到的工具:
概述
Pull to Refresh是一個流行到甚至讓人開始覺得有些過時了的交互,也就是所謂的“下拉刷新”。
這個交互簡單描述就是:
當一個元素的滾動位置處于其頂端時,做一個下拉手勢,將會對元素進行刷新。
由于Web中的限制,在具體實現上有一些妥協,我使用的策略是:
在 touchstart 事件中,檢測元素的滾動位置是否在其頂端,若是,則記錄起始手指位置,并繼續
在 touchmove 事件中,檢測當前手指位置和起始位置的相對關系,若是下拉,則進入下拉狀態
在下拉狀態中,繼續監聽 touchmove 事件,并更新UI,通常會拉出一個隱藏的元素,通過其提示用戶繼續下拉可以刷新
下拉到一定程度,超過閾值,則可以進入 Release to Refresh 狀態,通常也會在UI上做一些提示
在下拉手勢結束時,檢測下拉程度是否超過閾值,若是,則進行更新,否則恢復原貌
接下來的內容中將會實現一個名為 pull-to-refresh 的 directive ,在Vue中將其應用在指定的元素上,并指定相關參數,響應對應的回調函數和事件,則可以復用“下拉刷新”的功能。
使用Vue并非是Pull-to-Refresh本身、或者是RxJS依賴Vue,這只是做Demo的一個選擇。同樣,實現為 directive 也只是一個選擇,將其實現為 component 或者 mixin 都是完全可行的。
頁面
首先構建一個如圖所示的頁面框架
其結構為
#app .body .staff .person .person ... .person .bottom-bar
其中 .body 是一個局部滾動元素,我們將會在 .staff 元素上應用 pull-to-refresh ,讓其相對于body滾動時能夠具有下拉刷新功能。
而其他元素不是本文的重點,不在文中贅述了。
pull-to-refresh 事件流
Rx 中的事件流
Rx中的 Rx.Observable 可以使用“事件流”的概念來理解,它將一系列類似的、未來發生的事件整合成一條“流”,我們既可以像遍歷一個序列一樣去“遍歷”它,也可以像對序列那樣對它進行 map/filter/reduce/flatMap 等等操作,Rx還提供了諸如 skip/take/groupBy 等非常實用的操作,甚至是對兩條事件流進行“交織”的操作。
RxJS的API,可以在 rx-book 找到,對于很多流操作它還有圖形解釋,非常棒。 RxMarbles 是一個對Rx中各種流操作的圖形化學習工具,也是非常直觀。
drag 事件流
傳統方式
在使用手工處理 drag 的時候,我們通常的思路是這樣:
- 在 touchstart 中記錄起始位置,并開始監聽 touchmove 和 touchend
- 在 touchmove 中計算當前位置和起始位置之間的 offset ,并進行拖拽操作
- 在 touchend 中取消監聽 touchmove 和 touchstart ,并進行釋放操作
上面的描述起始是一個“狀態機”,而接下來我們要用Rx的風格來處理 drag 。
Rx 的風格
首先我們擁有3條事件流,他們看起來分別是這樣:
touchstart ---------@-----------------@------------------- touchmove -----------#-#-#-#-#-#--------#-#-#-#-#-#------ touchend -----------------------$--------------------$--
對于 touchstart 流中的每一個事件,將其 map 成一個 drag 流,其中每一個元素都由 current 和 start 兩個對象組成,每一條 drag 都會在整個 touchmove 流中持續,并在 touchend 事件時結束。
將上面“圖”里的那組事件流進行這樣的組合變換,我們可以得到下面這樣一個 drag 流
touchstart ---------@-----------------@------------------- touchmove -----------#-#-#-#-#-#--------#-#-#-#-#-#------ touchend -----------------------$--------------------$-- drag ---------@-----------------@------------------- |-#-#-#-#-#-# |--#-#-#-#-#-#
于是就可以通過Rx的訂閱函數來處理這條 drag 流:
drags.subscribe(drag => drag.subscribe(move => { let start = move.start let current = move.current obj.translate(current.X - start.X, current.Y - start.Y) }))
pull-to-refresh 事件流
pull-to-refresh 比 drag 要稍微復雜一點,不過也復雜不到哪去,下面對著重點代碼來梳理一下邏輯,完整代碼在 src/directives/pull-to-refresh.js 當中。
let touchstart = Rx.Observable.fromEvent(el, 'touchstart') let touchmove = Rx.Observable.fromEvent(el, 'touchmove') let touchend = Rx.Observable.fromEvent(el, 'touchend')
首先像 drag 那樣,建立起 touchstart/touchmove/touchend 三個流。
let touchcancel = Rx.Observable.fromEvent(document, 'touchcancel') let end = Rx.Observable.merge(touchend, touchcancel)
對 touchend 和 touchcancel 進行無差別處理,將它們 merge 成一條 end 流,形象描述就是:
touchend ---------#----------------#---- touchcancel ----------------*-------*------ end ---------#------*-------*-#----
對 touchstart 流進行過濾,只處理“元素處于其滾動狀態頂端”的那些事件,得到一條叫做 dragAtTop 的流:
let dragAtTop = touchstart.filter(e => wrapper.scrollTop === 0)
響應 dragAtTop 流,將它 map 成與上面類似的 drag 流,不過這次我們只關心縱軸上的數據。
let dragTopDown = dragAtTop.map(start => { let startY = start.touches[0].pageY return touchmove .map(move => { let currentY = move.touches[0].pageY return { startEvent: start, moveEvent: move, startY: startY, currentY: currentY, offset: currentY - startY } }) .skipWhile(drag => drag.offset < 0) // 先無視向上拖拽的那些動作,直到向下拖拽才開始算dragTopDown .takeUntil(end) // 同樣,還是到`end`流發生就結束 })
還是用上面那組事件來描述的話, dragTopDown 看起來就是這個樣子:
/這個不在頂端,于是被拋棄了 touchstart ----@----@-----------------@------------------- dragAtTop ---------@-----------------@------------------- touchmove ------^----v-v-^-^-^-v--------v-^-v-v-^-^------ end -----------------------$--------------------$-- dragTopDown---------@-----------------@------------------- |-----^-^-^-v |----^-v-v-^-^
現在我們就有了“頂部下拉”的事件流 dragTopDown ,對其進行響應,處理交互邏輯:
dragTopDown.forEach(drags => { // 響應所有drag move drags.forEach(drag => { drag.moveEvent.preventDefault() // 觸發下拉刷新時,屏蔽原生滾動 let offset = drag.offset / 2 // 壓縮滾動距離,實現拖拽“力度” if (offset < 0 || offset > maxOffset) { return // 超過范圍,不處理 } let refresh = offset >= releaseThreshold // 計算閾值,決定是否應該刷新 this.vm.$emit('pull-to-refresh-drag-move', offset, refresh) // 觸發事件 }) // 對于最后一個drag move,有其單獨邏輯 drags.last().subscribe(drag => { let offset = drag.offset / 2 let refresh = offset >= releaseThreshold if (refresh) { // 釋放刷新時,先主動回彈到正確高度 this.vm.$emit('pull-to-refresh-drag-move', releaseThreshold, refresh) } // 不刷新時,直接釋放 // 需要刷新時,調用onRefresh回調函數,完成刷新后再釋放 let promise = Promise.resolve(refresh ? onRefresh() : undefined) promise.then(ret => { this.vm.$emit('pull-to-refresh-drag-release', refresh) }) }) })
現在我們的 pull-to-refresh 這個 directive 就已經封裝了:
- pull-to-refresh-drag-move 事件,可以獲知下拉距離 offset 和是否超過刷新閾值 refresh
- pull-to-refresh-drag-release 事件,可以獲知本次釋放是否超過刷新閾值 refresh
它依賴:
- 監聽 touch 事件族的元素 el ——通過Vue的 directive 機制即可自己獲取
- el 所相對其滾動的容器 wrapper ——通過 directive 的 params 獲取
- 釋放刷新時的 on-refresh 回調,返回一個 Promise ,在刷新操作完成時 resolve ,進行恢復
使用 directive
接下來對 .staff 元素應用 v-pull-to-refresh- ,并且設定其各種參數,響應事件等,只摘主要的代碼了
模板
<div class="body" v-el:body> <list-view class="staff" v-el:staff v-pull-to-refresh :on-refresh="refresh" :wrapper="$els.body"> <div class="p2r-hidden"> ... 用于顯示下拉刷新狀態的隱藏層,通過對.staff使用負值margin來將其隱藏起來,下拉的時候則露出來 </div> <div class="person" v-for="person in staff"> ... 列表本身 </div> </list-view>
上面的代碼中對 .staff 應用了 v-pull-to-refresh ,并且對它綁定 on-refresh 回調函數, wrapper 設置為了 .body ,留下了 v-el:staff 引用,這樣我們可以在 pull-to-refresh-drag-move 等事件中修改它的UI樣式(當然,通過數據綁定來實現也OK)。
JS
export default { methods: { pull (offset) { this.$els.staff.style.transform = `translate3d(0, ${offset}px, 0)` }, refresh () { return new Promise(resolve => setTimeout(() => { this.shuffle() // 將原來的數據打亂,假裝成刷新了 resolve() }, 2000)) // 延遲2秒,假裝成正在加載的樣子 } }, events: { 'pull-to-refresh-drag-move': function (offset, result) { this.pull(offset) // 更新下拉距離 // 還原其他樣式 }, 'pull-to-refresh-drag-release': function (result) { this.pull(0) // 還原下拉距離 // 還原其他樣式 } } }
小結
使用Rx可以將離散的事件轉換成 Rx.Observable ,我們理解成“流”的概念,“流”雖然是“無定型”的,但我們還是可以把它們當做“序列”來處理。一些原本需要用“狀態”來實現的東西現在可以通過對流進行變化和組合來實現了,事件的脈絡變得更加清晰。
- 事件監聽 可以看做一個永遠不會結束的 Observable
- 異步調用 可以看做一個只會發生一次,就立即結束的 Observable
- 一個會結束的 Observable 可以通過 toPromise 來轉換成 Promise
- 一個會結束的 Observable 可以通過 toArray ,在其結束時,將它所有的元素轉換成數組
- Rx也提供了很多輔助函數,幫助你把DOM事件、callback、Promise等多種異步風格的API轉換成 Observable
over了
References
- RxJS
- VueJS
- SVG實現圓環loading進度效果實例頁面
- JSON Generator (生成測試數據的小幫手)
- rx-book
- RxMarbles
來自: http://jimliu.net/2016/01/15/building-a-pull-to-refresh-demo-with-rxjs/