React Native 實現瀑布流列表頁,分組+組內橫向的列表頁.....
React Native 實現瀑布流列表頁,分組+組內橫向的列表頁…..
隨著React Native的更新,因為其跨平臺的優越性,越來越多的公司和專案採用其作為其快速開發和迭代的基礎語言.
但是其並不是任何控制元件其都已經涵蓋了,就拿我們常見的列表頁來說, 一般通用的縱向或者橫向列表我們可以使用RN裡面的FlatList,需要分組的時候我們可以使用SectionList,但是當我們又想要分組又想要組內橫向排列的時候我們會發現已有的控制元件就無法滿足我們了….
那麼是否就是黔驢技窮呢?並不是,萬能的開發者不會因為這些小的需求而難住,於是我們可以通過SectionList+FlatList的組合來實現.
於是有了大的列表首先SectionList來進行分組,每組只有一個組內元素,這個組內元素是一個FlatList,然後在想普通的渲染FlatList的方式即可.
import React, { Component } from 'react';
import {
Dimensions,
SafeAreaView,
SectionList,
FlatList,
View,
Text,
TouchableOpacity,
StyleSheet,
Image
} from 'react-native';
const { width, height } = Dimensions.get('window');
const numColumns = 5;
export default class Me extends Component {
render() {
const data = [{
content: [
{key: 'mine_icon_hot', title: '排行榜'},
{key: 'mine_icon_preview', title: '審帖'},
{key: 'mine_icon_manhua', title: '漫畫'},
{key: 'mine_icon_activity', title: '我的收藏'},
{key: 'mine_icon_nearby', title: '附近'},
{key: 'mine_icon_random', title: '隨機穿越'},
{key: 'mine_icon_feedback', title: '意見反饋'},
{key: 'mine_icon_more', title: '更多'},
],
key: 'content',
}];
return (
<SafeAreaView style={styles.container}>
<SectionList
sections={[{data}]}
renderItem={this._renderSectionItem}
ListHeaderComponent={this._ListHeaderComponent}
ListFooterComponent={this._ListFooterComponent}
keyExtractor ={this._keyExtractor}
/>
</SafeAreaView>
)
}
_keyExtractor = (item, index) => {
return item.key;
}
_ListHeaderComponent = () => {
return (
<TouchableOpacity
activeOpacity={0.7}
style={styles.header}
>
<View style={styles.headerUser}>
<Image
source={{uri: 'default_header'}}
style={{width: 50, height: 50}}
/>
<Text style={{marginHorizontal: 10}}>百思不得姐</Text>
<Image
source={{uri: 'profile_level1'}}
style={{width: 36, height: 15}}
/>
</View>
<Image
source={{uri: 'arrow_right'}}
style={{width: 7, height: 12}}
/>
</TouchableOpacity>
)
}
_renderItem = ({item}) => {
return (
<TouchableOpacity
activeOpacity={0.7}
style={styles.item}
>
<Image
source={{uri: item.key}}
style={styles.itemImage}
/>
<Text style={styles.itemText}>{item.title}</Text>
</TouchableOpacity>
)
}
_renderSectionItem = ({section}) => {
return (
<FlatList
data={section.data[0].content}
numColumns={numColumns}
renderItem={this._renderItem}
style={{backgroundColor: '#fff'}}
scrollEnabled={false}
/>
)
}
_ListFooterComponent = () => {
return (
<TouchableOpacity
activeOpacity={0.7}
style={styles.footer}
>
<Text>好友動態</Text>
<Image
source={{uri:'arrow_right'}}
style={{width: 7, height: 12}}
/>
</TouchableOpacity>
)
}
};
const styles = StyleSheet.create({
container: {
flex: 1,
},
header: {
height: 100,
backgroundColor: '#fff',
marginBottom: 10,
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 15,
},
headerUser: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
},
footer: {
height: 50,
backgroundColor: '#fff',
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 15,
marginTop: 10,
},
item: {
backgroundColor: '#fff',
width: width/numColumns,
height: 80,
alignItems: 'center',
justifyContent: 'center',
},
itemImage: {
width: 40,
height: 40,
marginBottom: 5,
},
itemText: {
fontSize: 12,
}
})
這裡我們可以看到中間的一組就是橫向排列的,雖然我們不一定要使用這種方式來佈局,但是這個最起碼是一個解決的方案,這個案例可能不明顯,但是如果是有很多分組,組內又全部都是橫向排列的時候,這個方案就很容易完成了.
至於瀑布流,雖然一直以來都被人詬病,因為其雖然好看,但是其因為每一個元素的高度都不一樣,所以需要計算每一個的高度,從而確定下一個的排列的位置,於是這就會比較耗效能,同時因為其特殊性,導致其開發難度相較一般的列表頁會稍微複雜一點,但是其實我們只要掌握其原理我們還是很容易寫出來的.
首先我們要選擇使用什麼控制元件來寫,一般我們會第一時間想到scrollView,這個是可以的,但是因為其相對來說封裝的東西不多,且沒有自己的複用機制,相對來說我們的開發難度會複雜一些,
其實一直都有一個很好的控制元件,但是我們一般都會忽略,那就是FlatList和SectionList的底層—VirtualizedList
是的就是這個控制元件,通過簡單的封裝就實現了FlatList和SectionList,其有自己的渲染item和複用的機制,其封裝了上拉重新整理和下拉載入,我們可以使用這個控制元件來定義任何我們想要的各種變種的列表,比如瀑布流,比如音樂卡片…..
import * as React from 'react';
import {
VirtualizedList,
View,
ScrollView,
StyleSheet,
findNodeHandle,
RefreshControl,
} from 'react-native';
type Column = {
index: number,
totalHeight: number,
data: Array<any>,
heights: Array<number>,
};
const _stateFromProps = ({ numColumns, data, getHeightForItem }) => {
const columns: Array<Column> = Array.from({
length: numColumns,
}).map((col, i) => ({
index: i,
totalHeight: 0,
data: [],
heights: [],
}));
data.forEach((item, index) => {
const height = getHeightForItem({ item, index });
const column = columns.reduce(
(prev, cur) => (cur.totalHeight < prev.totalHeight ? cur : prev),
columns[0],
);
column.data.push(item);
column.heights.push(height);
column.totalHeight += height;
});
return { columns };
};
export type Props = {
data: Array<any>,
numColumns: number,
renderItem: ({ item: any, index: number, column: number }) => ?React.Element<
any,
>,
getHeightForItem: ({ item: any, index: number }) => number,
ListHeaderComponent?: ?React.ComponentType<any>,
ListEmptyComponent?: ?React.ComponentType<any>,
/**
* Used to extract a unique key for a given item at the specified index. Key is used for caching
* and as the react key to track item re-ordering. The default extractor checks `item.key`, then
* falls back to using the index, like React does.
*/
keyExtractor?: (item: any, index: number) => string,
// onEndReached will get called once per column, not ideal but should not cause
// issues with isLoading checks.
onEndReached?: ?(info: { distanceFromEnd: number }) => void,
contentContainerStyle?: any,
onScroll?: (event: Object) => void,
onScrollBeginDrag?: (event: Object) => void,
onScrollEndDrag?: (event: Object) => void,
onMomentumScrollEnd?: (event: Object) => void,
onEndReachedThreshold?: ?number,
scrollEventThrottle: number,
renderScrollComponent: (props: Object) => React.Element<any>,
/**
* Set this true while waiting for new data from a refresh.
*/
refreshing?: ?boolean,
/**
* If provided, a standard RefreshControl will be added for "Pull to Refresh" functionality. Make
* sure to also set the `refreshing` prop correctly.
*/
onRefresh?: ?Function,
};
type State = {
columns: Array<Column>,
};
// This will get cloned and added a bunch of props that are supposed to be on
// ScrollView so we wan't to make sure we don't pass them over (especially
// onLayout since it exists on both).
class FakeScrollView extends React.Component<{ style?: any, children?: any }> {
render() {
return (
<View style={this.props.style}>
{this.props.children}
</View>
);
}
}
export default class MasonryList extends React.Component<Props, State> {
static defaultProps = {
scrollEventThrottle: 50,
numColumns: 1,
renderScrollComponent: (props: Props) => {
if (props.onRefresh && props.refreshing != null) {
return (
<ScrollView
{...props}
refreshControl={
<RefreshControl
refreshing={props.refreshing}
onRefresh={props.onRefresh}
/>
}
/>
);
}
return <ScrollView {...props} />;
},
};
state = _stateFromProps(this.props);
_listRefs: Array<?VirtualizedList> = [];
_scrollRef: ?ScrollView;
_endsReached = 0;
componentWillReceiveProps(newProps: Props) {
this.setState(_stateFromProps(newProps));
}
getScrollResponder() {
if (this._scrollRef && this._scrollRef.getScrollResponder) {
return this._scrollRef.getScrollResponder();
}
return null;
}
getScrollableNode() {
if (this._scrollRef && this._scrollRef.getScrollableNode) {
return this._scrollRef.getScrollableNode();
}
return findNodeHandle(this._scrollRef);
}
scrollToOffset({ offset, animated }: any) {
if (this._scrollRef) {
this._scrollRef.scrollTo({ y: offset, animated });
}
}
_onLayout = event => {
this._listRefs.forEach(
list => list && list._onLayout && list._onLayout(event),
);
};
_onContentSizeChange = (width, height) => {
this._listRefs.forEach(
list =>
list &&
list._onContentSizeChange &&
list._onContentSizeChange(width, height),
);
};
_onScroll = event => {
if (this.props.onScroll) {
this.props.onScroll(event);
}
this._listRefs.forEach(
list => list && list._onScroll && list._onScroll(event),
);
};
_onScrollBeginDrag = event => {
if (this.props.onScrollBeginDrag) {
this.props.onScrollBeginDrag(event);
}
this._listRefs.forEach(
list => list && list._onScrollBeginDrag && list._onScrollBeginDrag(event),
);
};
_onScrollEndDrag = event => {
if (this.props.onScrollEndDrag) {
this.props.onScrollEndDrag(event);
}
this._listRefs.forEach(
list => list && list._onScrollEndDrag && list._onScrollEndDrag(event),
);
};
_onMomentumScrollEnd = event => {
if (this.props.onMomentumScrollEnd) {
this.props.onMomentumScrollEnd(event);
}
this._listRefs.forEach(
list =>
list && list._onMomentumScrollEnd && list._onMomentumScrollEnd(event),
);
};
_getItemLayout = (columnIndex, rowIndex) => {
const column = this.state.columns[columnIndex];
let offset = 0;
for (let ii = 0; ii < rowIndex; ii += 1) {
offset += column.heights[ii];
}
return { length: column.heights[rowIndex], offset, index: rowIndex };
};
_renderScrollComponent = () => <FakeScrollView style={styles.column} />;
_getItemCount = data => data.length;
_getItem = (data, index) => data[index];
_captureScrollRef = ref => (this._scrollRef = ref);
render() {
const {
renderItem,
ListHeaderComponent,
ListEmptyComponent,
keyExtractor,
onEndReached,
...props
} = this.props;
let headerElement;
if (ListHeaderComponent) {
headerElement = <ListHeaderComponent />;
}
let emptyElement;
if (ListEmptyComponent) {
emptyElement = <ListEmptyComponent />;
}
const content = (
<View style={styles.contentContainer}>
{this.state.columns.map(col =>
<VirtualizedList
{...props}
ref={ref => (this._listRefs[col.index] = ref)}
key={`$col_${col.index}`}
data={col.data}
getItemCount={this._getItemCount}
getItem={this._getItem}
getItemLayout={(data, index) =>
this._getItemLayout(col.index, index)}
renderItem={({ item, index }) =>
renderItem({ item, index, column: col.index })}
renderScrollComponent={this._renderScrollComponent}
keyExtractor={keyExtractor}
onEndReached={onEndReached}
onEndReachedThreshold={this.props.onEndReachedThreshold}
removeClippedSubviews={false}
/>,
)}
</View>
);
const scrollComponent = React.cloneElement(
this.props.renderScrollComponent(this.props),
{
ref: this._captureScrollRef,
removeClippedSubviews: false,
onContentSizeChange: this._onContentSizeChange,
onLayout: this._onLayout,
onScroll: this._onScroll,
onScrollBeginDrag: this._onScrollBeginDrag,
onScrollEndDrag: this._onScrollEndDrag,
onMomentumScrollEnd: this._onMomentumScrollEnd,
},
headerElement,
emptyElement && this.props.data.length === 0 ? emptyElement : content,
);
return scrollComponent;
}
}
const styles = StyleSheet.create({
contentContainer: {
flexDirection: 'row',
},
column: {
flex: 1,
},
});
這裡就是用到了VirtualizedList來封裝成的瀑布流,我們只要像使用FlatList一樣的方式就能使用了,當然我們需要自己新增一個計算每一個item的寬度和高度的方法,
於是使用的方式就成了這樣
import React, { Component } from 'react';
import {Dimensions, SafeAreaView, Text, View, StyleSheet, TouchableOpacity } from "react-native";
import MasonryList from '../Base/MasonryList';
import PlacehoderImage from '../Base/PlaceholderImage';
const { width, height } = Dimensions.get('window');
const itemWidth = (width - 16) / 2;
const secToTime = (s) => {
let h = 0, m = 0;
if(s > 60){
m = parseInt(s / 60);
s = parseInt(s % 60);
if(m > 60) {
h = parseInt(i / 60);
m = parseInt(i % 60);
}
}
// 補零
const zero = (v) => {
return (v >> 0) < 10 ? ("0" + v) : v;
};
return (h == 0 ? [zero(m), zero(s)].join(":") : [zero(h), zero(m), zero(s)].join(":"));
}
export default class ContentWaterfall extends React.Component {
constructor(props) {
super(props);
this.state = {
refreshing: false,
data: [],
np: 0,
}
}
componentDidMount = () => {
this.onRefreshing();
}
render() {
return (
<SafeAreaView style={styles.container}>
<MasonryList
data={this.state.data}
numColumns={2}
renderItem={this._renderItem}
getHeightForItem={this._getHeightForItem}
refreshing = {this.state.refreshing}
onRefresh = {this.onRefreshing}
onEndReachedThreshold={0.5}
onEndReached={this._onEndReached}
keyExtractor={this._keyExtractor}
/>
</SafeAreaView>
)
}
onRefreshing = () => {
this.setState({
refreshing: true,
})
const { api } = this.props;
fetch(api(this.state.np))
.then((response) => response.json())
.then((jsonData) => {
this.setState({
refreshing: false,
data: jsonData.list,
np: jsonData.info.np || 0,
})
});
}
_onEndReached = () => {
const { api } = this.props;
fetch(api(this.state.np))
.then((response) => response.json())
.then((jsonData) => {
this.setState({
data: [...this.state.data, ...jsonData.list],
np: jsonData.info.np,
})
});
}
_keyExtractor = (item, index) => {
return item.text + index;
}
_getHeightForItem = ({item}) => {
return Math.max(itemWidth, itemWidth / item.video.width * item.video.height);
}
_renderItem = ({item}) => {
const itemHeight = this._getHeightForItem({item});
return (
<TouchableOpacity
activeOpacity={0.7}
onPress={() => this._onPressContent(item)}
style={styles.item}>
<PlacehoderImage
source={{uri: item.video.thumbnail[0]}}
placeholder={{uri: 'placeholder'}}
style={{width: itemWidth, height: itemHeight, borderRadius: 4}}
/>
<View style={styles.itemText}>
<Text style={{color: '#fff'}}>{secToTime(item.video.duration)}</Text>
<Text style={{color: '#fff'}}>{item.comment}</Text>
</View>
</TouchableOpacity>
)
}
_onPressContent = (item) => {
this.props.navigation.navigate('ContentDetail', {item});
}
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
item: {
margin: 4,
},
itemText: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingHorizontal: 10,
position: 'absolute',
left: 0,
right: 0,
bottom: 0,
height: 30,
backgroundColor: '#0002',
borderBottomLeftRadius: 4,
borderBottomRightRadius: 4
},
})
效果如下
於是一個完整的瀑布流就實現了
當然如果你想要看完整的原始碼,你可以參考我的這個共享的專案
https://github.com/spicyShrimp/Misses
這是一個仿寫百思不得姐的專案,已經基本完成,如果覺得不錯,可以給個☆☆,您的支援,是我最大的動力……