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。

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容