最终效果

滚动到最底部

实现原理
- 使用绝对定位实现交错衔接
- 图片自适应布局
代码范例
数据类型
typings.d.ts
ts
type ArticleSimple = {
id: number;
title: string;
userName: string;
avatarUrl: string;
favoriteCount: number;
isFavorite: boolean;
image: string;
};
模拟数据 mock/articleList.ts
c
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
c
import WaterfallFlow from "../../components/WaterfallFlow";
import articleList from "@/mock/articleList";
c
<WaterfallFlow
data={articleList}
// 列数
numColumns={2}
// 列间距
columnGap={8}
// 行间距
rowGap={4}
// 触顶下拉刷新
onRefresh={refreshNewData}
// 触底加载更多数据
onLoadMore={loadMoreData}
// 是否在刷新
refreshing={store.refreshing}
// 列表页眉
renderHeader={() =>
(!isLoading_type && (
<TypeBar
allCategoryList={store.categoryList}
onCategoryChange={(category: Category) => {
console.log(JSON.stringify(category));
}}
/>
)) || <></>
}
// 列表项
renderItem={renderItem}
// 列表页脚
renderFooter={Footer}
/>
c
const refreshNewData = () => {
store.resetPage();
store.requestHomeList();
};
c
const loadMoreData = () => {
store.requestHomeList();
};
c
const Footer = () => {
return <Text style={styles.footerTxt}>---- 没有更多数据了 ---- </Text>;
};
c
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>
<Heart
value={item.isFavorite}
onValueChanged={(value: boolean) => {
console.log(value);
}}
/>
<Text style={styles.countTxt}>{item.favoriteCount}</Text>
</View>
</TouchableOpacity>
);
};
相关样式
c
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
c
import ResizeImage from "@/components/ResizeImage";
c
<ResizeImage uri={item.image} />
components/ResizeImage.tsx
c
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 (
<Image
style={{
width: (SCREEN_WIDTH - 18) >> 1,
height: height,
resizeMode: "cover",
}}
source={{ uri: uri }}
/>
);
};
【组件封装】点亮红心动画 Heart
app/(tabs)/index.tsx
c
import Heart from "@/components/Heart";
c
<Heart
value={item.isFavorite}
onValueChanged={(value: boolean) => {
console.log(value);
}}
/>
components/Heart.tsx
c
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}>
<Image
style={[styles.container, { width: size, height: size }]}
source={showState ? icon_heart : icon_heart_empty}
/>
<Animated.View
style={{
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
c
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 }) => (
<View
key={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 (
<View
style={[
styles.footerBox,
{
position: "absolute",
top: getListHeight(),
},
]}
>
{renderFooter && renderFooter(item)}
</View>
);
},
[renderFooter, getListHeight]
);
return (
<>
<FlatList
style={[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;