0%

react-native-封装下拉刷新和加载更多


最近又是很长一段时间没写文章了,忙着学习webpack,react,react-router,eslint,babel,mobx,一系列框架,开源的SimpleOne项目也在持续维护,把以前的ES5语法改为ES6,移除了react-timer-mixin这个模块,当然这个项目中的某些技术点需要总结,接下来的几篇会对这些技术点做一个记录。我们的app经常会用到长列表的展示方式,显示内容过多,大多数情况会用到分页加载,滑动到列表的底部,通过加载更多的方式,显示下一页的内容,看起来是一页,其实是多页拼接的长列表。一般我们的列表都有下拉刷新,但是这些react-native没有封装好的组件,我们需要自己实现,下面详细记录实现过程,如果你有更好的实现方式,希望一起学习讨论。


当前的效果图gif:
效果图1
效果图2

要实现下拉刷新和加载更多,需要对scrollview做一个扩展,对scrollview的滑动事件做监听和逻辑处理,利用官方的PanResponder 做手势识别,显示一个自定义的顶部下拉刷新视图,所以我们可以利用react-native现有的组件封装一个支持下拉刷新和加载更多的view来实现我们的需求。这里我们定义下拉刷新的动画是一个常见的图片旋转+文字提示。

对ScrollView封装和使用

封装ScrollView实现下拉刷新和加载更多

首先,定义下拉到位时距离顶部的高度,默认下拉到位的时长,默认顶部刷新视图的高度,下拉刷新提示文字的显示状态,主要是4个状态,默认状态,正在下拉状态,下拉到位状态,和下拉释放状态。

1
2
3
4
5
6
7
8
9
const defaultDuration = 300; //默认时长
/**
* 提示文字的显示状态
* @type {{pulling: boolean, pullok: boolean, pullrelease: boolean}}
*/
const defaultState = {pulling: false, pullok: false, pullrelease: false}; //默认状态
const statePulling = {pulling: true, pullok: false, pullrelease: false}; //正在下拉状态
const statePullok = {pulling: false, pullok: true, pullrelease: false}; //下拉到位状态
const statePullrelease = {pulling: false, pullok: false, pullrelease: true}; //下拉释放状态

接下来是定义手势判断

1
2
3
4
5
6
7
8
9
10
11
12
//向下手势
const isDownGesture = (x, y) => {
return y > 0 && (y > Math.abs(x));
};
//向上手势
const isUpGesture = (x, y) => {
return y < 0 && (Math.abs(x) < Math.abs(y));
};
//垂直方向手势
const isVerticalGesture = (x, y) => {
return (Math.abs(x) < Math.abs(y));
};

定义我们自定义的组件PullScollView继承自Component,定义默认值,传参类型,在构造函数中初始化

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
static defaultProps = {
topRefreshHeight: 50, //顶部刷新视图的高度
pullOkMargin:100 //下拉到位状态时距离顶部的高度
}

static propTypes = {
topRefreshHeight: PropTypes.number,
pullOkMargin: PropTypes.number,
onPulling: PropTypes.func,
onPullOk: PropTypes.func,
onPullRelease: PropTypes.func,
onLoadMore: PropTypes.func,
onScroll: PropTypes.func,
children: PropTypes.array,
style: PropTypes.object,
loadMoreState: PropTypes.number,
onRetry: PropTypes.func,
}

constructor(props) {
super(props)
this.defaultScrollEnabled = false
this.topRefreshHeight = this.props.topRefreshHeight
this.defaultXY = {x: 0, y: this.topRefreshHeight * -1}
this.pullOkMargin = this.props.pullOkMargin
this.state = Object.assign({}, props, {
pullPan: new Animated.ValueXY(this.defaultXY), //下拉区域
scrollEnabled: this.defaultScrollEnabled, //滚动启动
height: 0,
})
// 滚动监听
this.onScroll = this.onScroll.bind(this)
// 布局监听
this.onLayout = this.onLayout.bind(this)
// 下拉状态监听
this.isPullState = this.isPullState.bind(this)
// 下拉未到位,重置回调
this.resetDefaultXYHandler = this.resetDefaultXYHandler.bind(this)
// 数据加载完,重置归位
this.resolveHandler = this.resolveHandler.bind(this)
// 顶部绘制
this.renderTopRefresh = this.renderTopRefresh.bind(this)
// 默认顶部绘制
this.defaultTopRefreshRender = this.defaultTopRefreshRender.bind(this)
// 多点触摸识别
this.panResponder = PanResponder.create({
onMoveShouldSetPanResponder: this.onShouldSetPanResponder.bind(this),
onPanResponderMove: this.onPanResponderMove.bind(this),// 最近一次的移动距离为gestureState.move{X,Y}
onPanResponderRelease: this.onPanResponderRelease.bind(this),//放开了所有的触摸点,且此时视图已经成为了响应者。
})
this.setPullState(defaultState)// 设置提示文字的默认状态

}

PanResponder手势响应回调中的处理

1
2
3
4
5
6
7
8
9
10
11
12
13
// 手势响应回调,是否处理
onShouldSetPanResponder(e, gesture) {
//非垂直手势不处理
if (!isVerticalGesture(gesture.dx, gesture.dy)) {
return false;
}
if (!this.state.scrollEnabled) {
this.lastY = this.state.pullPan.y._value;
return true;
} else {
return false;
}
}

PanResponder手势移动回调中的处理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// 手势移动的处理
onPanResponderMove(e, gesture) {
if (isUpGesture(gesture.dx, gesture.dy)) { //向上手势
// 如果处于下拉状态,重置
if(this.isPullState()) {
this.resetDefaultXYHandler();
} else { // 恢复到默认位置
this.scroll.scrollTo({x:0, y: gesture.dy * -1});
}
return;
} else if (isDownGesture(gesture.dx, gesture.dy)) { //向下手势
// 设置下拉区域
this.state.pullPan.setValue({x: this.defaultXY.x, y: this.lastY + gesture.dy / 2});
if (gesture.dy < this.topRefreshHeight + this.pullOkMargin) { //正在下拉
// 之前状态不是正在下拉状态,调用正在下拉回调
if (!this.curState.pulling) {
this.props.onPulling && this.props.onPulling();
}
// 记录当前为下拉状态
this.setPullState(statePulling);
} else { //下拉到位
// 之前状态不是下拉到位状态,调用下拉到位回调
if (!this.state.pullok) {
this.props.onPullOk && this.props.onPullOk();
}
// 记录当前为下拉到位状态
this.setPullState(statePullok);
}
}
}

PanResponder手势释放回调中的处理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// 手势释放
onPanResponderRelease() {
if (this.curState.pulling) { // 之前是下拉状态
this.resetDefaultXYHandler(); //重置状态
}
if (this.curState.pullok) { // 之前是下拉到位状态
// 之前没有松开
if (!this.curState.pullrelease) {
// 之前是刷新中,调用数据处理回调
if (this.props.onPullRelease) {
this.props.onPullRelease(this.resolveHandler);
} else { // 重置
setTimeout(() => {this.resetDefaultXYHandler()}, 3000);
}
}
this.setPullState(statePullrelease); //记录当前状态为完成下拉,已松开
// 刷新view 的下拉动画开启
Animated.timing(this.state.pullPan, {
toValue: {x: 0, y: 0},
easing: Easing.linear,
duration: defaultDuration
}).start();

// 刷新视图中的转圈动画开启
this.spin();
}
}

下拉重置,注意这里需要停止转圈的动画,根据当前剩余的变化值去初始化动画,如果没有这个操作,可能导致后面几次下拉刷新显示的刷新视图动画异常。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/** 数据加载完成后调用此方法进行重置归位*/
resolveHandler() {
if (this.curState.pullrelease) { //仅触摸松开时才触发
this.resetDefaultXYHandler();
}
}

// 重置下拉
resetDefaultXYHandler() {
this.curState = defaultState;
this.state.pullPan.setValue(this.defaultXY);
this.state.spinValue.stopAnimation(value => {
console.log('剩余时间' + (1 - value) * 3000);
//计算角度比例
this.animation = Animated.timing(this.state.spinValue, {
toValue: 1,
duration: (1 - value) * 3000,
easing: Easing.linear,
});
});
}

实现刷新视图中的循环转圈动画

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 转圈动画
spin () {
this.animation.start((result)=>{
//正常转完1周,继续转
if (Boolean(result.finished)) {
this.animation = Animated.timing(
this.state.spinValue,
{
toValue: 1,
duration: 3000,
easing: Easing.linear
}
);
this.state.spinValue.setValue(0);// 每次转完都要重置
this.spin();
}
});
}

我们仍然需要对scrollview的滑动事件做一个监听,这里我们需要过滤一个临界状态,当列表已经滑动到顶部,但还没有触发下拉刷新的状态,不允许继续滚动。滑动监听实现加载更多,先获取到垂直方向的滑动距离,列表的高度,内容的高度,当滑出去的距离加上列表的高度大于等于内容高度时触发加载更多,这里给个20的偏移量。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// scrollview 的滚动回调
onScroll(e) {
if (e.nativeEvent.contentOffset.y <= 0) { //临界状态,此时已经到列表顶部,但是还没触发下拉刷新状态
this.setState({scrollEnabled: this.defaultScrollEnabled})
} else if (!this.isPullState()) { //当前不是下拉状态允许滚动
this.setState({scrollEnabled: true})
}
let y = e.nativeEvent.contentOffset.y;
// console.log('滑动距离' + y);
let height = e.nativeEvent.layoutMeasurement.height;
// console.log('列表高度' + height);
let contentHeight = e.nativeEvent.contentSize.height;
// console.log('内容高度' + contentHeight);
// console.log('判断条件' + (y + height));
if (y + height >= contentHeight - 20) {
console.log('触发加载更多');
this.props.onLoadMore && this.props.onLoadMore()
}
// 调用外部的滑动回调
this.props.onScroll && this.props.onScroll(e)
}

处于下拉过程判断

1
2
3
4
//处于下拉过程判断
isPullState() {
return this.curState.pulling || this.curState.pullok || this.curState.pullrelease;
}

设置当前处于的下拉状态

1
2
3
4
5
6
7
// 设置当前的下拉状态
setPullState(pullState) {
if (this.curState !== pullState) {
this.curState = pullState;
this.renderTopRefresh();
}
}

根据当前的下拉状态,完成下拉刷新部分的重绘:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
// 绘制下拉刷新
renderTopRefresh() {
let { pulling, pullok, pullrelease } = this.curState;
return this.defaultTopRefreshRender(pulling, pullok, pullrelease);
}


/**
使用setNativeProps解决卡顿问题
绘制默认的下拉刷新
*/
defaultTopRefreshRender(pulling, pullok, pullrelease) {
//控制下拉刷新提示文字显示状态
setTimeout(() => {
if (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 (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 (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});
}
}, 1);
// 初始化旋转角度
const spin = this.state.spinValue.interpolate({
inputRange: [0, 1],
outputRange: ['0deg', '1080deg']
});
// 绘制下拉刷新view
return (
<View style={{flexDirection: 'row', justifyContent: 'center', alignItems: 'center', height: defaultTopRefreshHeight}}>
<Animated.Image
style={{
width: 23,
height: 23,
transform: [{rotate: spin}] ,
position:'absolute',
left:24
}}
source={{uri: 'default_ptr_rotate'}}
/>

<Text ref={(c) => {this.txtPulling = c;}} style={styles.hide}>{tipText.pulling}</Text>
<Text ref={(c) => {this.txtPullok = c;}} style={styles.hide}>{tipText.pullok}</Text>
<Text ref={(c) => {this.txtPullrelease = c;}} style={styles.hide}>{tipText.pullrelease}</Text>
</View>
);
}

测量宽高,动态设置自定义view的宽高,绘制整个自定义view以及该组件内部包含的子view

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// 获得测量的宽高,动态设置
onLayout(e) {
if (this.state.width !== e.nativeEvent.layout.width || this.state.height !== e.nativeEvent.layout.height) {
this.scrollContainer.setNativeProps({style: {width: e.nativeEvent.layout.width, height: e.nativeEvent.layout.height}});
this.width = e.nativeEvent.layout.width;
this.height = e.nativeEvent.layout.height;
}
}



render() {
return (
<View style={[Refresh.styles.wrap, this.props.style]} onLayout={this.onLayout}>
<Animated.View style={[this.state.pullPan.getLayout()]}>
{/*绘制下拉刷新view*/}
{this.renderTopRefresh()}
{/*绘制里面的子view*/}
<View ref={(c) => {
this.scrollContainer = c
}} {...this.panResponder.panHandlers} style={{width: this.state.width, height: this.state.height}}>
<ScrollView {...this.props} ref={(c) => {
this.scroll = c
}} scrollEnabled={this.state.scrollEnabled} onScroll={this.onScroll}>
{this.props.children}
{/*绘制加载view*/}
<LoadMore state={this.props.loadMoreState} onRetry={() => this.props.onRetry()}/>
</ScrollView>
</View>
</Animated.View>
</View>
)
}

设置默认提示文本和自带样式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 默认提示文本
const tipText = {
pulling: "下拉刷新...",
pullok: "松开刷新......",
pullrelease: "刷新中......"
};

// 自带样式
const styles = StyleSheet.create({
wrap: {
flex: 1,
flexGrow: 1,
flexDirection: 'column',
zIndex:-999,
},
hide: {
position: 'absolute',
left: 10000
},
show: {
position: 'relative',
left: 0
}
});

在业务代码中使用

1
2
3
4
<PullScrollView style={{flex: 1, backgroundColor: 'white'}}
onPullRelease={this.onPullRelease}>
...
</PullScollView>

这里注意在onPullRelease中一定要调用resolve()让下拉刷新重置

1
2
3
4
5
6
onPullRelease(resolve) {
//刷新完毕,重置下拉刷新,再次更新刷新和加载更多状态
setTimeout(() => {
resolve();
}, 3000);
}

我们需要在触发加载更多,发起网络请求时显示一个加载更多的loadingview,通过loading参数传值控制当前是否显示,同样简单封装一下,这里加了一个逐个展示小圆点的组合动画

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
const pointsNum = 3; //点数量

class LoadMore extends Component {
constructor(props) {
super(props)
// 初始化点的数量
this.arr = []
for (let i = 0; i < pointsNum; i++) {
this.arr.push(i)
}
// 初始化动画
this.animatedValue = []
this.arr.forEach((value) => {
this.animatedValue[value] = new Animated.Value(0)
})
this.state = {
curState: this.props.state
}

}

componentDidMount(){
this.isMount = true
}

componentWillUnmount(){
this.isMount = false
}
/**
* 动画执行,逐个显示
*/
animate() {
this.arr.forEach((value) => {
this.animatedValue[value].setValue(0);
})
const animations = this.arr.map((item) => {
return Animated.timing(
this.animatedValue[item],
{
toValue: 1,
duration: 200,
easing: Easing.linear
}
)
})
this.loadingAnim = Animated.sequence(animations)
this.loadingAnim.start((result) => {
if (Boolean(result.finished)) this.animate()
})
}

render() {
return (
<View style={[styles.container]}>
{this.state.curState !== LoadMoreState.state.hide ? this.renderLoad() : null}
</View>
)
}


componentWillReceiveProps(nextProps) {
this.setState({
curState: nextProps.state
})
switch (nextProps.state) {
case LoadMoreState.state.loading:
if (this.props.loadAnim){
this.animate()
}
break
case LoadMoreState.state.hide:
this.arr.forEach((item) => {
this.animatedValue[item].stopAnimation(value => {
// console.log('剩余时间' + (1 - value) * 200);
});
})
break
case LoadMoreState.state.noMore:
setTimeout(() => {
if(this.isMount){
this.setState({
curState: LoadMoreState.state.hide
})
}
}, 3000)
break

}
}

/**
* 根据状态返回绘制文字
* @returns {string}
*/
renderLoadText() {
switch (this.state.curState) {
case LoadMoreState.state.loading:
if (this.props.loadAnim){
return LoadMoreState.stateText.loading
}else{
return LoadMoreState.stateText.loading + '...'
}

case LoadMoreState.state.noMore:
return LoadMoreState.stateText.noMore
case LoadMoreState.state.tip:
return LoadMoreState.stateText.tip
case LoadMoreState.state.error:
return LoadMoreState.stateText.error
}
}

// 绘制文字
renderLoad() {
const animations = this.arr.map((value, index) => {
return (
<Animated.Text key={index} style={{
opacity: this.animatedValue[value], fontSize: Config.screenW * 0.06, color: Config.normalTextColor
}}>.</Animated.Text>
)
})
return (
<TouchableOpacity
style={{flexDirection: 'row', alignItems: 'center', height: Config.screenW * 0.14,}}
onPress={() => new DoubleClick().filterDoubleClick(
function () {
if (this.state.curState === LoadMoreState.state.error){
this.props.onRetry && this.props.onRetry()
this.setState({
curState:LoadMoreState.state.loading
})
this.animate()
}
}.bind(this)
)}>
<Text style={{
color: Config.normalTextColor,
fontSize: Config.screenW * 0.04,
marginRight: Config.screenW * 0.02,
}}>
{this.renderLoadText()}
</Text>
{/*是加载状态绘制。。。的动画*/}
{this.state.curState === LoadMoreState.state.loading ?
<View style={styles.pointsView}>
{animations}
</View> : null}

</TouchableOpacity>
)
}
}

LoadMore.propTypes = {
state: PropTypes.number, //当前状态
onRetry: PropTypes.func ,//重试回调
loadAnim: PropTypes.bool, //是否显示加载动画,flatlist尾部组件不支持
}
LoadMore.defaultProps = {
state: LoadMoreState.state.hide, //默认不显示
loadAnim: false //默认不用动画
}

const styles = StyleSheet.create({
container: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
pointsView: {
height: Config.screenW * 0.14,
flexDirection: 'row',
alignItems: 'center',
},
instructions: {
textAlign: 'center',
color: '#333333',
marginBottom: 5,
},
})

export default LoadMore;

对flatlist封装

封装flatlist实现下拉刷新和加载更多

与scrollview的封装方式大致相同,render方法需要稍做修改

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
render() {
return (
<View style={[Refresh.styles.wrap, this.props.style]} onLayout={this.onLayout}>
<Animated.View style={[this.state.pullPan.getLayout()]}>
{/*绘制下拉刷新view*/}
{this.renderTopRefresh()}
{/*绘制里面的子view*/}
<View ref={(c) => {
this.scrollContainer = c
}} {...this.panResponder.panHandlers} style={{width: this.state.width, height: this.state.height}}>
<FlatList
ref={(c) => {
this.list = c
}}
extraData={this.state}
{...this.props}
onScroll={this.onScroll}
scrollEnabled={this.state.scrollEnabled}
ListFooterComponent={() => {
return <LoadMore state={this.props.loadMoreState} onRetry={() => this.props.onRetry()}/>
}}
/>

</View>
</Animated.View>
</View>
)
}

在业务代码中使用

1
2
3
4
5
6
7
8
9
10
11
12
<PullFlatList
data={this.state.data.data}
showsVerticalScrollIndicator={false}
ItemSeparatorComponent={this.space}
renderItem={({item, index}) => (
<ComicImg imgUrl={item} index={index}/>
)}
keyExtractor={item => item}
numColumns={1}
onPullRelease={this.props.onRefresh}
style={[CommonStyle.styles.listView,{width: Config.screenW,height:Config.screenH}]}
/>

对应的源码地址:

组件封装
组件使用