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

最终效果

滚动到最底部

实现原理

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

代码范例

数据类型

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;
相关推荐
song5014 小时前
React Native for OpenHarmony 实战:三方库 react-native-parallax-scroll-view 的鸿蒙化适配指南
分布式·react native·react.js·transformer·harmonyos
song5016 小时前
React Native for OpenHarmony 实战:三方库 react-native-ratings 的鸿蒙化适配指南
javascript·react native·react.js·分类·harmonyos
song50111 小时前
React Native for OpenHarmony 实战:三方库 react-native-popup-menu 的鸿蒙化适配指南
css·深度学习·react native·react.js·transformer·harmonyos
梦想的颜色1 天前
【编程实战】AI 时代 APP 开发全栈硬核指南:技术选型 + AI 架构 + 模型落地全维度决策
python·flutter·react native·react.js·ai·桌面应用·milvus
song5012 天前
React Native for OpenHarmony 实战:三方库 react-native-css-transformer 的鸿蒙化适配指南
css·人工智能·深度学习·react native·transformer
enjoywindstorm4 天前
移动端原生框架对比评测
react native
传奇开心果编程4 天前
【声明式UI开发实用技术学与练】第8课 状态提升与下放
学习·flutter·react native·ui·swiftui·composer
传奇开心果编程5 天前
【现代声明式UI学与练】第4课 列表渲染与 key——如何高效渲染列表、key 的作用、列表重排时的状态保持
学习·flutter·react native·ui·swiftui·android jetpack
传奇开心果编程6 天前
【现代声明式UI学与练】第7课 组件通信——父传子、子传父、跨层级通信、状态提升与 Context
学习·flutter·react native·ui·swiftui·android jetpack
500846 天前
React Native for OpenHarmony 实战:三方库 react-native-url-polyfill 的鸿蒙化适配指南
javascript·react native·react.js·性能优化·electron·harmonyos