本文作者:李磊
背景
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} />
);
},
結合上面狀態分析,看到 onPanResponderMove
和 onPanResponderRelease
這兩個引數,基本是可以滿足下拉重新整理機制的操作流程的。
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>
經過試用,發現這個方案有以下幾個致命性問題:
- 由於下拉過程是通過觸控響應系統經前端反饋給原生檢視的,大量的資料通訊和頁面重繪會導致頁面的卡頓,在頁面資料量較大時會更加明顯;
- 上滑和下拉的切換時通過 ScrollView 的 Enable 的屬性控制的,這樣會造成手勢操作的中斷;
- 手勢滑動過程缺少阻尼函式,表現得不如原生下拉重新整理自然;
另外還有 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 給出)
(demo 建議在移動裝置檢視,Web 端適配可嘗試將 onScrollBeginDrag onScrollEndDrag
更換為 onTouchStart onTouchEnd
)
總結
本文主要介紹了在 React Native 開發過程中,下拉重新整理元件的技術調研和實現過程。 Expo demo 包含了兩個方案的主要實現邏輯,讀者可根據自身業務需求做定製,有問題歡迎溝通。
參考連結
- 下拉重新整理是哪個設計師想出來的?
- United States Patent: 8448084
- 「下拉重新整理」被申請專利保護之後,為什麼還有如此多的應用使用它?
- React Native 中文網/RefreshControl
- GitHub facebook/React Native/RefreshControl
- React Native 自定義下拉重新整理元件
- React Native 中文網/panresponder
- react-native-ultimate-listview
本文釋出自 網易雲音樂前端團隊,可自由轉載,轉載請在標題標明轉載並在顯著位置保留出處。我們一直在招人,如果你恰好準備換工作,又恰好喜歡雲音樂,那就 加入我們!