使用RxJS做一個Pull-to-Refresh的例子

jopen 8年前發布 | 23K 次閱讀 Vue.js JavaScript開發

本文將用一個 Pull-to-Refresh 的例子來介紹如何使用RxJS進行高度抽象的復雜DOM事件處理。

文中所開發的完整demo代碼可以在 github 找到,在線demo在 這里 (需要使用手機或開啟touch模擬,未作瀏覽器兼容)。

這個程序將會用到的工具:

  • RxJS
  • VueJS (并非對其依賴,僅僅是為了方便開發一個UI)

概述

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

來自: http://jimliu.net/2016/01/15/building-a-pull-to-refresh-demo-with-rxjs/

 本文由用戶 jopen 自行上傳分享,僅供網友學習交流。所有權歸原作者,若您的權利被侵害,請聯系管理員。
 轉載本站原創文章,請注明出處,并保留原始鏈接、圖片水印。
 本站是一個以用戶分享為主的開源技術平臺,歡迎各類分享!