React Native支持任意组件实现下拉刷新功能,并且可以自定义下拉刷新头部

1.背景

无论是 Androi 还是 ios,下拉刷新都是一个很有必要也很重要的功能。那么在 RN(以下用 RN 表示 React Native )之中,我们该如何实现下拉刷新功能呢?RN 官方提供了一个用于 ScrollView , ListView 等带有滑动功能组件的下拉刷新组件 RefreshControl。查看 RefreshControl 相关源码可以发现,其实它是对原生下拉刷新组件的一个封装,好处是使用方便快捷。但缺点也很明显,就是它不可以进行自定义下拉刷新头部,并且只能使用与 ScrollView,ListView 这种带有滚动功能的组件之中。那么我们该如何去解决这两个问题呢?
先看下最终实现的效果,这里借助了 ScrollableTabView

ios.gif

android.gif

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。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 159,015评论 4 362
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,262评论 1 292
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,727评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,986评论 0 205
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,363评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,610评论 1 219
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,871评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,582评论 0 198
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,297评论 1 242
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,551评论 2 246
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,053评论 1 260
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,385评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,035评论 3 236
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,079评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,841评论 0 195
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,648评论 2 274
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,550评论 2 270

推荐阅读更多精彩内容