瀑布流(Waterfall/Masonry)是 Pinterest 带火的经典布局——多列不等高卡片自动填充,视觉错落有致,浏览效率极高。HarmonyOS NEXT 把瀑布流做成了内置组件 WaterFlow,不需要手动计算列高、不需要第三方库,声明式 API 即可实现。本文用它构建一个"灵感画廊",展示瀑布流布局的完整设计模式。


一、为什么需要 WaterFlow?

ArkUI 已经提供了 List(线性列表)和 Grid(固定网格),为什么还需要 WaterFlow?

List 每行一个元素,高度可以不同,但只能单列:

[ 卡片1  ][ 满宽  ]  ← 一行只能放一个
[ 卡片2  ][ 满宽  ]
[ 卡片3 ][ 满宽  ]

Grid 每行固定列数,但所有单元格高度相同:

[ 卡片1 ] [ 卡片2 ]  ← 同一行对齐,高度一致
[ 卡片3 ] [ 卡片4 ]  ← 矮卡片下面留白
[ 留白  ] [ 留白  ]

WaterFlow 每行固定列数,每个卡片高度不同,下一行自动向上填充:

[ 卡片1 ] [ 卡片2 ]  ← 卡片2比卡片1高
[ 卡片3 ] [        ]  ← 卡片3紧贴卡片1底部
[ 卡片4 ] [ 卡片2 ]  ← 卡片4紧贴卡片3底部
[        ] [ 卡片5 ]  ← 错落有致,无浪费空间

WaterFlow 解决的核心问题是:在有限屏幕空间内展示更多内容。 由于卡片高度不同且自动向上排列,同样面积下能比 Grid 多展示约 20%~30% 的内容。这对于图片浏览、商品推荐、内容探索等"以视觉为主"的场景非常关键。

// WaterFlow 的核心 API
WaterFlow(options?: { scroller?: Scroller }) {
  ForEach(items, (item: ItemType) => {
    FlowItem() {
      // 每个 FlowItem 可以有不同高度
      Column() { /* 卡片内容 */ }
    }
    .width('100%')
  }, (item: ItemType) => item.id.toString())
}
.columnsTemplate('1fr 1fr')   // 定义列模板,这里是两列等宽
.columnsGap(8)                // 列间距
.rowsGap(8)                   // 行间距
.onReachEnd(() => {           // 滚动到底部回调
  // 加载更多数据
})

二、WaterFlow 核心 API 详解

2.1 columnsTemplate:列模板字符串

columnsTemplate 使用 CSS Grid 的 grid-template-columns 语法,空格分隔每列宽度:

.columnsTemplate('1fr 1fr')           // 两列等宽,各占 50%
.columnsTemplate('1fr 1fr 1fr')       // 三列等宽,各占 33.3%
.columnsTemplate('2fr 1fr')           // 两列不等宽,左列 66.7%,右列 33.3%
.columnsTemplate('200vp 1fr')         // 左列固定 200vp,右列弹性填充
.columnsTemplate('1fr')               // 单列(退化为 List,但高度仍可变)

fr 是 CSS Grid 的"弹性份数"单位。'1fr 1fr 1fr' 表示将可用空间均分为 3 份,每列各占 1 份。

2.2 rowsTemplate:行模板(水平瀑布流)

如果使用水平瀑布流(layoutDirection: FlexDirection.Row),则通过 rowsTemplate 定义行高度:

WaterFlow() { ... }
  .layoutDirection(FlexDirection.Row)   // 水平滚动
  .rowsTemplate('1fr 1fr')             // 两行等高

大多数场景下使用默认的垂直瀑布流(FlexDirection.Column)即可。

2.3 columnsGap / rowsGap:列间距与行间距

WaterFlow() { ... }
  .columnsGap(12)    // 列与列之间的水平间距
  .rowsGap(12)       // 行与行之间的垂直间距

间距值建议在 8~16vp 之间——太大会浪费空间,太小会显得拥挤。

2.4 onReachEnd:滚动到底部回调

onReachEnd 是 WaterFlow 实现"无限加载"的关键:

WaterFlow() { ... }
  .onReachEnd(() => {
    // 用户滑到底部
    if (this.hasMore && !this.isLoading) {
      this.loadMore();  // 加载更多数据,追加到数组
    }
  })

onReachEnd 在滚动到距离底部一定阈值时触发(默认约 100vp)。不需要手动监听滚动位置计算"是否到底了"。

2.5 Scroller 控制器

private scroller: Scroller = new Scroller();

WaterFlow({ scroller: this.scroller }) { ... }

// 通过控制器操作滚动
this.scroller.scrollToIndex(0);           // 滚到第一个元素
this.scroller.scrollEdge(Edge.Top);       // 滚到顶部
this.scroller.scrollPage({ next: true }); // 翻一页

Scroller 提供了编程式控制滚动的能力,可以配合切换列数等操作使用。

2.6 FlowItem:瀑布流的子元素

FlowItem 是 WaterFlow 的直接子组件,每个 FlowItem 占据一列中的一个位置。关键点:

  • 宽度由 WaterFlow 的 column 宽度决定,不需要手动设置
  • 高度由 FlowItem 内部内容决定,这是实现"不等高"的关键
  • 必须设置 .width('100%') 让内容撑满列宽
FlowItem() {
  Column() {
    // 不同高度的图片占位区域
    Image('url').height(this.randomHeight)  // 高度各不相同
    Text('标题')
    Text('描述')
  }
  .width('100%')  // 撑满列宽
}

在这里插入图片描述

三、WaterFlow vs Grid vs List:如何选择

特性 List Grid WaterFlow
列数 1 列 固定 N 列 固定 N 列
子项高度 可变 固定(按最大高度对齐) 可变(自动向上填充)
空间利用率 一般 较低(等高出留白) 最高
视觉风格 列表式 整齐工整 错落有致
适用场景 新闻/聊天/设置 商品网格/相册 灵感/推荐/探索

选择建议

  • 内容以文字为主、讲究阅读顺序 → List
  • 内容以图片为主、讲究整齐划一 → Grid
  • 内容以图片为主、讲究视觉效果和浏览效率 → WaterFlow

在这里插入图片描述

四、Demo:灵感画廊

本 Demo 构建一个灵感画廊——瀑布流展示摄影作品,每张"照片"有不同高度,支持 2/3 列切换和无限加载。

页面结构

WaterFlowPage (~170行)
├── Header(☰ 标题 + 2列/3列切换按钮)
├── WaterFlow(瀑布流容器)
│   └── FlowItem × N(照片卡片)
│       ├── Stack:emoji 占位图(可变高度 120~180vp)
│       └── Column:标题 + 摄影师名
├── 底部加载提示("上拉加载更多...")

列数切换

用户点击右上角按钮,在 2 列和 3 列之间切换:

@State columns: number = 2;

WaterFlow() { ... }
  .columnsTemplate(this.columns === 2 ? '1fr 1fr' : '1fr 1fr 1fr')

只需改变 columnsTemplate 字符串,WaterFlow 自动重排所有卡片。不需要手动计算布局。

可变高度的照片占位块

这是瀑布流"不等高"的核心——每张"照片"有预定义的随机高度:

class PhotoItem {
  id: number;
  title: string;
  photographer: string;
  height: number;    // ← 这个值各不相同(120~180vp)
  color: string;     // ← 每张照片的主题色
  emoji: string;     // ← emoji 代替真实图片
}

// 不同照片有不同高度
new PhotoItem(1, '山间日出', '摄影师小王', 160, '#FF6B6B', '🏔️'),
new PhotoItem(2, '海浪拍岸', '海洋之眼', 180, '#4ECDC4', '🌊'),
new PhotoItem(3, '春日樱花', '花间一壶酒', 140, '#FFB7B2', '🌸'),

每个 FlowItem 内部:

Stack() {
  Text(photo.emoji)
    .fontSize(48)
    .textAlign(TextAlign.Center)
    .width('100%')
    .height(photo.height)   // ← 可变高度
}
.width('100%')
.height(photo.height)       // ← 可变高度
.backgroundColor(photo.color + '22')

因为每个 FlowItem 的高度不同,WaterFlow 会自动把它们排列成"矮卡片下面的空间被下一个卡片填充"的瀑布效果。

无限加载

滚动到底部时追加更多照片:

.onReachEnd(() => {
  if (this.hasMore) {
    this.photos = this.photos.concat(MORE_PHOTOS);
    this.hasMore = false;
  }
})

使用 concat 而不是 push 创建新数组,确保 @State 能检测到变化并触发 UI 刷新。

底部提示条

当还有更多数据时,在 WaterFlow 下方显示提示文字:

if (this.hasMore) {
  Row() {
    Text('上拉加载更多...')
      .fontSize(FontSize.CAPTION)
      .fontColor(AppColors.TEXT_TERTIARY)
  }
  .width('100%').height(36)
  .justifyContent(FlexAlign.Center)
}

照片点击详情

点击任意 FlowItem 弹出详情对话框:

.onClick(() => {
  promptAction.showDialog({
    title: photo.title,
    message: `摄影师:${photo.photographer}\n\n这张作品...`,
    buttons: [{ text: '关闭', color: AppColors.PRIMARY }]
  });
})

三个交互点:

  1. 列数切换 — 2 列 ↔ 3 列,WaterFlow 自动重排
  2. 照片点击 — 弹窗展示作品详情
  3. 无限加载 — 滚动到底部追加 9 张新照片

在这里插入图片描述

五、完整代码

import { AppColors, BorderRadius, FontSize, Spacing } from '../common/Constants';
import { promptAction } from '@kit.ArkUI';

class PhotoItem {
  id: number;
  title: string;
  photographer: string;
  height: number;
  color: string;
  emoji: string;

  constructor(id: number, title: string, photographer: string, height: number,
    color: string, emoji: string) {
    this.id = id;
    this.title = title;
    this.photographer = photographer;
    this.height = height;
    this.color = color;
    this.emoji = emoji;
  }
}

const INITIAL_PHOTOS: PhotoItem[] = [
  new PhotoItem(1, '山间日出', '摄影师小王', 160, '#FF6B6B', '🏔️'),
  new PhotoItem(2, '海浪拍岸', '海洋之眼', 180, '#4ECDC4', '🌊'),
  new PhotoItem(3, '春日樱花', '花间一壶酒', 140, '#FFB7B2', '🌸'),
  new PhotoItem(4, '都市夜景', '城市猎人', 170, '#2C3E50', '🏙️'),
  new PhotoItem(5, '沙漠黄昏', '行者无疆', 150, '#E8A87C', '🌅'),
  new PhotoItem(6, '拉面匠心', '美食侦探', 130, '#F7DC6F', '🍜'),
  new PhotoItem(7, '蝴蝶标本', '微观世界', 120, '#BB8FCE', '🦋'),
  new PhotoItem(8, '抽象艺术', '色彩诗人', 155, '#E74C3C', '🎨'),
  new PhotoItem(9, '古寺禅意', '东方美学', 165, '#D35400', '🏯'),
  new PhotoItem(10, '仙人掌花园', '沙漠绿洲', 145, '#27AE60', '🌵'),
  new PhotoItem(11, '大象漫步', '非洲之魂', 150, '#8D6E63', '🐘'),
  new PhotoItem(12, '舞会面具', '光影魔术师', 135, '#1A1A2E', '🎭'),
  new PhotoItem(13, '东京塔光', '霓虹漫步', 175, '#E74C3C', '🗼'),
  new PhotoItem(14, '雪山之巅', '极地探险家', 160, '#D6EAF8', '❄️'),
  new PhotoItem(15, '寿司之美', '味觉旅人', 120, '#F5B041', '🍣'),
];

const MORE_PHOTOS: PhotoItem[] = [
  new PhotoItem(16, '嘉年华夜', '欢乐捕手', 148, '#F39C12', '🎪'),
  new PhotoItem(17, '碧海金沙', '海岸线', 158, '#45B7D1', '🏖️'),
  new PhotoItem(18, '向日葵田', '追光者', 142, '#FFC312', '🌻'),
  new PhotoItem(19, '鹦鹉彩羽', '丛林密语', 132, '#00B894', '🦜'),
  new PhotoItem(20, '音乐节拍', '摇滚灵魂', 168, '#6C5CE7', '🎵'),
  new PhotoItem(21, '城堡落日', '中世纪幻想', 170, '#636E72', '🏰'),
  new PhotoItem(22, '彩虹瀑布', '自然奇迹', 138, '#E056A0', '🌈'),
  new PhotoItem(23, '葡萄美酒', '品酒师日记', 125, '#722F37', '🍷'),
  new PhotoItem(24, '星际穿越', '星空守望者', 162, '#0A0E27', '🚀'),
];

@Entry
@Component
struct WaterFlowPage {
  @State photos: PhotoItem[] = [...INITIAL_PHOTOS];
  @State columns: number = 2;
  @State hasMore: boolean = true;
  private scroller: Scroller = new Scroller();

  build() {
    Column() {
      Row() {
        Text('☰ 灵感画廊')
          .fontSize(FontSize.TITLE)
          .fontWeight(FontWeight.Bold)
          .fontColor(Color.White)
          .layoutWeight(1)

        Row() {
          Text(this.columns === 2 ? '▦' : '▤')
            .fontSize(18)
            .fontColor(Color.White)
            .margin({ right: 4 })
          Text(`${this.columns}`)
            .fontSize(FontSize.CAPTION)
            .fontColor(Color.White)
        }
        .padding({ left: 12, right: 16, top: 6, bottom: 6 })
        .backgroundColor('#FFFFFF33')
        .borderRadius(9999)
        .onClick(() => {
          this.columns = this.columns === 2 ? 3 : 2;
        })
      }
      .width('100%')
      .height(52)
      .backgroundColor(AppColors.PRIMARY)
      .padding({ left: Spacing.LG, right: Spacing.LG })

      WaterFlow({ scroller: this.scroller }) {
        ForEach(this.photos, (photo: PhotoItem, index: number) => {
          FlowItem() {
            Column() {
              Stack() {
                Text(photo.emoji)
                  .fontSize(48)
                  .textAlign(TextAlign.Center)
                  .width('100%')
                  .height(photo.height)
              }
              .width('100%')
              .height(photo.height)
              .backgroundColor(photo.color + '22')
              .borderRadius({ topLeft: BorderRadius.SM, topRight: BorderRadius.SM })
              .border({ width: 1, color: photo.color + '44' })

              Column() {
                Text(photo.title)
                  .fontSize(FontSize.BODY)
                  .fontColor(AppColors.TEXT_PRIMARY)
                  .fontWeight(FontWeight.Medium)
                  .width('100%')
                  .maxLines(1)
                  .textOverflow({ overflow: TextOverflow.Ellipsis })

                Text(photo.photographer)
                  .fontSize(FontSize.CAPTION)
                  .fontColor(AppColors.TEXT_TERTIARY)
                  .width('100%')
                  .margin({ top: 2 })
              }
              .width('100%')
              .padding({ left: 10, right: 10, top: 8, bottom: 10 })
            }
            .width('100%')
            .backgroundColor(Color.White)
            .borderRadius(BorderRadius.SM)
            .onClick(() => {
              promptAction.showDialog({
                title: photo.title,
                message: `摄影师:${photo.photographer}\n\n` +
                  `这张作品展现了摄影师对光影与构图的独特理解。` +
                  `通过${photo.title}这一主题,传达了对自然与生活的深刻感悟。` +
                  `\n\n— 来自灵感画廊`,
                buttons: [{ text: '关闭', color: AppColors.PRIMARY }]
              });
            })
          }
          .width('100%')
        }, (photo: PhotoItem) => photo.id.toString())
      }
      .columnsTemplate(this.columns === 2 ? '1fr 1fr' : '1fr 1fr 1fr')
      .columnsGap(8)
      .rowsGap(8)
      .layoutWeight(1)
      .width('100%')
      .padding({ left: Spacing.LG, right: Spacing.LG, top: Spacing.LG })
      .backgroundColor(AppColors.BACKGROUND)
      .onReachEnd(() => {
        if (this.hasMore) {
          this.photos = this.photos.concat(MORE_PHOTOS);
          this.hasMore = false;
        }
      })

      if (this.hasMore) {
        Row() {
          Text('上拉加载更多...')
            .fontSize(FontSize.CAPTION)
            .fontColor(AppColors.TEXT_TERTIARY)
        }
        .width('100%')
        .height(36)
        .justifyContent(FlexAlign.Center)
        .backgroundColor(AppColors.BACKGROUND)
      }
    }
    .width('100%')
    .height('100%')
  }
}

六、常见面试题 / 踩坑点

6.1 WaterFlow 的子组件必须用 FlowItem 包裹吗?

是的。WaterFlow 的直接子组件必须是 FlowItem。如果在 ForEach 里直接用 Column 而不包 FlowItem,编译器会报错或者布局异常。

// 正确
ForEach(items, (item) => {
  FlowItem() { Column() { ... } }
})

// 错误 — 不能用 Column 直接做 WaterFlow 子组件
ForEach(items, (item) => {
  Column() { ... }
})

6.2 columnsTemplate 可以用 @State 动态切换吗?

可以。columnsTemplate 接受字符串参数,可以直接绑定 @State 变量:

@State columns: number = 2;

WaterFlow() { ... }
  .columnsTemplate(this.columns === 2 ? '1fr 1fr' : '1fr 1fr 1fr')

切换 @State 后 WaterFlow 自动重排所有 FlowItem。不过需要注意——如果当前滚动位置在第 30 个元素,切换列数后这些元素可能被重新排列到完全不同的位置,用户体验可能不太好。建议在切换列数时配合 scroller.scrollToIndex(0) 回到顶部。

6.3 WaterFlow 怎么实现"加载更多"?

使用 onReachEnd 回调。当用户滚动接近底部时,追加数据到数组:

@State items: ItemType[] = [...INITIAL_ITEMS];
@State hasMore: boolean = true;

WaterFlow() { ... }
  .onReachEnd(() => {
    if (this.hasMore) {
      this.items = this.items.concat(MORE_ITEMS);  // 必须创建新数组引用
      this.hasMore = false;
    }
  })

关键点:必须用 concat 或展开运算符创建新数组引用,push 不会触发 @State 更新。

6.4 WaterFlow 和 Grid 的核心区别是什么?

Grid 的每一行是等高对齐的——行高由该行最高的子项决定,矮的子项下面会有留白。WaterFlow 的子项可以"向上填充"——左列矮卡片下面的空间可以被下一个卡片占据。

简单说:Grid 是"行对齐"(row-aligned),WaterFlow 是"列对齐"(column-aligned)。

6.5 如何获取 WaterFlow 的滚动状态?

通过 Scroller 控制器:

private scroller: Scroller = new Scroller();

WaterFlow({ scroller: this.scroller }) { ... }

// 编程式滚动
this.scroller.scrollToIndex(10);            // 滚到第 10 个元素
this.scroller.scrollEdge(Edge.Top);         // 滚到顶部
this.scroller.scrollPage({ next: true });   // 向下翻一页
this.scroller.scrollPage({ next: false });  // 向上翻一页

注意:Scroller 没有 currentOffset() 方法。如果需要监听滚动位置,需要用 .onScroll() 回调配合手动记录。


七、总结

WaterFlow 是 HarmonyOS NEXT 面向视觉内容浏览场景的重要布局组件。它的核心价值有三点:

1. 内置瀑布流算法。 无需手动计算每列的当前高度、判断下一个卡片应该放在哪一列、处理高度误差累积。WaterFlow 内置了完整的瀑布流布局引擎,开发者只需要提供数据和控制列数。

2. 声明式 API,与 List/Grid 一致。 WaterFlow 的使用方式与 List、Grid 高度一致——ForEach 遍历数据、FlowItem 包裹子组件、columnsTemplate 控制列数。开发者不需要学习全新的心智模型,从 Grid 切换到 WaterFlow 只需要改组件名和属性名。

3. 空间利用率最高。 在相同屏幕面积下,WaterFlow 比 Grid 能多展示约 20%~30% 的内容,因为不等高卡片自动向上填充,几乎没有空白浪费。这对于以视觉浏览为主的内容型应用(图片社区、设计灵感、商品推荐等)体验提升显著。

WaterFlow 特别适合以下场景:

  • 图片社区 / 灵感收集(本文 Demo)
  • 电商商品推荐(商品图比例各不同)
  • 设计作品展示(插画、海报、UI 稿)
  • 视频封面列表(横版/竖版视频封面混合)
  • 内容探索 / 发现页(图文混排、高度各异)

对于内容发现型页面,WaterFlow 的"错落有致"比 Grid 的"整齐划一"更有浏览欲望——用户会自然地向下滑动,因为每一行都是一个视觉惊喜。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐