React Native学习React NativeiOS备忘录

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

2017-06-22  本文已影响5284人  这真不是玩笑

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
}
上一篇下一篇

猜你喜欢

热点阅读