React Native实现一个带筛选功能的搜房列表(1)

原文链接React Native实现一个带筛选功能的搜房列表(1)

最近在写RN项目中需要实现一个带筛选功能的搜房列表,写完这个功能后发现有一些新的心得,在这里写下来跟大家分享一下。

文章中的代码都来自代码传送门--NNHybrid。主要集中在SearchHousePage.jssearchHouse.jsFHTFilterMenuManager.m。我会通过列表下拉刷新和上拉加载更多的实现使用Redux以及RN与原生iOS通信这三方面向大家分享这个页面的开发过程。

首先我们来看一下列表是如何实现的。

如何实现下拉刷新和上拉加载更多

在移动端的开发过程中,写一个带下拉刷新和上拉加载更多的列表可以说是一个常态。在React Native中我们一般使用FlatList或SectionList组件实现,这里我使用FlatList来实现这个列表。

我们知道FlatList默认是有下拉刷新功能的,但是自定义效果比较差,而且效果也不如iOS中MJRefresh的效果好,另外FlatList没有加载更多的功能,所以需要我们自己去实现下拉刷新和上拉加载更多。在下拉刷新的时候如果出现空数据或者报错,我们可能需要分别实现对应的占位视图。

基于上述要求,我们可以通过改变state中的headerRefreshState的值对头部刷新控件样式进行更改,而通过props中的footerRefreshState的值对底部刷新控件样式进行更改。

根据上面所述,我们可以用下面这张图来描述列表在不同刷新状态时候对应的样式。


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用来实现占位图

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