ReactNative【实战】瀑布流布局列表(含图片自适应、点亮红心动画)
最终效果
滚动到最底部
实现原理
- 使用绝对定位实现交错衔接
- 图片自适应布局
代码范例
数据类型
typings.d.ts
type ArticleSimple = {id: number;title: string;userName: string;avatarUrl: string;favoriteCount: number;isFavorite: boolean;image: string;
};
模拟数据 mock/articleList.ts
const articleList: ArticleSimple[] = [{id: 1,title: "让我抱抱,一起温暖,真的好治愈",userName: "小飞飞爱猫咪",avatarUrl:"https://img2.baidu.com/it/u=902203086,3868774028&fm=253&app=138&f=JPEG?w=500&h=500",image:"http://gips2.baidu.com/it/u=195724436,3554684702&fm=3028&app=3028&f=JPEG&fmt=auto?w=1280&h=960",favoriteCount: 325,isFavorite: false,},{id: 2,title: "不愧是网友给的配方,真的香迷糊了",userName: "大厨师小飞象",avatarUrl:"https://pic.rmb.bdstatic.com/bjh/events/eeae3b71dabc9a372afd7f9e112287086428.jpeg@h_1280",image:"http://gips0.baidu.com/it/u=3602773692,1512483864&fm=3028&app=3028&f=JPEG&fmt=auto?w=960&h=1280",favoriteCount: 1098,isFavorite: false,},{id: 3,title: "一觉醒来,满树的柑橘爬上了我的窗",userName: "小小风筝",avatarUrl:"https://img1.baidu.com/it/u=1811602911,3261262340&fm=253&app=138&f=JPEG?w=500&h=500",image:"http://gips3.baidu.com/it/u=1537137094,335954266&fm=3028&app=3028&f=JPEG&fmt=auto?w=720&h=1280",favoriteCount: 18700,isFavorite: false,},{id: 4,title: "满床清梦压星河",userName: "失忆",avatarUrl:"https://img1.baidu.com/it/u=3505470809,2700212068&fm=253&app=138&f=JPEG?w=500&h=500",image:"https://gips3.baidu.com/it/u=1014935733,598223672&fm=3074&app=3074&f=PNG?w=1440&h=2560",favoriteCount: 8700,isFavorite: true,},{id: 5,title: "手机拍出来的星星,没想到那么多人喜欢",userName: "慢慢",avatarUrl:"https://img1.baidu.com/it/u=1924685292,2387273894&fm=253&app=138&f=JPEG?w=500&h=500",image:"https://img2.baidu.com/it/u=2585843050,3523947274&fm=253&app=138&f=JPEG?w=1422&h=800",favoriteCount: 2655,isFavorite: false,},{id: 6,title: "告白如同田野间的风在青春里轰然",userName: "潇潇",avatarUrl:"https://img1.baidu.com/it/u=3843254675,2187553494&fm=253&app=120&f=JPEG?w=800&h=800",image:"https://img1.baidu.com/it/u=1926713654,274347830&fm=253&app=138&f=JPEG?w=1422&h=800",favoriteCount: 2655,isFavorite: false,},
];
export default articleList;
首页 app/(tabs)/index.tsx
import WaterfallFlow from "../../components/WaterfallFlow";
import articleList from "@/mock/articleList";
<WaterfallFlowdata={articleList}// 列数numColumns={2}// 列间距columnGap={8}// 行间距rowGap={4}// 触顶下拉刷新onRefresh={refreshNewData}// 触底加载更多数据onLoadMore={loadMoreData}// 是否在刷新refreshing={store.refreshing}// 列表页眉renderHeader={() =>(!isLoading_type && (<TypeBarallCategoryList={store.categoryList}onCategoryChange={(category: Category) => {console.log(JSON.stringify(category));}}/>)) || <></>}// 列表项renderItem={renderItem}// 列表页脚renderFooter={Footer}/>
const refreshNewData = () => {store.resetPage();store.requestHomeList();};
const loadMoreData = () => {store.requestHomeList();};
const Footer = () => {return <Text style={styles.footerTxt}>---- 没有更多数据了 ---- </Text>;};
const renderItem = (item: ArticleSimple) => {return (<TouchableOpacity style={styles.item} onPress={onArticlePress(item)}><ResizeImage uri={item.image} /><Text style={styles.titleTxt}>{item.title}</Text><View style={[styles.nameLayout]}><Image style={styles.avatarImg} source={{ uri: item.avatarUrl }} /><Text style={styles.nameTxt}>{item.userName}</Text><Heartvalue={item.isFavorite}onValueChanged={(value: boolean) => {console.log(value);}}/><Text style={styles.countTxt}>{item.favoriteCount}</Text></View></TouchableOpacity>);};
相关样式
item: {width: (SCREEN_WIDTH - 18) >> 1,backgroundColor: "white",marginLeft: 6,marginBottom: 6,borderRadius: 8,overflow: "hidden",},countTxt: {fontSize: 14,color: "#999",marginLeft: 4,},titleTxt: {fontSize: 14,color: "#333",marginHorizontal: 10,marginVertical: 4,},nameLayout: {width: "100%",flexDirection: "row",alignItems: "center",paddingHorizontal: 10,marginBottom: 10,},avatarImg: {width: 20,height: 20,resizeMode: "cover",borderRadius: 10,},nameTxt: {fontSize: 12,color: "#999",marginLeft: 6,flex: 1,},footerTxt: {width: "100%",fontSize: 14,color: "#999",marginVertical: 16,textAlign: "center",textAlignVertical: "center",},
【组件封装】图片自适应 ResizeImage
app/(tabs)/index.tsx
import ResizeImage from "@/components/ResizeImage";
<ResizeImage uri={item.image} />
components/ResizeImage.tsx
import React, { useEffect, useState } from "react";
import { Dimensions, Image } from "react-native";
type Props = {uri: string;
};
const { width: SCREEN_WIDTH } = Dimensions.get("window");
const SHOW_WIDTH = (SCREEN_WIDTH - 18) >> 1;
// eslint-disable-next-line react/display-name
export default ({ uri }: Props) => {const [height, setHeight] = useState<number>(200);useEffect(() => {if (uri) {Image.getSize(uri, (width: number, height: number) => {const showHeight = (SHOW_WIDTH * height) / width;setHeight(showHeight);});}}, [uri]);return (<Imagestyle={{width: (SCREEN_WIDTH - 18) >> 1,height: height,resizeMode: "cover",}}source={{ uri: uri }}/>);
};
【组件封装】点亮红心动画 Heart
app/(tabs)/index.tsx
import Heart from "@/components/Heart";
<Heartvalue={item.isFavorite}onValueChanged={(value: boolean) => {console.log(value);}}
/>
components/Heart.tsx
import React, { useEffect, useRef, useState } from "react";
import { Animated, Image, StyleSheet, TouchableOpacity } from "react-native";
import icon_heart from "../assets/icons/icon_heart.png";
import icon_heart_empty from "../assets/icons/icon_heart_empty.png";
type Props = {value: boolean;onValueChanged?: (value: boolean) => void;size?: number;
};
// eslint-disable-next-line react/display-name
export default (props: Props) => {const { value, onValueChanged, size = 20 } = props;const [showState, setShowState] = useState<boolean>(false);const scale = useRef<Animated.Value>(new Animated.Value(0)).current;const alpha = useRef<Animated.Value>(new Animated.Value(0)).current;useEffect(() => {setShowState(value);}, [value]);const onHeartPress = () => {const newState = !showState;setShowState(newState);onValueChanged?.(newState);if (newState) {alpha.setValue(1);const scaleAnim = Animated.timing(scale, {toValue: 1.8,duration: 300,useNativeDriver: false,});const alphaAnim = Animated.timing(alpha, {toValue: 0,duration: 400,useNativeDriver: false,delay: 200,});Animated.parallel([scaleAnim, alphaAnim]).start();} else {scale.setValue(0);alpha.setValue(0);}};return (<TouchableOpacity onPress={onHeartPress}><Imagestyle={[styles.container, { width: size, height: size }]}source={showState ? icon_heart : icon_heart_empty}/><Animated.Viewstyle={{width: size,height: size,borderRadius: size / 2,borderWidth: size / 20,position: "absolute",borderColor: "#ff2442",transform: [{ scale: scale }],opacity: alpha,}}/></TouchableOpacity>);
};
const styles = StyleSheet.create({container: {width: 20,height: 20,resizeMode: "contain",},
});
【核心组件】瀑布流布局列表
components/WaterfallFlow.tsx
import React, { useCallback, useEffect, useMemo, useState } from "react";
import {FlatList,LayoutChangeEvent,StyleSheet,useWindowDimensions,View,ViewStyle,
} from "react-native";
// 数据项接口
interface Item {[key: string]: any;id: string | number;title?: string;height?: number; // 可选:预计算高度originalWidth?: number; // 可选:原始宽度originalHeight?: number; // 可选:原始高度
}
// 布局后的数据项
interface LayoutItem extends Item {x: number;y: number;width: number;actualHeight?: number;
}
// 组件属性
interface WaterfallListProps {data: Item[];numColumns?: number;columnGap?: number;rowGap?: number;paddingLeft?: number;paddingRight?: number;onLoadMore?: () => void;refreshing?: boolean;onRefresh?: () => void;renderItem: (item: any) => React.ReactElement;renderFooter?: (item: any) => React.ReactElement;renderHeader?: (item: any) => React.ReactElement;style?: ViewStyle;
}
const WaterfallList: React.FC<WaterfallListProps> = ({data = [],numColumns = 2,columnGap = 8,rowGap = 8,paddingLeft = 8,paddingRight = 8,onLoadMore,refreshing = false,onRefresh,renderItem,renderFooter,renderHeader,style,
}) => {const { width: windowWidth } = useWindowDimensions();const [layoutData, setLayoutData] = useState<LayoutItem[]>([]);const [measuredItems, setMeasuredItems] = useState<Record<string, number>>({});// 计算列宽const contentWidth = useMemo(() =>windowWidth - columnGap * (numColumns - 1) - paddingLeft - paddingRight,[windowWidth, numColumns, columnGap, paddingLeft, paddingRight]);const columnWidth = useMemo(() => contentWidth / numColumns,[contentWidth, numColumns]);// 布局算法useEffect(() => {const layoutItems = calculateLayout(data,numColumns,columnWidth,rowGap,measuredItems);setLayoutData(layoutItems);// eslint-disable-next-line react-hooks/exhaustive-deps}, [data, numColumns, columnWidth, rowGap, measuredItems]);// 计算瀑布流布局const calculateLayout = useCallback((items: Item[],numColumns: number,columnWidth: number,rowGap: number,measuredHeights: Record<string, number>): LayoutItem[] => {// 初始化列高度记录const columnHeights: number[] = Array(numColumns).fill(0);return items.map((item) => {// 确定当前高度(测量值 > 预计算值 > 基于原始尺寸计算 > 默认值)const height =measuredHeights[item.id] ||item.height ||(item.originalWidth && item.originalHeight? (columnWidth / item.originalWidth) * item.originalHeight: 200);// 找到当前最短的列const shortestColumnIndex = columnHeights.reduce((minIndex, height, index) =>height < columnHeights[minIndex] ? index : minIndex,0);// 计算项目位置const x = shortestColumnIndex * (columnWidth + columnGap);const y = columnHeights[shortestColumnIndex];// 更新列高度columnHeights[shortestColumnIndex] = y + height + rowGap;return {...item,x,y,width: columnWidth,actualHeight: height,};});},// eslint-disable-next-line react-hooks/exhaustive-deps[]);// 处理项目布局变化const handleLayout = useCallback((itemId: string | number, event: LayoutChangeEvent) => {const { height } = event.nativeEvent.layout;// 仅在高度变化时更新if (height !== measuredItems[itemId]) {setMeasuredItems((prev) => ({ ...prev, [itemId]: height }));}},[measuredItems]);// 获取列表总高度const getListHeight = useCallback(() => {if (layoutData.length === 0) return 0;// 找到所有列中的最大高度const columnHeights: number[] = Array(numColumns).fill(0);layoutData.forEach((item) => {const columnIndex = Math.floor(item.x / (columnWidth + columnGap));const itemHeight = item.actualHeight || 200;columnHeights[columnIndex] = Math.max(columnHeights[columnIndex],item.y + itemHeight);});return Math.max(...columnHeights);}, [layoutData, numColumns, columnWidth, columnGap]);// 渲染项目const renderWaterfallItem = useCallback(({ item }: { item: LayoutItem }) => (<Viewkey={item.id}style={{position: "absolute",left: item.x,top: item.y,width: item.width,}}onLayout={(event) => handleLayout(item.id, event)}>{renderItem(item)}</View>),[handleLayout, renderItem]);// 渲染底部加载更多const Footer = useCallback(({ item }: { item: LayoutItem }) => {return (<Viewstyle={[styles.footerBox,{position: "absolute",top: getListHeight(),},]}>{renderFooter && renderFooter(item)}</View>);},[renderFooter, getListHeight]);return (<><FlatListstyle={[style]}data={layoutData}renderItem={renderWaterfallItem}keyExtractor={(item) => item.id.toString()}contentContainerStyle={{minHeight: "100%",width: windowWidth,height: getListHeight() + 80,}}showsVerticalScrollIndicator={false}onEndReached={onLoadMore}onEndReachedThreshold={0.1}ListFooterComponent={Footer}ListHeaderComponent={renderHeader}refreshing={refreshing}onRefresh={onRefresh}/></>);
};
const styles = StyleSheet.create({footerBox: {width: "100%",alignItems: "center",justifyContent: "center",},
});
export default WaterfallList;