鸿蒙新特性:WaterFlow 瀑布流布局深度解析
瀑布流(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 }]
});
})
三个交互点:
- 列数切换 — 2 列 ↔ 3 列,WaterFlow 自动重排
- 照片点击 — 弹窗展示作品详情
- 无限加载 — 滚动到底部追加 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 的"整齐划一"更有浏览欲望——用户会自然地向下滑动,因为每一行都是一个视觉惊喜。
更多推荐




所有评论(0)