React Native 實現自定義下拉重新整理元件

雲音樂大前端團隊發表於2020-07-21

本文作者:李磊

背景

Web 應用如果要更新列表資料,一般會選擇點選左上角重新整理按鈕,或使用快捷鍵 Ctrl+F5,進行頁面資源和資料的全量更新。如果頁面提供了重新整理按鈕或是翻頁按鈕,也可以點選只做資料更新。

但移動客戶端螢幕寸土寸金,無論是加上一個重新整理按鈕,還是配合越來越少的手機按鍵來做重新整理操作,都不是十分便捷的方案。

於是,在這方寸之間,各種各樣的滑動方案和手勢方案來觸發事件,成了移動客戶端的普遍趨勢。在重新整理資料方面,移動端最常用的方案就是下拉重新整理的機制。

什麼是下拉重新整理?

下拉重新整理的機制最早是由 Loren Brichter 在 Tweetie 2 中實現。Tweetie 是 Twitter 的第三方客戶端,後來被 Twitter 收購,Loren Brichter 也成為 Twitter 員工(現已離開)。

Loren Brichter 在 2010 年 4 月 8 日為下拉重新整理申請了專利,並獲得授權United States Patent: 8448084。但他很願意看到這個機制被其他 app 採用,也曾經說過申請是防禦性的。

我們看下專利保護範圍最大的主權項是:

  • 在一種佈置中,顯示包含內容項的滾動列表;
  • 可以接受與滾動命令相關聯的輸入;
  • 根據滾動命令,顯示一個滾動重新整理的觸發器;
  • 基於滾動命令,確定滾動重新整理的觸發器被啟用後,重新整理滾動列表中的內容。

簡單來說,下拉載入的機制包含三個狀態:

  • “下拉更新”:展示使用者下拉可擴充套件的操作。
  • “鬆開更新”:提示使用者下拉操作的臨界點。
  • “資料更新動畫”:手勢釋放,提醒使用者資料正在更新。

在那之後,很多以 news feed 為主的移動客戶端都相繼採用了這個設計。

React Native 支援下拉重新整理麼?

React Native 提供了 RefreshControl 元件,可以用在 ScrollView 或 FlatList 內部,為其新增下拉重新整理的功能。

RefreshControl 內部實現是分別封裝了 iOS 環境下的 UIRefreshControl 和安卓環境下的 AndroidSwipeRefreshLayout,兩個都是移動端的原生元件。

由於適配的原生方案不同,RefreshControl 不支援自定義,只支援一些簡單的引數修改,如:重新整理指示器顏色、重新整理指示器下方字型。並且已有引數還受不同平臺的限制。

最常見的需求會要求下拉載入指示器有自己特色的 loading 動畫,個別的需求方還會加上操作的文字說明和上次載入的時間。只支援修改顏色的 RefreshControl 肯定是無法滿足的。

那想要自定義下拉重新整理要怎麼做呢?

解決方案1

ScrollView 是官方提供的一個封裝了平臺 ScrollView (滾動檢視)的元件,常用於顯示滾動區域。同時還整合了觸控的“手勢響應者”系統。

手勢響應系統用來判斷使用者的一次觸控操作的真實意圖是什麼。通常使用者的一次觸控需要經過幾個階段才能判斷。比如開始是點選,之後變成了滑動。隨著持續時間的不同,這些操作會轉化。

另外,手勢響應系統也可以提供給其他元件,可以使元件在不關心父元件或子元件的前提下自行處理觸控互動。PanResponder 類提供了一個對觸控響應系統的可預測的包裝。它可以將多點觸控操作協調成一個手勢。它使得一個單點觸控可以接受更多的觸控操作,也可以用於識別簡單的多點觸控手勢。

它在原生事件外提供了一個新的 gestureState 物件:

onPanResponderMove: (nativeEvent, gestureState) => {}

nativeEvent 原生事件物件包含以下欄位:

  • changedTouches - 在上一次事件之後,所有發生變化的觸控事件的陣列集合(即上一次事件後,所有移動過的觸控點)
  • identifier - 觸控點的 ID
  • locationX - 觸控點相對於父元素的橫座標
  • locationY - 觸控點相對於父元素的縱座標
  • pageX - 觸控點相對於根元素的橫座標
  • pageY - 觸控點相對於根元素的縱座標
  • target - 觸控點所在的元素 ID
  • timestamp - 觸控事件的時間戳,可用於移動速度的計算
  • touches - 當前螢幕上的所有觸控點的集合

gestureState 物件為了描繪手勢操作,有如下的欄位:

  • stateID - 觸控狀態的 ID。在螢幕上有至少一個觸控點的情況下,這個 ID 會一直有效。
  • moveX - 最近一次移動時的螢幕橫座標
  • moveY - 最近一次移動時的螢幕縱座標
  • x0 - 當響應器產生時的螢幕座標
  • y0 - 當響應器產生時的螢幕座標
  • dx - 從觸控操作開始時的累計橫向路程
  • dy - 從觸控操作開始時的累計縱向路程
  • vx - 當前的橫向移動速度
  • vy - 當前的縱向移動速度
  • numberActiveTouches - 當前在螢幕上的有效觸控點的數量

可以看下 PanResponder 的基本用法:

componentWillMount: function() {
  this._panResponder = PanResponder.create({
    // 要求成為響應者:
    onStartShouldSetPanResponder: (evt, gestureState) => true,
    onStartShouldSetPanResponderCapture: (evt, gestureState) => true,
    onMoveShouldSetPanResponder: (evt, gestureState) => true,
    onMoveShouldSetPanResponderCapture: (evt, gestureState) => true,
    onPanResponderGrant: (evt, gestureState) => {
      // 開始手勢操作。給使用者一些視覺反饋,讓他們知道發生了什麼事情!
      // gestureState.{x,y} 現在會被設定為0
    },
    onPanResponderMove: (evt, gestureState) => {
      // 最近一次的移動距離為gestureState.move{X,Y}
      // 從成為響應者開始時的累計手勢移動距離為gestureState.d{x,y}
    },
    onPanResponderTerminationRequest: (evt, gestureState) => true,
    onPanResponderRelease: (evt, gestureState) => {
      // 使用者放開了所有的觸控點,且此時檢視已經成為了響應者。
      // 一般來說這意味著一個手勢操作已經成功完成。
    },
    onPanResponderTerminate: (evt, gestureState) => {
      // 另一個元件已經成為了新的響應者,所以當前手勢將被取消。
    },
    onShouldBlockNativeResponder: (evt, gestureState) => {
      // 返回一個布林值,決定當前元件是否應該阻止原生元件成為JS響應者
      // 預設返回true。目前暫時只支援android。
      return true;
    },
  });
},

render: function() {
  return (
    <View {...this._panResponder.panHandlers} />
  );
},

結合上面狀態分析,看到 onPanResponderMoveonPanResponderRelease 這兩個引數,基本是可以滿足下拉重新整理機制的操作流程的。

onPanResponderMove 處理滑動過程。

onPanResponderMove(event, gestureState) {
  // 最近一次的移動距離為 gestureState.move{X,Y}
  // 從成為響應者開始時的累計手勢移動距離為 gestureState.d{x,y}
  if (gestureState.dy >= 0) {
    if (gestureState.dy < 120) {
      this.state.containerTop.setValue(gestureState.dy);
    }
  } else {
    this.state.containerTop.setValue(0);
    if (this.scrollRef) {
      if (typeof this.scrollRef.scrollToOffset === 'function') {
        // inner is FlatList
        this.scrollRef.scrollToOffset({
          offset: -gestureState.dy,
          animated: true,
        });
      } else if(typeof this.scrollRef.scrollTo === 'function') {
        // inner is ScrollView
        this.scrollRef.scrollTo({
          y: -gestureState.dy,
          animated: true,
        });
      }
    }
  }
}

onPanResponderRelease 處理釋放時的操作。

onPanResponderRelease(event, gestureState) {
  // 使用者放開了所有的觸控點,且此時檢視已經成為了響應者。
  // 一般來說這意味著一個手勢操作已經成功完成。
  // 判斷是否達到了觸發重新整理的條件
  const threshold = this.props.refreshTriggerHeight || this.props.headerHeight;
  if (this.containerTranslateY >= threshold) {
    // 觸發重新整理
    this.props.onRefresh();
  } else {
    // 沒到重新整理的位置,回退到頂部
    this._resetContainerPosition();
  }
  // 檢查 scrollEnabled 開關
  this._checkScroll();
}

剩下的就是如何區分容器的滑動,和下拉重新整理的觸發。

當 ScrollView 的 scrollEnabled 屬性設定為 false 時,可以禁止使用者滾動。因此,可以將 ScrollView 作為內容容器。當滾動到容器頂部的時候,關閉 ScrollView 的 scrollEnabled 屬性,通過設定 Animated.View 的 translateY,顯示自定義載入器。

<Animated.View style={[{ flex: 1, transform: [{ translateY: this.state.containerTop }] }]}>
  {child}
</Animated.View>

expo pulltorefresh1

經過試用,發現這個方案有以下幾個致命性問題:

  1. 由於下拉過程是通過觸控響應系統經前端反饋給原生檢視的,大量的資料通訊和頁面重繪會導致頁面的卡頓,在頁面資料量較大時會更加明顯;
  2. 上滑和下拉的切換時通過 ScrollView 的 Enable 的屬性控制的,這樣會造成手勢操作的中斷;
  3. 手勢滑動過程缺少阻尼函式,表現得不如原生下拉重新整理自然;

另外還有 ScrollView 的滑動和模擬的下拉過程滑動配合不夠默契的問題。

解決方案2

ScrollView 在 iOS 裝置下有個特性,如果內容範圍比滾動檢視本身大,在到達內容末尾的時候,可以彈性地拉動一截。可以將載入指示器放在頁面的上邊緣,彈性滾動時露出。這樣既不需要利用到手勢影響渲染速度,又可以將滾動和下拉過程很好的融合。

因此,只要處理好滾動操作的各階段事件就好。

onScroll = (event) => {
  // console.log('onScroll()');
  const { y } = event.nativeEvent.contentOffset
  this._offsetY = y
  if (this._dragFlag) {
    if (!this._isRefreshing) {
      const height = this.props.refreshViewHeight
      if (y <= -height) {
        this.setState({
          refreshStatus: RefreshStatus.releaseToRefresh,
          refreshTitle: this.props.refreshableTitleRelease
        })
      } else {
        this.setState({
          refreshStatus: RefreshStatus.pullToRefresh,
          refreshTitle: this.props.refreshableTitlePull
        })
      }
    }
  }
  if (this.props.onScroll) {
    this.props.onScroll(event)
  }
}

onScrollBeginDrag = (event) => {
  // console.log('onScrollBeginDrag()');
  this._dragFlag = true
  this._offsetY = event.nativeEvent.contentOffset.y
  if (this.props.onScrollBeginDrag) {
    this.props.onScrollBeginDrag(event)
  }
}

onScrollEndDrag = (event) => {
  // console.log('onScrollEndDrag()',  y);
  this._dragFlag = false
  const { y } = event.nativeEvent.contentOffset
  this._offsetY = y
  const height = this.props.refreshViewHeight
  if (!this._isRefreshing) {
    if (this.state.refreshStatus === RefreshStatus.releaseToRefresh) {
      this._isRefreshing = true
      this.setState({
        refreshStatus: RefreshStatus.refreshing,
        refreshTitle: this.props.refreshableTitleRefreshing
      })
      this._scrollview.scrollTo({ x: 0, y: -height, animated: true });
      this.props.onRefresh()
    }
  } else if (y <= 0) {
    this._scrollview.scrollTo({ x: 0, y: -height, animated: true })
  }
  if (this.props.onScrollEndDrag) {
    this.props.onScrollEndDrag(event)
  }
}

唯一美中不足的就是,iOS 支援超過內容的滑動,安卓不支援,需要單獨適配下安卓。

將載入指示器放在頁面內,通過 scrollTo 方法控制頁面距頂部距離,來模擬下拉空間。(iOS 和安卓方案已在 expo pulltorefresh2 給出)

expo pulltorefresh2

(demo 建議在移動裝置檢視,Web 端適配可嘗試將 onScrollBeginDrag onScrollEndDrag 更換為 onTouchStart onTouchEnd

總結

本文主要介紹了在 React Native 開發過程中,下拉重新整理元件的技術調研和實現過程。 Expo demo 包含了兩個方案的主要實現邏輯,讀者可根據自身業務需求做定製,有問題歡迎溝通。

參考連結

本文釋出自 網易雲音樂前端團隊,可自由轉載,轉載請在標題標明轉載並在顯著位置保留出處。我們一直在招人,如果你恰好準備換工作,又恰好喜歡雲音樂,那就 加入我們

相關文章