原文鏈接React Native實(shí)現(xiàn)一個(gè)帶篩選功能的搜房列表(1)
最近在寫(xiě)RN項(xiàng)目中需要實(shí)現(xiàn)一個(gè)帶篩選功能的搜房列表,寫(xiě)完這個(gè)功能后發(fā)現(xiàn)有一些新的心得,在這里寫(xiě)下來(lái)跟大家分享一下。
文章中的代碼都來(lái)自代碼傳送門(mén)--NNHybrid。主要集中在SearchHousePage.js、searchHouse.js和FHTFilterMenuManager.m。我會(huì)通過(guò)列表下拉刷新和上拉加載更多的實(shí)現(xiàn)、使用Redux以及RN與原生iOS通信這三方面向大家分享這個(gè)頁(yè)面的開(kāi)發(fā)過(guò)程。
首先我們來(lái)看一下列表是如何實(shí)現(xiàn)的。
如何實(shí)現(xiàn)下拉刷新和上拉加載更多
在移動(dòng)端的開(kāi)發(fā)過(guò)程中,寫(xiě)一個(gè)帶下拉刷新和上拉加載更多的列表可以說(shuō)是一個(gè)常態(tài)。在React Native中我們一般使用FlatList或SectionList組件實(shí)現(xiàn),這里我使用FlatList來(lái)實(shí)現(xiàn)這個(gè)列表。
我們知道FlatList默認(rèn)是有下拉刷新功能的,但是自定義效果比較差,而且效果也不如iOS中MJRefresh的效果好,另外FlatList沒(méi)有加載更多的功能,所以需要我們自己去實(shí)現(xiàn)下拉刷新和上拉加載更多。在下拉刷新的時(shí)候如果出現(xiàn)空數(shù)據(jù)或者報(bào)錯(cuò),我們可能需要分別實(shí)現(xiàn)對(duì)應(yīng)的占位視圖。
基于上述要求,我們可以通過(guò)改變state中的headerRefreshState的值對(duì)頭部刷新控件樣式進(jìn)行更改,而通過(guò)props中的footerRefreshState的值對(duì)底部刷新控件樣式進(jìn)行更改。
根據(jù)上面所述,我們可以用下面這張圖來(lái)描述列表在不同刷新?tīng)顟B(tài)時(shí)候?qū)?yīng)的樣式。
主要代碼
RefreshConst
// 默認(rèn)刷新控件高度
export const defaultHeight = 60;
// 下拉刷新?tīng)顟B(tài)
export const HeaderRefreshState = {
Idle: 'Idle', //無(wú)刷新的情況
Pulling: 'Pulling', //松開(kāi)刷新
Refreshing: 'Refreshing', //正在刷新
}
// 加載更多狀態(tài)
export const FooterRefreshState = {
Idle: 'Idle', //無(wú)刷新的情況
Refreshing: 'Refreshing', //正在刷新
NoMoreData: 'NoMoreData', //沒(méi)有更多數(shù)據(jù)
EmptyData: 'EmptyData', //空數(shù)據(jù)
Failure: 'Failure', //錯(cuò)誤提示
}
// 下拉刷新默認(rèn)props
export const defaultHeaderProps = {
headerIsRefreshing: false,
headerHeight: defaultHeight,
headerIdleText: '下拉可以刷新',
headerPullingText: '松開(kāi)立即刷新',
headerRefreshingText: '正在刷新數(shù)據(jù)中...',
}
// 加載更多默認(rèn)props
export const defaultFooterProps = {
footerRefreshState: FooterRefreshState.Idle,
footerHeight: defaultHeight,
footerRefreshingText: '更多數(shù)據(jù)加載中...',
footerFailureText: '點(diǎn)擊重新加載',
footerNoMoreDataText: '已加載全部數(shù)據(jù)',
footerEmptyDataText: '暫時(shí)沒(méi)有相關(guān)數(shù)據(jù)',
}
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>
);
}
// 默認(rèn)加載更多組件
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相關(guān)屬性
headerIsRefreshing: PropTypes.bool,
headerHeight: PropTypes.number,
onHeaderRefresh: PropTypes.func,
headerIdleText: PropTypes.string,
headerPullingText: PropTypes.string,
headerRefreshingText: PropTypes.string,
headerRefreshComponent: PropTypes.func,
// Footer相關(guān)屬性
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('調(diào)用一下'+ 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}
/>
);
}
/**
* 列表正在滾動(dòng)
* @private
* @param {{}} event
*/
_onScroll(event) {
const offsetY = event.nativeEvent.contentOffset.y;
if (this.isDragging) {
if (!this._isRefreshing()) {
if (offsetY <= -this.headerHeight) {
// 松開(kāi)以刷新
this.setState({ headerRefreshState: HeaderRefreshState.Pulling });
this.state.arrowAnimation.setValue(1);
} else {
// 下拉以刷新
this.setState({ headerRefreshState: HeaderRefreshState.Idle });
this.state.arrowAnimation.setValue(0);
}
}
}
}
/**
* 列表開(kāi)始拖拽
* @private
* @param {{}} event
*/
_onScrollBeginDrag(event) {
this.isDragging = true;
}
/**
* 列表結(jié)束拖拽
* @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
);
}
/**
* 觸發(fā)加載更多
*/
_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用來(lái)實(shí)現(xiàn)占位圖
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>
);
}
}
最終實(shí)現(xiàn)
在SearchHousePage.js中實(shí)現(xiàn)列表,主要代碼如下:
footerRefreshComponent(footerRefreshState, data) {
switch (footerRefreshState) {
// 自定義footerFailureComponent,當(dāng)有數(shù)據(jù)的時(shí)候返回null,這樣列表就會(huì)使用默認(rèn)的footerFailureComponent,否則顯示錯(cuò)誤占位圖
case FooterRefreshState.Failure: {
return AppUtil.isEmptyArray(data) ? (
<PlaceholderView
height={AppUtil.windowHeight - AppUtil.fullNavigationBarHeight - 44}
imageSource={require('../../resource/images/placeHolder/placeholder_error.png')}
tipText='出了點(diǎn)小問(wèn)題'
needReload={true}
reloadHandler={() => this._loadData(true)}
/>
) : null;
}
// 空數(shù)據(jù)占位圖的實(shí)現(xiàn)
case FooterRefreshState.EmptyData: {
return (
<PlaceholderView
height={AppUtil.windowHeight - AppUtil.fullNavigationBarHeight - 44}
imageSource={require('../../resource/images/placeHolder/placeholder_house.png')}
tipText='真的沒(méi)了'
infoText='更換篩選條件試試吧'
/>
);
}
default:
return null;
}
}
// 列表的實(shí)現(xiàn)
<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)}
/>
各狀態(tài)對(duì)應(yīng)的效果圖
NoMoreData
列表無(wú)數(shù)據(jù)時(shí)的Failure
列表有數(shù)據(jù)時(shí)的Failure
EmptyData
綜上
到這里,我們已經(jīng)完成了一個(gè)帶下拉刷新和上拉加載更多的列表,并且實(shí)現(xiàn)了空數(shù)據(jù)占位。接著就是介紹數(shù)據(jù)的加載,在React Native實(shí)現(xiàn)一個(gè)帶篩選功能的搜房列表(2)中我會(huì)介紹如何使用redux進(jìn)行數(shù)據(jù)的加載。另外上面提供的代碼均是從項(xiàng)目當(dāng)中截取的,如果需要查看完整代碼的話,在代碼傳送門(mén)--NNHybrid中。
相關(guān)代碼路徑:
RefreshFlatList: /NNHybridRN/components/refresh/RefreshFlatList.js
RefreshConst: /NNHybridRN/components/refresh/RefreshConst.js
PlaceholderView: /NNHybridRN/components/common/PlaceholderView.js
SearchHousePage: /NNHybridRN/sections/searchHouse/SearchHousePage.js