React Native實現一個帶篩選功能的搜房列表(1)

原文連接React Native實現一個帶篩選功能的搜房列表(1)react

最近在寫RN項目中須要實現一個帶篩選功能的搜房列表,寫完這個功能後發現有一些新的心得,在這裏寫下來跟你們分享一下。git

開始以前,咱們先看一下最終實現的效果 github

search_house

文章中的代碼都來自代碼傳送門--NNHybrid。主要集中在SearchHousePage.jssearchHouse.jsFHTFilterMenuManager.m。我會經過列表下拉刷新和上拉加載更多的實現使用Redux以及RN與原生iOS通訊這三方面向你們分享這個頁面的開發過程。redux

首先咱們來看一下列表是如何實現的。react-native

如何實現下拉刷新和上拉加載更多

在移動端的開發過程當中,寫一個帶下拉刷新和上拉加載更多的列表能夠說是一個常態。在React Native中咱們通常使用FlatList或SectionList組件實現,這裏我使用FlatList來實現這個列表。bash

咱們知道FlatList默認是有下拉刷新功能的,可是自定義效果比較差,並且效果也不如iOS中MJRefresh的效果好,另外FlatList沒有加載更多的功能,因此須要咱們本身去實現下拉刷新和上拉加載更多。在下拉刷新的時候若是出現空數據或者報錯,咱們可能須要分別實現對應的佔位視圖。app

基於上述要求,咱們能夠經過改變state中的headerRefreshState的值對頭部刷新控件樣式進行更改,而經過props中的footerRefreshState的值對底部刷新控件樣式進行更改。flex

根據上面所述,咱們能夠用下面這張圖來描述列表在不一樣刷新狀態時候對應的樣式。 ui

RefreshState

主要代碼

RefreshConst

// 默認刷新控件高度
export const defaultHeight = 60;

// 下拉刷新狀態
export const HeaderRefreshState = {
    Idle: 'Idle', //無刷新的狀況
    Pulling: 'Pulling', //鬆開刷新
    Refreshing: 'Refreshing', //正在刷新
}

// 加載更多狀態
export const FooterRefreshState = {
    Idle: 'Idle', //無刷新的狀況
    Refreshing: 'Refreshing', //正在刷新
    NoMoreData: 'NoMoreData', //沒有更多數據
    EmptyData: 'EmptyData', //空數據
    Failure: 'Failure', //錯誤提示
}

// 下拉刷新默認props
export const defaultHeaderProps = {
    headerIsRefreshing: false,
    headerHeight: defaultHeight,
    headerIdleText: '下拉能夠刷新',
    headerPullingText: '鬆開當即刷新',
    headerRefreshingText: '正在刷新數據中...',
}

// 加載更多默認props
export const defaultFooterProps = {
    footerRefreshState: FooterRefreshState.Idle,
    footerHeight: defaultHeight,
    footerRefreshingText: '更多數據加載中...',
    footerFailureText: '點擊從新加載',
    footerNoMoreDataText: '已加載所有數據',
    footerEmptyDataText: '暫時沒有相關數據',
}
複製代碼

RefreshFlatList

import React, { Component } from 'react';
import {
    StyleSheet,
    View,
    Text,
    Image,
    FlatList,
    ActivityIndicator,
    Animated,
} from 'react-native';
import { PropTypes } from 'prop-types';
import AppUtil from '../../utils/AppUtil';

import {
    HeaderRefreshState,
    FooterRefreshState,
    defaultHeaderProps,
    defaultFooterProps,
} from './RefreshConst';

/**
 * 頭部刷新組件的箭頭或菊花
 */
const headerArrowOrActivity = (headerRefreshState, arrowAnimation) => {
    if (headerRefreshState == HeaderRefreshState.Refreshing) {
        return (
            <ActivityIndicator
                style={{ marginRight: 10 }}
                size="small"
                color={AppUtil.app_theme}
            />
        );
    } else {
        return (
            <Animated.Image
                source={require('../../resource/images/arrow/refresh_arrow.png')}
                style={{
                    width: 20,
                    height: 20,
                    marginRight: 10,
                    transform: [{
                        rotateZ: arrowAnimation.interpolate({
                            inputRange: [0, 1],
                            outputRange: ['0deg', '-180deg']
                        })
                    }]
                }}
            />
        );
    }
}

/**
 * 頭部刷新組件的Text組件
 */
const headerTitleComponent = (headerRefreshState, props) => {
    const { headerIdleText, headerPullingText, headerRefreshingText } = props;

    let headerTitle = '';

    switch (headerRefreshState) {
        case HeaderRefreshState.Idle:
            headerTitle = headerIdleText;
            break;
        case HeaderRefreshState.Pulling:
            headerTitle = headerPullingText;
            break;
        case HeaderRefreshState.Refreshing:
            headerTitle = headerRefreshingText;
            break;
        default:
            break;
    }

    return (
        <Text style={{ fontSize: 13, color: AppUtil.app_theme }}>
            {headerTitle}
        </Text>
    );
}

// 默認加載更多組件
export const defaultFooterRefreshComponent = ({
    footerRefreshState,
    footerRefreshingText,
    footerFailureText,
    footerNoMoreDataText,
    footerEmptyDataText,
    onHeaderRefresh,
    onFooterRefresh,
    data }) => {
    switch (footerRefreshState) {
        case FooterRefreshState.Idle:
            return (
                <View style={styles.footerContainer} />
            );
        case FooterRefreshState.Refreshing:
            return (
                <View style={styles.footerContainer} >
                    <ActivityIndicator size="small" color={AppUtil.app_theme} />
                    <Text style={[styles.footerText, { marginLeft: 7 }]}>
                        {footerRefreshingText}
                    </Text>
                </View>
            );
        case FooterRefreshState.Failure:
            return (
                <TouchableOpacity onPress={() => {
                    if (AppUtil.isEmptyArray(data)) {
                        onHeaderRefresh && onHeaderRefresh();
                    } else {
                        onFooterRefresh && onFooterRefresh();
                    } Î
                }}>
                    <View style={styles.footerContainer}>
                        <Text style={styles.footerText}>{footerFailureText}</Text>
                    </View>
                </TouchableOpacity>
            );
        case FooterRefreshState.EmptyData:
            return (
                <TouchableOpacity onPress={() => { onHeaderRefresh && onHeaderRefresh(); }}>
                    <View style={styles.footerContainer}>
                        <Text style={styles.footerText}>{footerEmptyDataText}</Text>
                    </View>
                </TouchableOpacity>
            );
        case FooterRefreshState.NoMoreData:
            return (
                <View style={styles.footerContainer} >
                    <Text style={styles.footerText}>{footerNoMoreDataText}</Text>
                </View>
            );
    }

    return null;
}

export default class RefreshFlatList extends Component {

    static propTypes = {
        listRef: PropTypes.any,
        data: PropTypes.array,
        renderItem: PropTypes.func,

        // Header相關屬性
        headerIsRefreshing: PropTypes.bool,

        headerHeight: PropTypes.number,

        onHeaderRefresh: PropTypes.func,

        headerIdleText: PropTypes.string,
        headerPullingText: PropTypes.string,
        headerRefreshingText: PropTypes.string,

        headerRefreshComponent: PropTypes.func,

        // Footer相關屬性
        footerRefreshState: PropTypes.string,

        onFooterRefresh: PropTypes.func,

        footerHeight: PropTypes.number,

        footerRefreshingText: PropTypes.string,
        footerFailureText: PropTypes.string,
        footerNoMoreDataText: PropTypes.string,
        footerEmptyDataText: PropTypes.string,

        footerRefreshComponent: PropTypes.func,
    };

    static defaultProps = {
        listRef: 'flatList',
        ...defaultHeaderProps,
        ...defaultFooterProps,
    }

    constructor(props) {
        super(props);

        const { headerHeight, footerHeight } = this.props;

        this.isDragging = false;
        this.headerHeight = headerHeight;
        this.footerHeight = footerHeight;

        this.state = {
            arrowAnimation: new Animated.Value(0),
            headerRefreshState: HeaderRefreshState.Idle,
        };

    }

    componentWillReceiveProps(nextProps) {
        const { headerIsRefreshing, listRef } = nextProps;


        if (headerIsRefreshing !== this.props.headerIsRefreshing) {
            // console.log('調用一下'+ headerIsRefreshing + this.props.headerIsRefreshing);
            const offset = headerIsRefreshing ? -this.headerHeight : 0;
            const headerRefreshState = headerIsRefreshing ? HeaderRefreshState.Refreshing : HeaderRefreshState.Idle;

            if (!headerIsRefreshing) this.state.arrowAnimation.setValue(0);

            this.refs[listRef].scrollToOffset({ animated: true, offset });
            this.setState({ headerRefreshState });
        }
    }

    /**
     * 加載下拉刷新組件
     */
    _renderHeader = () => {
        const { headerRefreshComponent } = this.props;
        const { arrowAnimation, headerRefreshState } = this.state;

        if (headerRefreshComponent) {
            return (
                <View style={{ marginTop: -this.headerHeight, height: this.headerHeight }}>
                    {headerRefreshComponent(headerRefreshState)}
                </View>
            );
        } else {
            return (
                <View style={{
                    alignItems: 'center',
                    justifyContent: 'center',
                    flexDirection: 'row',
                    marginTop: -this.headerHeight,
                    height: this.headerHeight
                }} >
                    {headerArrowOrActivity(headerRefreshState, arrowAnimation)}
                    {headerTitleComponent(headerRefreshState, this.props)}
                </View >
            );
        }
    }

    /**
     * 加載更多組件
     */
    _renderFooter = () => {
        const {
            footerRefreshState,
            footerRefreshComponent,
        } = this.props;

        if (footerRefreshComponent) {
            const component = footerRefreshComponent(footerRefreshState);
            if (component) return component;
        }

        return defaultFooterRefreshComponent({ ...this.props });
    }

    render() {
        return (
            <FlatList
                {...this.props}
                ref={this.props.listRef}
                onScroll={event => this._onScroll(event)}
                onScrollEndDrag={event => this._onScrollEndDrag(event)}
                onScrollBeginDrag={event => this._onScrollBeginDrag(event)}
                onEndReached={this._onEndReached}
                ListHeaderComponent={this._renderHeader}
                ListFooterComponent={this._renderFooter}
                onEndReachedThreshold={0.1}
            />
        );
    }

    /**
     * 列表正在滾動
     * @private
     * @param {{}} event 
     */
    _onScroll(event) {
        const offsetY = event.nativeEvent.contentOffset.y;
        if (this.isDragging) {
            if (!this._isRefreshing()) {
                if (offsetY <= -this.headerHeight) {
                    // 鬆開以刷新
                    this.setState({ headerRefreshState: HeaderRefreshState.Pulling });
                    this.state.arrowAnimation.setValue(1);
                } else {
                    // 下拉以刷新
                    this.setState({ headerRefreshState: HeaderRefreshState.Idle });
                    this.state.arrowAnimation.setValue(0);
                }
            }
        }
    }

    /**
     * 列表開始拖拽
     * @private
     * @param {{}} event
     */
    _onScrollBeginDrag(event) {
        this.isDragging = true;
    }

    /**
     * 列表結束拖拽
     * @private
     * @param {{}} event
     */
    _onScrollEndDrag(event) {
        this.isDragging = false;
        const offsetY = event.nativeEvent.contentOffset.y;
        const { listRef, onHeaderRefresh } = this.props;

        if (!this._isRefreshing()) {
            if (this.state.headerRefreshState === HeaderRefreshState.Pulling) {
                this.refs[listRef].scrollToOffset({ animated: true, offset: -this.headerHeight });
                this.setState({ headerRefreshState: HeaderRefreshState.Refreshing });
                onHeaderRefresh && onHeaderRefresh();
            }
        } else {
            if (offsetY <= 0) {
                this.refs[listRef].scrollToOffset({ animated: true, offset: -this.headerHeight });
            }
        }
    }

    /**
     * 列表是否正在刷新
     */
    _isRefreshing = () => {
        return (
            this.state.headerRefreshState === HeaderRefreshState.Refreshing &&
            this.props.footerRefreshState === FooterRefreshState.Refreshing
        );
    }

    /**
     * 觸發加載更多
     */
    _onEndReached = () => {
        const { onFooterRefresh, data } = this.props;

        if (!this._isRefreshing() &&
            !AppUtil.isEmptyArray(data) &&
            this.props.footerRefreshState !== FooterRefreshState.NoMoreData) {
            onFooterRefresh && onFooterRefresh();
        }
    }
}

const styles = StyleSheet.create({
    headerContainer: {
        position: 'absolute',
        left: 0,
        right: 0,
    },
    customHeader: {
        position: 'absolute',
        left: 0,
        right: 0,
    },
    defaultHeader: {
        position: 'absolute',
        alignItems: 'center',
        justifyContent: 'center',
        flexDirection: 'row',
        left: 0,
        right: 0,
    },
    footerContainer: {
        flex: 1,
        flexDirection: 'row',
        justifyContent: 'center',
        alignItems: 'center',
        padding: 10,
        height: 60,
    },
    footerText: {
        fontSize: 14,
        color: AppUtil.app_theme
    }
});
複製代碼

PlaceholderView

PlaceholderView.js用來實現佔位圖this

export default class PlaceholderView extends Component {

    static propTypes = {
        height: PropTypes.number,
        imageSource: PropTypes.any,
        tipText: PropTypes.string,
        infoText: PropTypes.string,
        spacing: PropTypes.number,
        needReload: PropTypes.bool,
        reloadHandler: PropTypes.func
    }

    static defaultProps = {
        height: AppUtil.windowHeight,
        hasError: false,
        tipText: '',
        infoText: '',
        spacing: 10,
        needReload: false,
        reloadHandler: null
    }

    renderImage = imageSource => {
        return imageSource ? (
            <NNImage style={styles.image} enableAdaptation={true} source={imageSource} />
        ) : null;
    }

    renderTipText = tipText => {
        return !AppUtil.isEmptyString(tipText) ? (
            <Text style={styles.tipText}>{tipText}</Text>
        ) : null;
    }

    renderInfoText = infoText => {
        return !AppUtil.isEmptyString(infoText) ? (
            <Text style={styles.infoText}>{infoText}</Text>
        ) : null;
    }

    renderReloadButton = (needReload, reloadHandler) => {
        return needReload ? (
            <TouchableOpacity onPress={() => {
                if (reloadHandler) {
                    reloadHandler();
                }
            }}>
                <View style={styles.reloadButton}>
                    <Text style={styles.reloadButtonText}>從新加載</Text>
                </View>
            </TouchableOpacity>
        ) : null;
    }

    render() {
        const {
            height,
            imageSource,
            tipText,
            infoText,
            needReload,
            reloadHandler,
        } = this.props;

        return (
            <View style={{ ...styles.container, height }}>
                {this.renderImage(imageSource)}
                {this.renderTipText(tipText)}
                {this.renderInfoText(infoText)}
                {this.renderReloadButton(needReload, reloadHandler)}
            </View>
        );
    }
}
複製代碼

最終實現

SearchHousePage.js中實現列表,主要代碼以下:

footerRefreshComponent(footerRefreshState, data) {
    switch (footerRefreshState) {
        // 自定義footerFailureComponent,當有數據的時候返回null,這樣列表就會使用默認的footerFailureComponent,不然顯示錯誤佔位圖
        case FooterRefreshState.Failure: {
            return AppUtil.isEmptyArray(data) ? (
                <PlaceholderView
                    height={AppUtil.windowHeight - AppUtil.fullNavigationBarHeight - 44}
                    imageSource={require('../../resource/images/placeHolder/placeholder_error.png')}
                    tipText='出了點小問題'
                    needReload={true}
                    reloadHandler={() => this._loadData(true)}
                />
            ) : null;
        }
        // 空數據佔位圖的實現
        case FooterRefreshState.EmptyData: {
            return (
                <PlaceholderView
                    height={AppUtil.windowHeight - AppUtil.fullNavigationBarHeight - 44}
                    imageSource={require('../../resource/images/placeHolder/placeholder_house.png')}
                    tipText='真的沒了'
                    infoText='更換篩選條件試試吧'
                />
            );
        }
        default:
            return null;
    }
}

// 列表的實現
<RefreshFlatList
    ref='flatList'
    style={{ marginTop: AppUtil.fullNavigationBarHeight + 44 }}
    showsHorizontalScrollIndicator={false}
    data={searchHouse.houseList}
    keyExtractor={item => `${item.id}`}
    renderItem={({ item, index }) => this._renderHouseCell(item, index)}
    headerIsRefreshing={searchHouse.headerIsRefreshing}
    footerRefreshState={searchHouse.footerRefreshState}
    onHeaderRefresh={() => this._loadData(true)}
    onFooterRefresh={() => this._loadData(false)}
    footerRefreshComponent={footerRefreshState => this.footerRefreshComponent(footerRefreshState, searchHouse.houseList)}
/>
複製代碼

各狀態對應的效果圖

NoMoreData

RefreshStateNoMoreData

列表無數據時的Failure

RefreshFailurePlaceholder

列表有數據時的Failure

RefreshStateFailure

EmptyData

RefreshEmptyPlaceholder

綜上

到這裏,咱們已經完成了一個帶下拉刷新和上拉加載更多的列表,而且實現了空數據佔位。接着就是介紹數據的加載,在React Native實現一個帶篩選功能的搜房列表(2)中我會介紹如何使用redux進行數據的加載。另外上面提供的代碼均是從項目當中截取的,若是須要查看完整代碼的話,在代碼傳送門--NNHybrid中。

相關代碼路徑:

RefreshFlatList: /NNHybridRN/components/refresh/RefreshFlatList.js

RefreshConst: /NNHybridRN/components/refresh/RefreshConst.js

PlaceholderView: /NNHybridRN/components/common/PlaceholderView.js

SearchHousePage: /NNHybridRN/sections/searchHouse/SearchHousePage.js
複製代碼
相關文章
相關標籤/搜索