1.背景
無(wú)論是 Androi 還是 ios,下拉刷新都是一個(gè)很有必要也很重要的功能。那么在 RN(以下用 RN 表示 React Native )之中,我們?cè)撊绾螌?shí)現(xiàn)下拉刷新功能呢?RN 官方提供了一個(gè)用于 ScrollView , ListView 等帶有滑動(dòng)功能組件的下拉刷新組件 RefreshControl。查看 RefreshControl 相關(guān)源碼可以發(fā)現(xiàn),其實(shí)它是對(duì)原生下拉刷新組件的一個(gè)封裝,好處是使用方便快捷。但缺點(diǎn)也很明顯,就是它不可以進(jìn)行自定義下拉刷新頭部,并且只能使用與 ScrollView,ListView 這種帶有滾動(dòng)功能的組件之中。那么我們?cè)撊绾稳ソ鉀Q這兩個(gè)問(wèn)題呢?
先看下最終實(shí)現(xiàn)的效果,這里借助了 ScrollableTabView


2.實(shí)現(xiàn)原理分析
對(duì)于下拉刷新功能,其實(shí)它的原理很簡(jiǎn)單。就是對(duì)要操作的組件進(jìn)行 y 軸方向的位置進(jìn)行判斷。當(dāng)滾動(dòng)到頂部的時(shí)候,此時(shí)如果下拉的話,那么就進(jìn)行下拉刷新的操作,如果上拉的話,那么就進(jìn)行原本組件的滾動(dòng)操作?;谶@個(gè)原理,找了一些第三方實(shí)現(xiàn)的框架,基本上實(shí)現(xiàn)方式都是通過(guò) ScrollView,ListView 等的 onScroll 方法進(jìn)行監(jiān)聽(tīng)回調(diào)。然后設(shè)置 Enable 屬性來(lái)控制其是否可以滾動(dòng)。但在使用的過(guò)程中有兩個(gè)問(wèn)題,一個(gè)是 onScroll 回調(diào)的頻率不夠,很多時(shí)候在滾動(dòng)到了頂部的時(shí)候不能正確回調(diào)數(shù)值。另外一個(gè)問(wèn)題就是 Enable 屬性的問(wèn)題,當(dāng)在修改 Enable 數(shù)值的時(shí)候,當(dāng)前的手勢(shì)操作會(huì)停止。具體反映到 UI 上的效果就是,完成一次下拉刷新之后,第一次向上滾動(dòng)的效果不能觸發(fā)。那么,能不能有其他的方式去實(shí)現(xiàn) RN 上的下拉刷新呢?
3.實(shí)現(xiàn)過(guò)程
3.1 判斷組件的滾動(dòng)位置
在上面的原理分析中,一個(gè)重點(diǎn)就是判斷要操作的組件的滾動(dòng)位置,那么改如何去判斷呢?在這里我們對(duì) RN 的 View,ScrollView,ListView,F(xiàn)latList 進(jìn)行了相關(guān)的判斷,不過(guò)要注意的是,F(xiàn)latList 是 RN0.43 版本之后才出現(xiàn)的,所以如果你使用的 RN 版本小于 0.43 的話,那么你就要?jiǎng)h除掉該下拉刷新框架關(guān)于 FlatList 的部分。
我們來(lái)看下如何進(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 //這個(gè)方法需要自己去源碼里面添加
}
//根據(jù)y的值來(lái)判斷是否到達(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;
}
首先對(duì)于普通的 View,由于它沒(méi)有滾動(dòng)屬性,所以它默認(rèn)處于頂部。而對(duì)于 ListView 來(lái)說(shuō),通過(guò)查找它的源碼,發(fā)現(xiàn)它有個(gè) scrollProperties 屬性,里面包含了一些滾動(dòng)的屬性值,而 scrollProperties.offset 就是表示橫向或者縱向的滾動(dòng)值。而對(duì)于 FlatList 而言,它并沒(méi)相關(guān)的屬性。但是發(fā)現(xiàn) VirtualizedList 中存在如下屬性,而 FlatList 是對(duì) VirtualizedList 的一個(gè)封裝
_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()
}
同時(shí)在 VirtualizedList(node_modules/react-native/Libraries/Lists/VirtualizedList.js) 添加如下方法
getScrollMetrics = () => {
return this._scrollMetrics
}
另外,對(duì)于 ScrollView 而言,并沒(méi)有找到相關(guān)滾動(dòng)位置的屬性,所以在這里用 ListView 配合 ScrollView 來(lái)使用,將 ScrollView 作為
ListView 的一個(gè)子控件
//ScrollView 暫時(shí)沒(méi)有找到比較好的方法去判斷時(shí)候滾動(dòng)到頂部,
//所以這里用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)要操作的組件滾動(dòng)到頂部的時(shí)候,此時(shí)下拉就是下拉刷新操作,而上拉就實(shí)現(xiàn)原本的操作邏輯
3.2 組件位置的布局控制
下拉刷新的滾動(dòng)方式一般有兩種,一種是內(nèi)容跟隨下拉頭部一起下拉滾動(dòng),一種是內(nèi)容固定不動(dòng),只有下拉頭部在滾動(dòng)。在這里用isContentScroll屬性來(lái)進(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>
);
}
從里面可以看到一個(gè)方法 this.getScrollable() , 這個(gè)就是我們要進(jìn)行下拉刷新的內(nèi)容,這個(gè)方法類似我們?cè)?java 中的抽象方法,是一定要實(shí)現(xiàn)的,并且操作的內(nèi)容的要指定 ref 為 this.scroll,舉個(gè)例子
export default class PullView extends Pullable {
getScrollable = () => {
return (
<View ref={(c) => {this.scroll = c;}}
{...this.props}>
{this.props.children}
</View>
);
}
}
3.3 添加默認(rèn)刷新頭部
這里我們添加個(gè)默認(rèn)的下拉刷新頭部,用于當(dāng)不添加下拉刷新頭部時(shí)候的默認(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 的展示效果,同時(shí)需要根據(jù)下拉的狀態(tài)來(lá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 方法來(lái)代替 setStat e的使用,減少 render 的次數(shù)
3.4 下拉刷新手勢(shì)控制
在下拉刷新之中,手勢(shì)的控制是必不可少的一環(huán),至于如何為組件添加手勢(shì),大家可以看下 RN 官網(wǎng)上的介紹
this.panResponder = PanResponder.create({
onStartShouldSetPanResponder: this.onShouldSetPanResponder,
onStartShouldSetPanResponderCapture: this.onShouldSetPanResponder,
onMoveShouldSetPanResponder: this.onShouldSetPanResponder,
onMoveShouldSetPanResponderCapture: this.onShouldSetPanResponder,
onPanResponderTerminationRequest: (evt, gestureState) => false, //這個(gè)很重要,這邊不放權(quán)
onPanResponderMove: this.onPanResponderMove,
onPanResponderRelease: this.onPanResponderRelease,
onPanResponderTerminate: this.onPanResponderRelease,
});
這里比較重要的一點(diǎn)就是 onPanResponderTerminationRequest (有其他組件請(qǐng)求使用手勢(shì)),這個(gè)時(shí)候不能將手勢(shì)控制交出去
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 //這個(gè)方法需要自己去源碼里面添加
}
//根據(jù)y的值來(lái)判斷是否到達(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方法主要是對(duì)當(dāng)前是否進(jìn)行下拉操作進(jìn)行判斷。下拉的前提是內(nèi)容滾動(dòng)到頂部,下拉手勢(shì)并且該內(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)
}
}
//下拉的時(shí)候根據(jù)高度進(jìn)行對(duì)應(yīng)的操作
onPullStateChange = (moveHeight) => {
//因?yàn)榉祷氐膍oveHeight單位是px,所以要將this.topIndicatorHeight轉(zhuǎn)化為px進(jìn)行計(jì)算
let topHeight = index.dip2px(this.topIndicatorHeight)
if (moveHeight > 0 && moveHeight < topHeight) { //此時(shí)是下拉沒(méi)有到位的狀態(tài)
this.pullSatte = "pulling"
} else if (moveHeight >= topHeight) { //下拉刷新到位
this.pullSatte = "pullok"
} else { //下拉刷新釋放,此時(shí)返回的值為-1
this.pullSatte = "pullrelease"
}
if (this.props.topIndicatorRender == null) { //沒(méi)有就自己來(lái)
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 方法中主要是對(duì)下拉時(shí)候頭部組件 UI 進(jìn)行判斷,這里有三個(gè)狀態(tài)的判斷以及下拉距離的回調(diào)
onPanResponderRelease = (e, gesture) => {
if (this.pullSatte == 'pulling') { //沒(méi)有下拉到位
this.resetDefaultXYHandler(); //重置狀態(tài)
} else if (this.pullSatte == 'pullok') { //已經(jīng)下拉到位了
//傳入-1,表示此時(shí)進(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 方法中主要是下拉刷新完成或者下拉刷新中斷時(shí)候?qū)︻^部 UI 的一個(gè)重置,并且有相關(guān)的回調(diào)操作
4.屬性和方法介紹
4.1 屬性
| Porp | Type | Optional | Default | Description |
|---|---|---|---|---|
| refreshable | bool | yes | true | 是否需要下拉刷新功能 |
| isContentScroll | bool | yes | false | 在下拉的時(shí)候內(nèi)容時(shí)候要一起跟著滾動(dòng) |
| onPullRelease | func | yes | 刷新的回調(diào) | |
| topIndicatorRender | func | yes | 下拉刷新頭部的樣式,當(dāng)它為空的時(shí)候就使用默認(rèn)的 | |
| topIndicatorHeight | number | yes | 下拉刷新頭部的高度,當(dāng)topIndicatorRender不為空的時(shí)候要設(shè)置正確的topIndicatorHeight | |
| onPullStateChangeHeight | func | yes | 下拉時(shí)候的回調(diào),主要是刷新的狀態(tài)的下拉的距離 | |
| onPushing | func | yes | 下拉時(shí)候的回調(diào),告訴外界此時(shí)是否在下拉刷新 |
4.2 方法
startRefresh() : 手動(dòng)調(diào)用下拉刷新功能
finishRefresh() : 結(jié)束下拉刷新
5.最后
該組件已經(jīng)發(fā)布到 npm 倉(cāng)庫(kù),使用的時(shí)候只需要 npm install react-native-rk-pull-to-refresh --save 就可以了,同時(shí)需要 react-native link react-native-rk-pull-to-refresh,它的使用Demo已經(jīng)上傳Github了:https://github.com/hzl123456/react-native-rk-pull-to-refresh
另外:在使用過(guò)程中不要設(shè)置內(nèi)容組件 Bounce 相關(guān)的屬性為 false ,例如:ScrollView 的 bounces 屬性( ios 特有)
6.更新與2018年1月9日
在使用的過(guò)程中,發(fā)現(xiàn)在 Android 中使用的過(guò)程中經(jīng)常會(huì)出現(xiàn)下拉無(wú)法觸發(fā)下拉刷新的問(wèn)題,所以 Android 的下拉刷新采用原生組件封裝的形式。對(duì) 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
//因?yàn)榉祷氐膍oveHeight單位是px,所以要將this.topIndicatorHeight轉(zhuǎn)化為px進(jìn)行計(jì)算
let topHeight = index.dip2px(this.topIndicatorHeight)
if (moveHeight > 0 && moveHeight < topHeight) { //此時(shí)是下拉沒(méi)有到位的狀態(tài)
this.pullState = "pulling"
} else if (moveHeight >= topHeight) { //下拉刷新到位
this.pullState = "pullok"
} else { //下拉刷新釋放,此時(shí)返回的值為-1
this.pullState = "pullrelease"
}
//此時(shí)處于刷新中的狀態(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()
}
}
同時(shí)修改了主動(dòng)調(diào)用下拉刷新的的方法為 startRefresh() , 結(jié)束刷新的方法為 finishRefresh() , 其他的使用方式和方法沒(méi)有修改
7.更新于2018年5月14日
由于 React Native 版本的更新,移除了 React.PropTypes ,更新了 PropTypes 的引入方式,改動(dòng)如下(基于 RN 0.55.4 版本):
1.使用 import PropTypes from 'prop-types' 引入 PropTypes
2.修改 FlatList 滑動(dòng)距離的判斷,這樣你就不需要再修改源碼了
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日
最近升級(jí)了 React Native 到 0.58.1 版本,發(fā)現(xiàn) android 的下拉刷新頭部無(wú)法隱藏,一直顯示在最頂端,排查 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)致了我們的下拉刷新頭部無(wú)法隱藏的問(wèn)題。修改如下:
//設(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,可以解決這個(gè) bug。