React Native 實現自定義下拉刷新組件

本文做者:李磊

背景

Web 應用若是要更新列表數據,通常會選擇點擊左上角刷新按鈕,或使用快捷鍵 Ctrl+F5,進行頁面資源和數據的全量更新。若是頁面提供了刷新按鈕或是翻頁按鈕,也能夠點擊只作數據更新。html

但移動客戶端屏幕寸土寸金,不管是加上一個刷新按鈕,仍是配合愈來愈少的手機按鍵來作刷新操做,都不是十分便捷的方案。前端

因而,在這方寸之間,各類各樣的滑動方案和手勢方案來觸發事件,成了移動客戶端的廣泛趨勢。在刷新數據方面,移動端最經常使用的方案就是下拉刷新的機制。react

什麼是下拉刷新?

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

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

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

  • 在一種佈置中,顯示包含內容項的滾動列表;
  • 能夠接受與滾動命令相關聯的輸入;
  • 根據滾動命令,顯示一個滾動刷新的觸發器;
  • 基於滾動命令,肯定滾動刷新的觸發器被激活後,刷新滾動列表中的內容。

簡單來講,下拉加載的機制包含三個狀態:react-native

  • 「下拉更新」:展現用戶下拉可擴展的操做。
  • 「鬆開更新」:提示用戶下拉操做的臨界點。
  • 「數據更新動畫」:手勢釋放,提醒用戶數據正在更新。

在那以後,不少以 news feed 爲主的移動客戶端都相繼採用了這個設計。數組

React Native 支持下拉刷新麼?

React Native 提供了 RefreshControl 組件,能夠用在 ScrollView 或 FlatList 內部,爲其添加下拉刷新的功能。app

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

因爲適配的原生方案不一樣,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 包含了兩個方案的主要實現邏輯,讀者可根據自身業務需求作定製,有問題歡迎溝通。

參考連接

本文發佈自 網易雲音樂前端團隊,可自由轉載,轉載請在標題標明轉載並在顯著位置保留出處。咱們一直在招人,若是你剛好準備換工做,又剛好喜歡雲音樂,那就 加入咱們
相關文章
相關標籤/搜索