1.背景
无论是 Androi 还是 ios,下拉刷新都是一个很有必要也很重要的功能。那么在 RN(以下用 RN 表示 React Native )之中,我们该如何实现下拉刷新功能呢?RN 官方提供了一个用于 ScrollView , ListView 等带有滑动功能组件的下拉刷新组件 RefreshControl。查看 RefreshControl 相关源码可以发现,其实它是对原生下拉刷新组件的一个封装,好处是使用方便快捷。但缺点也很明显,就是它不可以进行自定义下拉刷新头部,并且只能使用与 ScrollView,ListView 这种带有滚动功能的组件之中。那么我们该如何去解决这两个问题呢?
先看下最终实现的效果,这里借助了 ScrollableTabView
2.实现原理分析
对于下拉刷新功能,其实它的原理很简单。就是对要操作的组件进行 y 轴方向的位置进行判断。当滚动到顶部的时候,此时如果下拉的话,那么就进行下拉刷新的操作,如果上拉的话,那么就进行原本组件的滚动操作。基于这个原理,找了一些第三方实现的框架,基本上实现方式都是通过 ScrollView,ListView 等的 onScroll 方法进行监听回调。然后设置 Enable 属性来控制其是否可以滚动。但在使用的过程中有两个问题,一个是 onScroll 回调的频率不够,很多时候在滚动到了顶部的时候不能正确回调数值。另外一个问题就是 Enable 属性的问题,当在修改 Enable 数值的时候,当前的手势操作会停止。具体反映到 UI 上的效果就是,完成一次下拉刷新之后,第一次向上滚动的效果不能触发。那么,能不能有其他的方式去实现 RN 上的下拉刷新呢?
3.实现过程
3.1 判断组件的滚动位置
在上面的原理分析中,一个重点就是判断要操作的组件的滚动位置,那么改如何去判断呢?在这里我们对 RN 的 View,ScrollView,ListView,FlatList 进行了相关的判断,不过要注意的是,FlatList 是 RN0.43 版本之后才出现的,所以如果你使用的 RN 版本小于 0.43 的话,那么你就要删除掉该下拉刷新框架关于 FlatList 的部分。
我们来看下如何进行相关的判断。
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 //这个方法需要自己去源码里面添加
}
//根据y的值来判断是否到达顶部
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,由于它没有滚动属性,所以它默认处于顶部。而对于 ListView 来说,通过查找它的源码,发现它有个 scrollProperties 属性,里面包含了一些滚动的属性值,而 scrollProperties.offset 就是表示横向或者纵向的滚动值。而对于 FlatList 而言,它并没相关的属性。但是发现 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 而言,并没有找到相关滚动位置的属性,所以在这里用 ListView 配合 ScrollView 来使用,将 ScrollView 作为
ListView 的一个子控件
//ScrollView 暂时没有找到比较好的方法去判断时候滚动到顶部,
//所以这里用ListView配合ScrollView进行使用
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>
)
}
}
那么当要操作的组件滚动到顶部的时候,此时下拉就是下拉刷新操作,而上拉就实现原本的操作逻辑
3.2 组件位置的布局控制
下拉刷新的滚动方式一般有两种,一种是内容跟随下拉头部一起下拉滚动,一种是内容固定不动,只有下拉头部在滚动。在这里用isContentScroll属性来进行选择判断
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()
, 这个就是我们要进行下拉刷新的内容,这个方法类似我们在 java 中的抽象方法,是一定要实现的,并且操作的内容的要指定 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 添加默认刷新头部
这里我们添加个默认的下拉刷新头部,用于当不添加下拉刷新头部时候的默认的显示
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 的展示效果,同时需要根据下拉的状态来进行头部效果的切换
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 的次数
3.4 下拉刷新手势控制
在下拉刷新之中,手势的控制是必不可少的一环,至于如何为组件添加手势,大家可以看下 RN 官网上的介绍
this.panResponder = PanResponder.create({
onStartShouldSetPanResponder: this.onShouldSetPanResponder,
onStartShouldSetPanResponderCapture: this.onShouldSetPanResponder,
onMoveShouldSetPanResponder: this.onShouldSetPanResponder,
onMoveShouldSetPanResponderCapture: this.onShouldSetPanResponder,
onPanResponderTerminationRequest: (evt, gestureState) => false, //这个很重要,这边不放权
onPanResponderMove: this.onPanResponderMove,
onPanResponderRelease: this.onPanResponderRelease,
onPanResponderTerminate: this.onPanResponderRelease,
});
这里比较重要的一点就是 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 //这个方法需要自己去源码里面添加
}
//根据y的值来判断是否到达顶部
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方法主要是对当前是否进行下拉操作进行判断。下拉的前提是内容滚动到顶部,下拉手势并且该内容需要下拉刷新操作( 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)
}
}
//下拉的时候根据高度进行对应的操作
onPullStateChange = (moveHeight) => {
//因为返回的moveHeight单位是px,所以要将this.topIndicatorHeight转化为px进行计算
let topHeight = index.dip2px(this.topIndicatorHeight)
if (moveHeight > 0 && moveHeight < topHeight) { //此时是下拉没有到位的状态
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")
//进行状态和下拉距离的回调
this.props.onPullStateChangeHeight && this.props.onPullStateChangeHeight(
this.pullSatte == "pulling", this.pullSatte == "pullok",
this.pullSatte == "pullrelease", moveHeight)
}
onPanResponderMove 方法中主要是对下拉时候头部组件 UI 进行判断,这里有三个状态的判断以及下拉距离的回调
onPanResponderRelease = (e, gesture) => {
if (this.pullSatte == 'pulling') { //没有下拉到位
this.resetDefaultXYHandler(); //重置状态
} else if (this.pullSatte == 'pullok') { //已经下拉到位了
//传入-1,表示此时进行的是释放刷新的操作
this.onPullStateChange(-1)
//进行下拉刷新的回调
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要进行刷新
this.onPullStateChange(-1)
});
}
onPanResponderRelease 方法中主要是下拉刷新完成或者下拉刷新中断时候对头部 UI 的一个重置,并且有相关的回调操作
4.属性和方法介绍
4.1 属性
Porp | Type | Optional | Default | Description |
---|---|---|---|---|
refreshable | bool | yes | true | 是否需要下拉刷新功能 |
isContentScroll | bool | yes | false | 在下拉的时候内容时候要一起跟着滚动 |
onPullRelease | func | yes | 刷新的回调 | |
topIndicatorRender | func | yes | 下拉刷新头部的样式,当它为空的时候就使用默认的 | |
topIndicatorHeight | number | yes | 下拉刷新头部的高度,当topIndicatorRender不为空的时候要设置正确的topIndicatorHeight | |
onPullStateChangeHeight | func | yes | 下拉时候的回调,主要是刷新的状态的下拉的距离 | |
onPushing | func | yes | 下拉时候的回调,告诉外界此时是否在下拉刷新 |
4.2 方法
startRefresh() : 手动调用下拉刷新功能
finishRefresh() : 结束下拉刷新
5.最后
该组件已经发布到 npm 仓库,使用的时候只需要 npm install react-native-rk-pull-to-refresh --save
就可以了,同时需要 react-native link react-native-rk-pull-to-refresh
,它的使用Demo已经上传Github了:https://github.com/hzl123456/react-native-rk-pull-to-refresh
另外:在使用过程中不要设置内容组件 Bounce 相关的属性为 false ,例如:ScrollView 的 bounces 属性( ios 特有)
6.更新与2018年1月9日
在使用的过程中,发现在 Android 中使用的过程中经常会出现下拉无法触发下拉刷新的问题,所以 Android 的下拉刷新采用原生组件封装的形式。对 android-Ultra-Pull-To-Refresh 进行封装。调用主要如下
'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转化为px进行计算
let topHeight = index.dip2px(this.topIndicatorHeight)
if (moveHeight > 0 && moveHeight < topHeight) { //此时是下拉没有到位的状态
this.pullState = "pulling"
} else if (moveHeight >= topHeight) { //下拉刷新到位
this.pullState = "pullok"
} else { //下拉刷新释放,此时返回的值为-1
this.pullState = "pullrelease"
}
//此时处于刷新中的状态
if (state == 3) {
this.pullState = "pullrelease"
}
//默认的设置
this.defaultTopSetting()
//告诉外界是否要锁住
this.props.onPushing && this.props.onPushing(this.pullState != "pullrelease")
//进行状态和下拉距离的回调
this.props.onPullStateChangeHeight && this.props.onPullStateChangeHeight(this.pullState, moveHeight)
}
finishRefresh = () => {
this.refresh && this.refresh.finishRefresh()
}
startRefresh = () => {
this.refresh && this.refresh.startRefresh()
}
}
同时修改了主动调用下拉刷新的的方法为 startRefresh() , 结束刷新的方法为 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 版本,发现 android 的下拉刷新头部无法隐藏,一直显示在最顶端,排查 RN 的源码发现。
public ReactViewGroup(Context context) {
super(context);
setClipChildren(false);
mDrawingOrderHelper = new ViewGroupDrawingOrderHelper(this);
}
ReactViewGroup 默认调用了setClipChildren(false)方法,这样子 View 将可以超出父 View 的布局范围,也就导致了我们的下拉刷新头部无法隐藏的问题。修改如下:
//设置所有的parent的clip属性为true,为了兼容RN的view默认为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() 的最后调用 setViewClipChildren(getParent()) 方法,修改下拉刷新控件的所有父 View 的 clipChildren 属性为 true,可以解决这个 bug。