1.背景
無論是 Androi 還是 ios,下拉刷新都是一個很有必要也很重要的功能。那么在 RN(以下用 RN 表示 React Native )之中,我們該如何實現(xiàn)下拉刷新功能呢?RN 官方提供了一個用于 ScrollView , ListView 等帶有滑動功能組件的下拉刷新組件 RefreshControl。查看 RefreshControl 相關(guān)源碼可以發(fā)現(xiàn),其實它是對原生下拉刷新組件的一個封裝,好處是使用方便快捷。但缺點(diǎn)也很明顯,就是它不可以進(jìn)行自定義下拉刷新頭部,并且只能使用與 ScrollView,ListView 這種帶有滾動功能的組件之中。那么我們該如何去解決這兩個問題呢?
先看下最終實現(xiàn)的效果,這里借助了 ScrollableTabView
2.實現(xiàn)原理分析
對于下拉刷新功能,其實它的原理很簡單。就是對要操作的組件進(jìn)行 y 軸方向的位置進(jìn)行判斷。當(dāng)滾動到頂部的時候,此時如果下拉的話,那么就進(jìn)行下拉刷新的操作,如果上拉的話,那么就進(jìn)行原本組件的滾動操作。基于這個原理,找了一些第三方實現(xiàn)的框架,基本上實現(xiàn)方式都是通過 ScrollView,ListView 等的 onScroll 方法進(jìn)行監(jiān)聽回調(diào)。然后設(shè)置 Enable 屬性來控制其是否可以滾動。但在使用的過程中有兩個問題,一個是 onScroll 回調(diào)的頻率不夠,很多時候在滾動到了頂部的時候不能正確回調(diào)數(shù)值。另外一個問題就是 Enable 屬性的問題,當(dāng)在修改 Enable 數(shù)值的時候,當(dāng)前的手勢操作會停止。具體反映到 UI 上的效果就是,完成一次下拉刷新之后,第一次向上滾動的效果不能觸發(fā)。那么,能不能有其他的方式去實現(xiàn) RN 上的下拉刷新呢?
3.實現(xiàn)過程
3.1 判斷組件的滾動位置
在上面的原理分析中,一個重點(diǎn)就是判斷要操作的組件的滾動位置,那么改如何去判斷呢?在這里我們對 RN 的 View,ScrollView,ListView,F(xiàn)latList 進(jìn)行了相關(guān)的判斷,不過要注意的是,F(xiàn)latList 是 RN0.43 版本之后才出現(xiàn)的,所以如果你使用的 RN 版本小于 0.43 的話,那么你就要刪除掉該下拉刷新框架關(guān)于 FlatList 的部分。
我們來看下如何進(jìn)行相關(guān)的判斷。
onShouldSetPanResponder = (e, gesture) => {
let y = 0
if (this.scroll instanceof ListView) { //ListView下的判斷
y = this.scroll.scrollProperties.offset;
} else if (this.scroll instanceof FlatList) {//FlatList下的判斷
y = this.scroll.getScrollMetrics().offset //這個方法需要自己去源碼里面添加
}
//根據(jù)y的值來判斷是否到達(dá)頂部
this.state.atTop = (y <= 0)
if (this.state.atTop && index.isDownGesture(gesture.dx, gesture.dy) && this.props.refreshable) {
this.lastY = this.state.pullPan.y._value;
return true;
}
return false;
}
首先對于普通的 View,由于它沒有滾動屬性,所以它默認(rèn)處于頂部。而對于 ListView 來說,通過查找它的源碼,發(fā)現(xiàn)它有個 scrollProperties 屬性,里面包含了一些滾動的屬性值,而 scrollProperties.offset 就是表示橫向或者縱向的滾動值。而對于 FlatList 而言,它并沒相關(guān)的屬性。但是發(fā)現(xiàn) VirtualizedList 中存在如下屬性,而 FlatList 是對 VirtualizedList 的一個封裝
_scrollMetrics = {
visibleLength: 0, contentLength: 0, offset: 0, dt: 10, velocity: 0, timestamp: 0,
};
那么很容易想到自己添加方法去獲取。那么在
FlatList(node_modules/react-native/Libraries/Lists/FlatList.js) 添加如下方法
getScrollMetrics = () => {
return this._listRef.getScrollMetrics()
}
同時在 VirtualizedList(node_modules/react-native/Libraries/Lists/VirtualizedList.js) 添加如下方法
getScrollMetrics = () => {
return this._scrollMetrics
}
另外,對于 ScrollView 而言,并沒有找到相關(guān)滾動位置的屬性,所以在這里用 ListView 配合 ScrollView 來使用,將 ScrollView 作為
ListView 的一個子控件
//ScrollView 暫時沒有找到比較好的方法去判斷時候滾動到頂部,
//所以這里用ListView配合ScrollView進(jìn)行使用
export default class PullScrollView extends Pullable {
getScrollable=()=> {
return (
<ListView
ref={(c) => {this.scroll = c;}}
renderRow={this.renderRow}
dataSource={new ListView.DataSource({rowHasChanged: (r1, r2) => r1 !== r2}).cloneWithRows([])}
enableEmptySections={true}
renderHeader={this._renderHeader}/>
);
}
renderRow = (rowData, sectionID, rowID, highlightRow) => {
return <View/>
}
_renderHeader = () => {
return (
<ScrollView
scrollEnabled={false}>
{this.props.children}
</ScrollView>
)
}
}
那么當(dāng)要操作的組件滾動到頂部的時候,此時下拉就是下拉刷新操作,而上拉就實現(xiàn)原本的操作邏輯
3.2 組件位置的布局控制
下拉刷新的滾動方式一般有兩種,一種是內(nèi)容跟隨下拉頭部一起下拉滾動,一種是內(nèi)容固定不動,只有下拉頭部在滾動。在這里用isContentScroll屬性來進(jìn)行選擇判斷
render() {
return (
<View style={styles.wrap} {...this.panResponder.panHandlers} onLayout={this.onLayout}>
{this.props.isContentScroll ?
<View pointerEvents='box-none'>
<Animated.View style={[this.state.pullPan.getLayout()]}>
{this.renderTopIndicator()}
<View ref={(c) => {this.scrollContainer = c;}}
style={{width: this.state.width, height: this.state.height}}>
{this.getScrollable()}
</View>
</Animated.View>
</View> :
<View>
<View ref={(c) => {this.scrollContainer = c;}}
style={{width: this.state.width, height: this.state.height}}>
{this.getScrollable()}
</View>
<View pointerEvents='box-none'
style={{position: 'absolute', left: 0, right: 0, top: 0}}>
<Animated.View style={[this.state.pullPan.getLayout()]}>
{this.renderTopIndicator()}
</Animated.View>
</View>
</View>}
</View>
);
}
從里面可以看到一個方法 this.getScrollable()
, 這個就是我們要進(jìn)行下拉刷新的內(nèi)容,這個方法類似我們在 java 中的抽象方法,是一定要實現(xiàn)的,并且操作的內(nèi)容的要指定 ref 為 this.scroll,舉個例子
export default class PullView extends Pullable {
getScrollable = () => {
return (
<View ref={(c) => {this.scroll = c;}}
{...this.props}>
{this.props.children}
</View>
);
}
}
3.3 添加默認(rèn)刷新頭部
這里我們添加個默認(rèn)的下拉刷新頭部,用于當(dāng)不添加下拉刷新頭部時候的默認(rèn)的顯示
defaultTopIndicatorRender = () => {
return (
<View style={{flexDirection: 'row', justifyContent: 'center', alignItems: 'center', height: index.defaultTopIndicatorHeight}}>
<ActivityIndicator size="small" color="gray" style={{marginRight: 5}}/>
<Text ref={(c) => {
this.txtPulling = c;
}} style={styles.hide}>{index.pulling}</Text>
<Text ref={(c) => {
this.txtPullok = c;
}} style={styles.hide}>{index.pullok}</Text>
<Text ref={(c) => {
this.txtPullrelease = c;
}} style={styles.hide}>{index.pullrelease}</Text>
</View>
);
}
效果就是上面的 gif 中除了 View 的 tab 的展示效果,同時需要根據(jù)下拉的狀態(tài)來進(jìn)行頭部效果的切換
if (this.pullSatte == "pulling") {
this.txtPulling && this.txtPulling.setNativeProps({style: styles.show});
this.txtPullok && this.txtPullok.setNativeProps({style: styles.hide});
this.txtPullrelease && this.txtPullrelease.setNativeProps({style: styles.hide});
} else if (this.pullSatte == "pullok") {
this.txtPulling && this.txtPulling.setNativeProps({style: styles.hide});
this.txtPullok && this.txtPullok.setNativeProps({style: styles.show});
this.txtPullrelease && this.txtPullrelease.setNativeProps({style: styles.hide});
} else if (this.pullSatte == "pullrelease") {
this.txtPulling && this.txtPulling.setNativeProps({style: styles.hide});
this.txtPullok && this.txtPullok.setNativeProps({style: styles.hide});
this.txtPullrelease && this.txtPullrelease.setNativeProps({style: styles.show});
}
const styles = StyleSheet.create({
wrap: {
flex: 1,
flexGrow: 1,
zIndex: -999,
},
hide: {
position: 'absolute',
left: 10000,
backgroundColor: 'transparent'
},
show: {
position: 'relative',
left: 0,
backgroundColor: 'transparent'
}
});
這里借助 setNativeProps 方法來代替 setStat e的使用,減少 render 的次數(shù)
3.4 下拉刷新手勢控制
在下拉刷新之中,手勢的控制是必不可少的一環(huán),至于如何為組件添加手勢,大家可以看下 RN 官網(wǎng)上的介紹
this.panResponder = PanResponder.create({
onStartShouldSetPanResponder: this.onShouldSetPanResponder,
onStartShouldSetPanResponderCapture: this.onShouldSetPanResponder,
onMoveShouldSetPanResponder: this.onShouldSetPanResponder,
onMoveShouldSetPanResponderCapture: this.onShouldSetPanResponder,
onPanResponderTerminationRequest: (evt, gestureState) => false, //這個很重要,這邊不放權(quán)
onPanResponderMove: this.onPanResponderMove,
onPanResponderRelease: this.onPanResponderRelease,
onPanResponderTerminate: this.onPanResponderRelease,
});
這里比較重要的一點(diǎn)就是 onPanResponderTerminationRequest (有其他組件請求使用手勢),這個時候不能將手勢控制交出去
onShouldSetPanResponder = (e, gesture) => {
let y = 0
if (this.scroll instanceof ListView) { //ListView下的判斷
y = this.scroll.scrollProperties.offset;
} else if (this.scroll instanceof FlatList) {//FlatList下的判斷
y = this.scroll.getScrollMetrics().offset //這個方法需要自己去源碼里面添加
}
//根據(jù)y的值來判斷是否到達(dá)頂部
this.state.atTop = (y <= 0)
if (this.state.atTop && index.isDownGesture(gesture.dx, gesture.dy) && this.props.refreshable) {
this.lastY = this.state.pullPan.y._value;
return true;
}
return false;
}
onShouldSetPanResponder方法主要是對當(dāng)前是否進(jìn)行下拉操作進(jìn)行判斷。下拉的前提是內(nèi)容滾動到頂部,下拉手勢并且該內(nèi)容需要下拉刷新操作( refreshable 屬性)
onPanResponderMove = (e, gesture) => {
if (index.isDownGesture(gesture.dx, gesture.dy) && this.props.refreshable) { //下拉
this.state.pullPan.setValue({x: this.defaultXY.x, y: this.lastY + gesture.dy / 2});
this.onPullStateChange(gesture.dy)
}
}
//下拉的時候根據(jù)高度進(jìn)行對應(yīng)的操作
onPullStateChange = (moveHeight) => {
//因為返回的moveHeight單位是px,所以要將this.topIndicatorHeight轉(zhuǎn)化為px進(jìn)行計算
let topHeight = index.dip2px(this.topIndicatorHeight)
if (moveHeight > 0 && moveHeight < topHeight) { //此時是下拉沒有到位的狀態(tài)
this.pullSatte = "pulling"
} else if (moveHeight >= topHeight) { //下拉刷新到位
this.pullSatte = "pullok"
} else { //下拉刷新釋放,此時返回的值為-1
this.pullSatte = "pullrelease"
}
if (this.props.topIndicatorRender == null) { //沒有就自己來
if (this.pullSatte == "pulling") {
this.txtPulling && this.txtPulling.setNativeProps({style: styles.show});
this.txtPullok && this.txtPullok.setNativeProps({style: styles.hide});
this.txtPullrelease && this.txtPullrelease.setNativeProps({style: styles.hide});
} else if (this.pullSatte == "pullok") {
this.txtPulling && this.txtPulling.setNativeProps({style: styles.hide});
this.txtPullok && this.txtPullok.setNativeProps({style: styles.show});
this.txtPullrelease && this.txtPullrelease.setNativeProps({style: styles.hide});
} else if (this.pullSatte == "pullrelease") {
this.txtPulling && this.txtPulling.setNativeProps({style: styles.hide});
this.txtPullok && this.txtPullok.setNativeProps({style: styles.hide});
this.txtPullrelease && this.txtPullrelease.setNativeProps({style: styles.show});
}
}
//告訴外界是否要鎖住
this.props.onPushing && this.props.onPushing(this.pullSatte != "pullrelease")
//進(jìn)行狀態(tài)和下拉距離的回調(diào)
this.props.onPullStateChangeHeight && this.props.onPullStateChangeHeight(
this.pullSatte == "pulling", this.pullSatte == "pullok",
this.pullSatte == "pullrelease", moveHeight)
}
onPanResponderMove 方法中主要是對下拉時候頭部組件 UI 進(jìn)行判斷,這里有三個狀態(tài)的判斷以及下拉距離的回調(diào)
onPanResponderRelease = (e, gesture) => {
if (this.pullSatte == 'pulling') { //沒有下拉到位
this.resetDefaultXYHandler(); //重置狀態(tài)
} else if (this.pullSatte == 'pullok') { //已經(jīng)下拉到位了
//傳入-1,表示此時進(jìn)行的是釋放刷新的操作
this.onPullStateChange(-1)
//進(jìn)行下拉刷新的回調(diào)
this.props.onPullRelease && this.props.onPullRelease();
//重置刷新的頭部到初始位置
Animated.timing(this.state.pullPan, {
toValue: {x: 0, y: 0},
easing: Easing.linear,
duration: this.duration
}).start();
}
}
//重置刷新的操作
resetDefaultXYHandler = () => {
Animated.timing(this.state.pullPan, {
toValue: this.defaultXY,
easing: Easing.linear,
duration: this.duration
}).start(() => {
//ui要進(jìn)行刷新
this.onPullStateChange(-1)
});
}
onPanResponderRelease 方法中主要是下拉刷新完成或者下拉刷新中斷時候?qū)︻^部 UI 的一個重置,并且有相關(guān)的回調(diào)操作
4.屬性和方法介紹
4.1 屬性
Porp | Type | Optional | Default | Description |
---|---|---|---|---|
refreshable | bool | yes | true | 是否需要下拉刷新功能 |
isContentScroll | bool | yes | false | 在下拉的時候內(nèi)容時候要一起跟著滾動 |
onPullRelease | func | yes | 刷新的回調(diào) | |
topIndicatorRender | func | yes | 下拉刷新頭部的樣式,當(dāng)它為空的時候就使用默認(rèn)的 | |
topIndicatorHeight | number | yes | 下拉刷新頭部的高度,當(dāng)topIndicatorRender不為空的時候要設(shè)置正確的topIndicatorHeight | |
onPullStateChangeHeight | func | yes | 下拉時候的回調(diào),主要是刷新的狀態(tài)的下拉的距離 | |
onPushing | func | yes | 下拉時候的回調(diào),告訴外界此時是否在下拉刷新 |
4.2 方法
startRefresh() : 手動調(diào)用下拉刷新功能
finishRefresh() : 結(jié)束下拉刷新
5.最后
該組件已經(jīng)發(fā)布到 npm 倉庫,使用的時候只需要 npm install react-native-rk-pull-to-refresh --save
就可以了,同時需要 react-native link react-native-rk-pull-to-refresh
,它的使用Demo已經(jīng)上傳Github了:https://github.com/hzl123456/react-native-rk-pull-to-refresh
另外:在使用過程中不要設(shè)置內(nèi)容組件 Bounce 相關(guān)的屬性為 false ,例如:ScrollView 的 bounces 屬性( ios 特有)
6.更新與2018年1月9日
在使用的過程中,發(fā)現(xiàn)在 Android 中使用的過程中經(jīng)常會出現(xiàn)下拉無法觸發(fā)下拉刷新的問題,所以 Android 的下拉刷新采用原生組件封裝的形式。對 android-Ultra-Pull-To-Refresh 進(jìn)行封裝。調(diào)用主要如下
'use strict';
import React from 'react';
import RefreshLayout from '../view/RefreshLayout'
import RefreshHeader from '../view/RefreshHeader'
import PullRoot from './PullRoot'
import * as index from './info';
export default class Pullable extends PullRoot {
constructor(props) {
super(props);
this.pullState = 'pulling'; //pulling,pullok,pullrelease
this.topIndicatorHeight = this.props.topIndicatorHeight ? this.props.topIndicatorHeight : index.defaultTopIndicatorHeight;
}
render() {
return (
<RefreshLayout
{...this.props}
style={{flex: 1}}
ref={(c) => this.refresh = c}>
<RefreshHeader
style={{flex: 1, height: this.topIndicatorHeight}}
viewHeight={index.dip2px(this.topIndicatorHeight)}
onPushingState={(e) => this.onPushingState(e)}>
{this.renderTopIndicator()}
</RefreshHeader>
{this.getScrollable()}
</RefreshLayout>
)
}
onPushingState = (event) => {
let moveHeight = event.nativeEvent.moveHeight
let state = event.nativeEvent.state
//因為返回的moveHeight單位是px,所以要將this.topIndicatorHeight轉(zhuǎn)化為px進(jìn)行計算
let topHeight = index.dip2px(this.topIndicatorHeight)
if (moveHeight > 0 && moveHeight < topHeight) { //此時是下拉沒有到位的狀態(tài)
this.pullState = "pulling"
} else if (moveHeight >= topHeight) { //下拉刷新到位
this.pullState = "pullok"
} else { //下拉刷新釋放,此時返回的值為-1
this.pullState = "pullrelease"
}
//此時處于刷新中的狀態(tài)
if (state == 3) {
this.pullState = "pullrelease"
}
//默認(rèn)的設(shè)置
this.defaultTopSetting()
//告訴外界是否要鎖住
this.props.onPushing && this.props.onPushing(this.pullState != "pullrelease")
//進(jìn)行狀態(tài)和下拉距離的回調(diào)
this.props.onPullStateChangeHeight && this.props.onPullStateChangeHeight(this.pullState, moveHeight)
}
finishRefresh = () => {
this.refresh && this.refresh.finishRefresh()
}
startRefresh = () => {
this.refresh && this.refresh.startRefresh()
}
}
同時修改了主動調(diào)用下拉刷新的的方法為 startRefresh() , 結(jié)束刷新的方法為 finishRefresh() , 其他的使用方式和方法沒有修改
7.更新于2018年5月14日
由于 React Native 版本的更新,移除了 React.PropTypes ,更新了 PropTypes 的引入方式,改動如下(基于 RN 0.55.4 版本):
1.使用 import PropTypes from 'prop-types' 引入 PropTypes
2.修改 FlatList 滑動距離的判斷,這樣你就不需要再修改源碼了
let y = 0
if (this.scroll instanceof ListView) { //ListView下的判斷
y = this.scroll.scrollProperties.offset;
} else if (this.scroll instanceof FlatList) {//FlatList下的判斷
y = this.scroll._listRef._getScrollMetrics().offset
}
8.更新于2019年2月15日
最近升級了 React Native 到 0.58.1 版本,發(fā)現(xiàn) android 的下拉刷新頭部無法隱藏,一直顯示在最頂端,排查 RN 的源碼發(fā)現(xiàn)。
public ReactViewGroup(Context context) {
super(context);
setClipChildren(false);
mDrawingOrderHelper = new ViewGroupDrawingOrderHelper(this);
}
ReactViewGroup 默認(rèn)調(diào)用了setClipChildren(false)方法,這樣子 View 將可以超出父 View 的布局范圍,也就導(dǎo)致了我們的下拉刷新頭部無法隱藏的問題。修改如下:
//設(shè)置所有的parent的clip屬性為true,為了兼容RN的view默認(rèn)為false的bug
setViewClipChildren(getParent());
private void setViewClipChildren(ViewParent rootView) {
if (rootView != null && rootView instanceof ViewGroup) {
ViewGroup viewGroup = ((ViewGroup) rootView);
viewGroup.setClipChildren(true);
setViewClipChildren(viewGroup.getParent());
}
}
在 onFinishInflate() 的最后調(diào)用 setViewClipChildren(getParent()) 方法,修改下拉刷新控件的所有父 View 的 clipChildren 屬性為 true,可以解決這個 bug。