鸿蒙应用开发UI基础第三十七节:瀑布流布局WaterFlow小红书首页实战开发
本文系统介绍了鸿蒙WaterFlow瀑布流组件的核心应用,重点内容包括: 核心架构:WaterFlow/FlowItem组件体系及其与Grid/List的选型边界 布局规则:不等高自适应布局的核心填充算法和性能优化策略 关键技术:LazyForEach懒加载、DataSource数据源管理、滑动窗口优化等 工程实践:完整实现小红书式双列瀑布流,包含数据模型、通用数据源基类等核心模块 交互特性:支持
·
本节我们将系统学习鸿蒙不等高瀑布流核心组件WaterFlow,以小红书首页为例搞懂瀑布流布局的核心规则、LazyForEach懒加载、无限滚动、性能优化等核心能力。
【学习目标】
- 掌握
WaterFlow/FlowItem核心架构,明确与Grid/List组件的选型边界,理解瀑布流布局的核心填充规则 - 吃透
WaterFlow六大核心能力:基础布局、无限滚动、动态列数、滚动控制、下拉刷新、性能优化 - 实现小红书首页核心交互:双列不等高瀑布流、下拉刷新、触底预加载、笔记卡片自适应
一、工程目录结构
WaterFlowDemo/
├── entry/
│ └── src/
│ └── main/
│ ├── ets/
│ │ ├── entryability/
│ │ │ └── EntryAbility.ets // 应用入口
│ │ ├── pages/
│ │ │ └── Index.ets // 小红书首页
│ │ ├── model/
│ │ │ └── NoteCardModel.ets // 数据模型
│ │ ├── components/
│ │ │ └── NoteCard.ets // 笔记卡片组件
│ │ └── datasource/
│ │ ├── BaseDataSource.ets // 通用列表数据源基类
│ │ └── NoteDataSource.ets // 懒加载业务数据源
│ ├── resources/
│ └── module.json5
└── build-profile.json5
二、瀑布流布局核心基础
2.1 什么是瀑布流布局
瀑布流布局是内容社区、电商类应用最主流的不等高自适应滚动布局方案,核心由WaterFlow容器组件和FlowItem子组件组成:
WaterFlow:瀑布流父容器,定义列数、布局模式、滚动规则等全局配置FlowItem:瀑布流子项容器,必须作为WaterFlow的直接子组件使用
核心特点:列数固定、高度自适应、自动填充最短列,完美适配小红书这类以图片为主、内容高度不固定的场景。
核心铁则:WaterFlow的直接子组件只能是FlowItem,FlowItem必须嵌套在WaterFlow容器内。
2.2 小红书瀑布流核心填充规则
- 第一行2个卡片,从左到右填充左右两列
- 后续卡片自动填入总高度更小的列
- 高度相同时,优先左列
- 垂直方向天然支持无限滚动
- 卡片宽度自动均分,高度由内容自适应
2.3 WaterFlow 核心选型规则
| 组件 | 核心优势 |
|---|---|
| WaterFlow | 不等高自适应、自动填充、原生无限滚动、滑动窗口优化 |
| Grid | 固定行列布局 |
| List | 单列滚动、性能最优 |
三、WaterFlow 核心配置详解
3.1 核心构造函数
WaterFlow(value?: {
footer?: CustomBuilder; // 可选:瀑布流底部自定义组件,用于加载更多、无更多数据提示
layoutMode?: WaterFlowLayoutMode;// 可选:布局模式,SLIDING_WINDOW 性能最优,长列表必须使用
sections?: WaterFlowSections; // 可选:多区域/多分组瀑布流,实现复杂分区布局
scroller?: Scroller; // 可选:滚动控制器,支持滚动到顶、滚动监听、精准定位
})
3.2 核心布局属性
| 属性 | 作用 | 配置示例 |
|---|---|---|
| columnsTemplate | 定义列数 | '1fr 1fr' 双列 |
| columnsGap | 列间距 | 8vp |
| rowsGap | 行间距 | 12vp |
| layoutMode | 渲染模式 | SLIDING_WINDOW 滑动窗口 |
3.3 核心布局模式
- SLIDING_WINDOW:仅渲染可见区域,组件自动回收,长列表必选
- ALIGN_ITEMS:一次性渲染所有项,禁止用于无限滚动
3.4 核心事件
onScrollIndex:滚动时触发,用于预加载下一页onReachEnd:滚动到底部触发,兜底加载onReachStart:滚动到顶部,用于下拉刷新
四、核心:LazyForEach + DataSource
4.1 核心优势
- 真正懒加载:只渲染屏幕可见区域,大幅降低内存占用
- 滑动丝滑:配合
SLIDING_WINDOW,万级数据无卡顿 - 数据解耦:数据源与UI分离,便于维护和扩展
五、完整代码实现
5.1 数据模型:NoteCardModel.ets
@Observed
export class NoteCardModel {
noteId: string;
coverUrl: string;
authorName: string;
avatarUrl: string;
likeCount: number;
title: string;
isLike: boolean = false
constructor(
noteId: string,
coverUrl: string,
authorName: string,
avatarUrl: string,
likeCount: number,
title: string
) {
this.noteId = noteId;
this.coverUrl = coverUrl;
this.authorName = authorName;
this.avatarUrl = avatarUrl;
this.likeCount = likeCount;
this.title = title;
}
}
5.2 通用数据源:BaseDataSource.ets
/**
* 通用数据源基类
* 基于鸿蒙官方 IDataSource 接口实现,封装列表数据的增删改查及刷新通知逻辑
* 用于减少 List / Grid / WaterFlow 等列表组件的重复代码
* @template T 列表数据泛型类型
*/
export class BaseDataSource<T> implements IDataSource {
/**
* 列表数据源数组
* @protected
*/
protected dataArray: T[] = [];
/**
* 数据变更监听器集合
* @private
*/
private listeners: DataChangeListener[] = [];
/**
* 获取列表数据总条数
* @returns 数据数量
*/
totalCount(): number {
return this.dataArray.length;
}
/**
* 根据索引获取对应数据项
* @param index 数据索引
* @returns 对应索引的数据项
*/
getData(index: number): T {
return this.dataArray[index];
}
/**
* 获取全部数据
* @returns 返回全部数据
*/
getAllData(): T[] {
return this.dataArray;
}
/**
* 通知列表:指定索引数据新增
* @param index 新增数据的索引
*/
notifyDataAdd(index: number): void {
this.listeners.forEach(listener => {
listener.onDataAdd(index);
});
}
/**
* 通知列表:指定索引数据发生变化
* @param index 变更数据的索引
*/
notifyDataChange(index: number): void {
this.listeners.forEach(listener => {
listener.onDataChange(index);
});
}
/**
* 通知列表:指定索引数据被删除
* @param index 删除数据的索引
*/
notifyDataDelete(index: number): void {
this.listeners.forEach(listener => {
listener.onDataDelete(index);
});
}
/**
* 通知列表:数据位置发生移动
* @param from 原索引
* @param to 目标索引
*/
notifyDataMove(from: number, to: number): void {
this.listeners.forEach(listener => {
listener.onDataMove(from, to);
});
}
/**
* 通知列表:执行批量数据操作
* @param operations 操作集合
*/
notifyDatasetChange(operations: DataOperation[]): void {
this.listeners.forEach(listener => {
listener.onDatasetChange(operations);
});
}
/**
* 通知列表:全局数据重新加载
*/
notifyDataReload(): void {
this.listeners.forEach(listener => {
listener.onDataReloaded();
});
}
/**
* 注册数据变更监听器
* @param listener 数据监听器
*/
registerDataChangeListener(listener: DataChangeListener): void {
if (this.listeners.indexOf(listener) < 0) {
this.listeners.push(listener);
}
}
/**
* 注销数据变更监听器
* @param listener 数据监听器
*/
unregisterDataChangeListener(listener: DataChangeListener): void {
const pos = this.listeners.indexOf(listener);
if (pos >= 0) {
this.listeners.splice(pos, 1);
}
}
/**
* 向列表末尾添加单条数据
* @param data 待添加的数据项
*/
pushData(data: T): void {
this.dataArray.push(data);
const addIndex = this.dataArray.length - 1;
this.notifyDataAdd(addIndex);
}
/**
* 批量添加数据到列表末尾
* @param dataList 待添加的数据集合
*/
pushDataList(dataList: T[]): void {
const startIndex = this.dataArray.length;
this.dataArray.push(...dataList);
this.notifyDatasetChange([{
type: DataOperationType.ADD,
index: startIndex,
count: dataList.length
}]);
}
/**
* 根据索引删除单条数据
* @param index 待删除数据的索引
*/
deleteData(index: number): void {
if (index < 0 || index >= this.dataArray.length) return;
this.dataArray.splice(index, 1);
this.notifyDataDelete(index)
}
/**
* 更新指定索引的数据
* @param index 待更新的数据索引
* @param newData 新数据
*/
updateData(index: number, newData: T): void {
if (index < 0 || index >= this.dataArray.length) return;
this.dataArray[index] = newData;
this.notifyDataChange(index);
}
/**
* 移动数据位置
* @param from 原索引
* @param to 目标索引
*/
moveData(from: number, to: number): void {
if (from < 0 || from >= this.dataArray.length || to < 0 || to >= this.dataArray.length) return;
const items = this.dataArray.splice(from, 1);
this.dataArray.splice(to, 0, ...items);
this.notifyDataMove(from, to);
}
/**
* 全量替换数据源并刷新列表
* @param newDataList 新数据源
*/
reloadData(newDataList: T[]): void {
this.dataArray = newDataList;
this.notifyDataReload();
}
}
5.3 懒加载业务数据源:NoteDataSource.ets
import { NoteCardModel } from '../model/NoteCardModel';
import { BaseDataSource } from './BaseDataSource';
// 继承BaseDataSource
export class NoteDataSource extends BaseDataSource<NoteCardModel> {
private data: NoteCardModel[] = [];
// 获取数据总数
override totalCount(): number {
return this.data.length;
}
// 根据索引获取数据
override getData(index: number): NoteCardModel {
return this.data[index];
}
// 初始化数据
initializeData(data: NoteCardModel[]): void {
this.data = [...data];
// 通知UI刷新
this.notifyDataReload();
}
// 追加数据(下拉加载更多)
addData(data: NoteCardModel[]): void {
this.data.push(...data);
// 通知UI追加数据
this.notifyDatasetChange([{type:DataOperationType.ADD,index:this.data.length - data.length,count:data.length}]);
}
// 清空数据(下拉刷新)
clearData(): void {
this.data = [];
this.notifyDataReload();
}
}
5.4 笔记卡片组件:NoteCard.ets
import { NoteCardModel } from "../model/NoteCardModel";
@Reusable
@Component
export struct NoteCard {
@ObjectLink note: NoteCardModel;
aboutToReuse(params: Record<string, Object | null | undefined>): void {
}
build() {
Column({ space: 6 }) {
// 笔记封面图
Image(this.note.coverUrl)
.width('100%')
.backgroundColor('#F0F0F0')
.constraintSize({minHeight:150,maxHeight:250})
.borderRadius({ topLeft: 8, topRight: 8 })
Text(this.note.title)
.fontSize(14)
.fontColor('#666')
.textOverflow({overflow:TextOverflow.Clip})
.maxLines(2)
.padding(5)
// 作者信息 + 点赞
Row() {
// 左侧:头像 + 昵称
Row() {
Text(this.note.authorName[0] || '')
.fontSize(12)
.fontColor($r('sys.color.comp_background_list_card'))
.width(20)
.height(20)
.borderRadius(10)
.backgroundColor('#FF2442')
.textAlign(TextAlign.Center)
Text(this.note.authorName)
.fontSize(12)
.fontColor('#666')
.margin({ left: 15 })
}
.layoutWeight(1)
// 右侧:点赞按钮
Button({stateEffect:true,type: ButtonType.Normal }) {
Row({ space: 2 }) {
Image(this.note.isLike ? $r('app.media.like_selected') : $r('app.media.like_normal'))
.objectFit(ImageFit.Contain)
.width(22)
.height(22)
Text(this.formatLikeCount(this.note.likeCount))
.fontSize(12)
.fontColor('#666')
}
}
.backgroundColor(Color.Transparent)
.border({ width: 0 })
.onClick(() => {
this.note.isLike = !this.note.isLike;
this.note.likeCount += this.note.isLike ? 1 : -1;
})
}
.width('100%')
.padding({ left: 8, right: 8, bottom: 8 })
}
.width('100%')
.backgroundColor($r('sys.color.comp_background_list_card'))
.borderRadius(8)
.alignItems(HorizontalAlign.Start)
.justifyContent(FlexAlign.Start)
}
// 格式化点赞数
private formatLikeCount(count: number): string {
if (count >= 10000) {
return (count / 10000).toFixed(1) + 'w';
}
return count.toString();
}
}
5.5 小红书首页:Index.ets
import { NoteCard } from '../components/NoteCard';
import { NoteCardModel } from '../model/NoteCardModel';
import { NoteDataSource } from '../datasource/NoteDataSource';
import { util } from '@kit.ArkTS';
@Entry
@Component
struct Index {
// 加载状态
@State isLoading: boolean = false;
// 滚动控制器
private scroller: Scroller = new Scroller();
// 懒加载数据源
private dataSource: NoteDataSource = new NoteDataSource();
// 页码
private page: number = 1;
aboutToAppear(): void {
this.initData();
}
// 初始化数据
initData(): void {
const mockData = [
new NoteCardModel(util.generateRandomUUID(true), "https://s41.ax1x.com/2026/04/01/peGZWhn.png", "奇趣玩家", "", 13, "春日氛围感穿搭指南,带你一起走进三月"),
new NoteCardModel(util.generateRandomUUID(true), "https://s41.ax1x.com/2026/04/01/peGZRts.png", "爱吃小丸子.", "", 8, "零失败家常美食教程"),
new NoteCardModel(util.generateRandomUUID(true), "https://s41.ax1x.com/2026/04/01/peGZ60g.png", "反派不探险", "", 4399, "超实用小众旅行攻略"),
new NoteCardModel(util.generateRandomUUID(true), "https://s41.ax1x.com/2026/04/01/peGZhpq.png", "Andme", "", 50, "高效实用学习笔记分享"),
];
this.dataSource.initializeData(mockData);
}
// 加载更多数据
loadMoreData(): void {
if (this.isLoading) return;
this.isLoading = true;
setTimeout(() => {
const newData: NoteCardModel[] = [];
for (let i = 0; i < 4; i++) {
newData.push(new NoteCardModel(
util.generateRandomUUID(true),
`https://picsum.photos/300/${Math.floor(Math.random() * 300) + 400}`,
`用户${this.page++}`,
"",
Math.floor(Math.random() * 1000),
`LazyForEach懒加载笔记 ${this.page}`
));
}
// 追加数据
this.dataSource.addData(newData);
this.isLoading = false;
}, 1500);
}
// 底部加载组件
@Builder
loadFooter() {
if (this.isLoading) {
Row({ space: 8 }) {
LoadingProgress().color('#FF2442').width(24).height(24)
Text('正在加载更多笔记...')
.fontSize(14)
.fontColor('#999')
}
.width('100%')
.height(60)
.justifyContent(FlexAlign.Center)
}
}
build() {
Column() {
// 顶部导航栏
Row() {
Text("小红书")
.fontSize(20)
.fontWeight(FontWeight.Bold)
.fontColor('#FF2442')
}
.width('100%')
.height(50)
.justifyContent(FlexAlign.Center)
.backgroundColor(Color.White)
WaterFlow({
footer: this.loadFooter,
scroller: this.scroller,
layoutMode: WaterFlowLayoutMode.SLIDING_WINDOW
}) {
LazyForEach(this.dataSource, (note: NoteCardModel) => {
FlowItem() {
NoteCard({ note: note })
}.width('100%') // 这里必须设置宽度'100%',否则笔记奇数情况下 宽度会自适应到屏幕宽度。
}, (note: NoteCardModel) => note.noteId)
}
.columnsTemplate('1fr 1fr')
.columnsGap(8)
.rowsGap(12)
.width('100%')
.layoutWeight(1)
.cachedCount(5)
.edgeEffect(EdgeEffect.Spring)// 弹性滚动
.backgroundColor('#F5F5F5')
.padding(8)
// 滚动预加载
.onScrollIndex((first: number, last: number) => {
if (last >= this.dataSource.totalCount() - 3) {
this.loadMoreData();
}
})
// 触底兜底加载
.onReachEnd(() => {
this.loadMoreData();
})
}
.width('100%')
.height('100%')
.backgroundColor('#F5F5F5')
}
}
运行效果

六、最佳实践
- LazyForEach:长列表推荐DataSource+LazyForEach
- 滑动窗口模式:
layoutMode(WaterFlowLayoutMode.SLIDING_WINDOW)必开 - 组件复用:卡片添加
@Reusable,性能提升50%以上 - FlowItem 不设置宽度导致撑满屏幕 必须设置 width: 100% 让其自适应列宽可根据具体需求设置。
七、仓库代码
- 工程名称:WaterFlowDemo
- 仓库地址:https://gitee.com/HarmonyOS-UI-Basics/harmony-os-ui-basics.git
八、下节预告
下一节我们将正式学习鸿蒙轮播组件 Swiper 全解析,掌握页面轮播、广告Banner、卡片滑动的标准化开发:
- 掌握 Swiper 基础用法、尺寸约束与父子布局规则
- 实现循环播放、自动轮播、轮播方向、每页多子页等核心配置
- 自定义导航点样式、箭头样式、间距与位置,打造高质感轮播效果
- 学习 Swiper 控制器与页面切换动画,实现丝滑滑动体验
- 掌握 Swiper 与 Tabs 联动、数据懒加载兼容、视图位置保持等高级特性
更多推荐
所有评论(0)