ReactNative【实战】瀑布流布局列表(含图片自适应、点亮红心动画)

发布于:2025-07-09 ⋅ 阅读:(24) ⋅ 点赞:(0)

最终效果

在这里插入图片描述

滚动到最底部

在这里插入图片描述

实现原理

  • 使用绝对定位实现交错衔接
  • 图片自适应布局

代码范例

数据类型

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";
      <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}
      />
  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>
          <Heart
            value={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 (
    <Image
      style={{
        width: (SCREEN_WIDTH - 18) >> 1,
        height: height,
        resizeMode: "cover",
      }}
      source={{ uri: uri }}
    />
  );
};

【组件封装】点亮红心动画 Heart

app/(tabs)/index.tsx

import Heart from "@/components/Heart";
<Heart
  value={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}>
      <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

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;

网站公告

今日签到

点亮在社区的每一天
去签到