HarmonyOS社交通讯应用开发 42 :瀑布流 WaterFlow 详解
瀑布流 WaterFlow 详解
引言
浏览页的第一个 Tab 是内容流,它的核心组件是 ArkUI 的 WaterFlow——一个典型的"瀑布流"布局容器:多列并行向下铺展,每列高度独立增长,图片卡片按顺序填入当前最矮的列,形成错落有致的浏览体验。与小红书、淘宝首页等主流内容 App 的呈现方式一致。

在《ContinuePublish》中,瀑布流组件实现在 entry/src/main/ets/view/contentBrowse/WaterFlowContentComponent.ets。这个文件虽然只有一百多行,却浓缩了瀑布流开发的大部分要点:列数断点适配、Footer 加载态、触底回调、惯性滑动限速、预加载缓存、嵌套滚动联动等。本文逐一拆解这些 API 在本项目中的真实用法。
知识点讲解
1. WaterFlow 与 FlowItem
WaterFlow 是 ArkUI 提供的瀑布流容器,语法与 List、Grid 同源:外层 WaterFlow 定义布局规则,内层 FlowItem 包裹每个条目。它的核心特性是列内高度自适应:同一个 FlowItem 在不同列中因为内容高度不同(本项目是图片宽高比不同),会形成长短不一的列。WaterFlow 自动把新条目放入当前最短的列,保证整体视觉均衡。
2. columnsTemplate:列模板
columnsTemplate 决定列的数量与宽度比例,取值类似 Grid 的 '1fr 1fr' 这样的 fr 模板字符串。这里出现的 1fr 是 CSS Grid 同源的"弹性轨道"语法:fr 是分数单位,'1fr 1fr' 表示两列等宽、各占可用宽度的 1/2;'1fr 1fr 1fr 1fr 1fr' 表示五列等宽。之所以用模板字符串而不是一个简单的数字,是因为它可以表达"非等宽"的列——比如 '1fr 2fr' 会让第二列是第一列的两倍宽。本项目所有列等宽,但语法本身预留了更多可能性。本项目通过 BreakpointType(见 entry/src/main/ets/utils/BreakpointSystem.ets)按断点取不同列数。
2.1 与 List、Grid 的选型对比
同样是滚动列表,为什么这里选 WaterFlow 而不是 List 或 Grid?三者各司其职:
List:单列线性布局,每个条目宽度一致、高度自由,适合聊天记录、设置页这类"上下排列"的内容;Grid:多列网格,列数与行数固定,每个格子尺寸统一(或按行列模板等分),适合图标宫格、照片墙;WaterFlow:多列但每列独立流动,条目高度可以千差万别,框架自动把新条目放入当前高度最短的列,形成错落有致的"瀑布"效果。
判断标准很简单:如果卡片高度不齐、希望"填坑式"排列,就用 WaterFlow。本项目卡片高度由图片宽高比决定(448×335 的矮卡与 257×257 的方卡交替出现),正是 WaterFlow 的典型适用场景。
3. footer:尾部自定义内容
WaterFlow 支持通过 footer 参数传入一个 @Builder,渲染在列表末尾。常用于"加载中"、"到底了"之类的状态提示。本项目用一个 FooterState 枚举(定义在 entry/src/main/ets/viewmodel/FooterTabData.ets)驱动两种尾部状态:
export enum FooterState {
Loading = 0,
End = 1
}
4. onReachEnd 与数据追加
onReachEnd 在滚动到达底部时触发,是"加载更多"的标准钩子。本项目数据是本地 Mock 的,所以触底后的"加载"逻辑简化为判断是否达到上限。
5. 滑动与嵌套相关参数
flingSpeedLimit:限制惯性滑动(fling)的最大速度,避免快速甩动时画面跳跃过大;cachedCount:设置缓存节点数量,超出可视区一定数量的节点会预创建缓存,滚动回看时无需重新构建组件;nestedScroll:配置与父容器的嵌套滚动联动策略;restoreId:给滚动容器一个恢复 ID,配合系统"状态恢复"能力,页面重建时能恢复滚动位置。
结合本项目源码分析
1. 组件骨架与数据入口
WaterFlowContentComponent 的核心 build 如下:
build() {
Column({ space: CommonConstants.SPACE_EIGHT }) {
Column() {
WaterFlow({ footer: this.footer, scroller: this.waterFlowScroller }) {
LazyForEach(this.waterFlowListData.getData(), (item: WaterFlowData) => {
FlowItem() {
WaterFlowImageView({
source: item.waterFlowHead.source,
title: item.waterFlowDescription.title,
titleEn: item.waterFlowDescription.titleEn,
userImage: item.waterFlowDescription.userImage,
userName: item.waterFlowDescription.userName,
collectionsCount: item.waterFlowDescription.collectionsCount,
waterFlowItemWidth: this.waterFlowItemWidth,
imageWidth: item.waterFlowHead.width,
imageHeight: item.waterFlowHead.height,
detailPageUrl: item.detailPageUrl,
index: item.waterFlowDescription.index
})
.reuseId(CommonConstants.WATER_FLOW_IMAGE_REUSE_ID);
}
.backgroundColor(Color.White)
.width(CommonConstants.FULL_PERCENT)
.clip(true)
.borderRadius($r('app.integer.border_radius16'))
.onAppear(() => {
this.listDataCount = this.waterFlowListData.dataSource.totalCount();
})
}, (item: WaterFlowData) => {
return item.waterFlowDescription.index.toString();
})
}
// ...
}
}
}
几个要点:
WaterFlow({ footer: this.footer, scroller: this.waterFlowScroller }):footer传入 Builder(下文详述),scroller传入外部Scroller,供ContentBrowsePage的bindToScrollable联动标题栏使用;- 条目组件是
WaterFlowImageView(entry/src/main/ets/view/contentBrowse/WaterFlowImageView.ets),数据来自waterFlowListData的BasicDataSource(entry/src/main/ets/viewmodel/WaterFlowListData.ets),每个WaterFlowData包含waterFlowHead(图片路径与宽高)、waterFlowDescription(标题、作者、收藏数)和detailPageUrl; FlowItem本身是白底卡片:backgroundColor(Color.White)、clip(true)裁剪圆角内容、borderRadius(16);圆角图片 + 白底是典型的卡片式内容流设计;- LazyForEach 的 keyGenerator 使用
item.waterFlowDescription.index.toString(),保证每张卡片的 key 唯一且稳定(详见下一篇文章); .onAppear()在条目出现时把当前数据总数同步给listDataCount,这只是示例工程中用于展示"已加载条数"的状态同步。
2. 列数断点:2/2/3/5 列
.columnsTemplate(new BreakpointType(
BreakpointConstants.GRID_NUM_TWO,
BreakpointConstants.GRID_NUM_TWO,
BreakpointConstants.GRID_NUM_THREE,
BreakpointConstants.GRID_NUM_FIVE
).getValue(this.currentBreakpoint))
.columnsGap($r('app.integer.water_flow_column_gap'))
.rowsGap($r('app.integer.water_flow_row_gap'))
BreakpointType 的四个参数依次对应 XS、SM、MD、LG 四个断点(entry/src/main/ets/constants/BreakpointConstants.ets):
public static readonly GRID_NUM_TWO: string = '1fr 1fr';
public static readonly GRID_NUM_THREE: string = '1fr 1fr 1fr';
public static readonly GRID_NUM_FIVE: string = '1fr 1fr 1fr 1fr 1fr';
即手机(XS/SM,宽度小于 600vp)显示 2 列,平板(MD,600~840vp)显示 3 列,大屏/2in1(LG,≥840vp)显示 5 列。columnsGap 与 rowsGap 引用资源 water_flow_column_gap / water_flow_row_gap,控制列间距与行间距。
currentBreakpoint 是 @StorageLink(BreakpointConstants.BREAKPOINT_NAME) 绑定的全局断点(在 BreakpointSystem.ets 的 updateBreakpoint() 中按窗口宽度 320/600/840vp 计算并写入 AppStorage)。窗口尺寸变化 → 断点变化 → columnsTemplate 重新取值 → 瀑布流自动重排,这就是"一多适配"在布局层的落地。
3. 整屏外边距随断点变化
瀑布流区域左右外边距同样跟随断点:
.margin({
left: new BreakpointType(
BreakpointConstants.SEARCHBAR_AND_WATER_FLOW_MARGIN_LEFT_SM,
BreakpointConstants.SEARCHBAR_AND_WATER_FLOW_MARGIN_LEFT_SM,
BreakpointConstants.SEARCHBAR_AND_WATER_FLOW_MARGIN_LEFT_MD,
BreakpointConstants.SEARCHBAR_AND_WATER_FLOW_MARGIN_LEFT_LG
).getValue(this.currentBreakpoint),
right: /* 对称的右外边距 */
})
.animation({
duration: CommonConstants.ANIMATION_DURATION_TIME,
curve: Curve.EaseOut,
playMode: PlayMode.Normal
})
SEARCHBAR_AND_WATER_FLOW_MARGIN_* 在 BreakpointConstants.ets 中定义为 16 / 24 / 32(sm/md/lg),即屏幕越大留白越多。外层还挂了 .animation({ duration: 300, curve: Curve.EaseOut }),断点切换时外边距变化带 300ms 缓动动画,视觉上更柔和——这是小屏转大屏(如折叠屏展开)时常用的过渡手法。
4. footer:Loading 与 End 双态
@Builder
footer() {
Row() {
if (this.footerState === FooterState.End) {
Text($r('app.string.footer_text_max_count'))
.fontWeight(CommonConstants.TEXT_FONT_WEIGHT_400)
.fontSize($r('app.integer.font_size_14'))
.height($r('app.integer.text_height'))
.margin({ bottom: $r('app.integer.margin_10') })
.opacity($r('app.float.opacity_percent_40'))
} else if (this.footerState === FooterState.Loading) {
Row() {
LoadingProgress()
.color(Color.Black)
.opacity($r('app.float.opacity_percent_60'))
.width($r('app.integer.loading_width'))
.height($r('app.integer.loading_height'))
Text($r('app.string.footer_text_loading'))
.fontWeight(CommonConstants.TEXT_FONT_WEIGHT_400)
.fontSize($r('app.integer.font_size_14'))
.height($r('app.integer.text_height'))
.opacity($r('app.float.opacity_percent_40'))
}
.height($r('app.integer.loading_height'))
.width(CommonConstants.FULL_PERCENT)
.alignItems(VerticalAlign.Center)
.justifyContent(FlexAlign.Center)
}
}
.width(CommonConstants.FULL_PERCENT)
.height(CommonConstants.TWENTY_PERCENT)
.margin({ bottom: $r('app.integer.margin_30') })
.alignItems(VerticalAlign.Bottom)
.justifyContent(FlexAlign.Center)
}
Loading态:LoadingProgress()转圈动画 + "加载中"文字,居中显示;End态:一行"已加载全部内容"提示(footer_text_max_count字符串资源),40% 透明度弱化视觉;- footer 容器高度固定为视口 20%(
TWENTY_PERCENT)、内容靠底部对齐(VerticalAlign.Bottom),保证两种状态下整体高度一致,切换状态时瀑布流不会跳动。
初始 footerState 为 FooterState.Loading。数据不足 20 条时始终显示"加载中",达到上限后显示"已加载全部内容"。
5. 触底判断与加载上限
.onReachEnd(() => {
if (this.waterFlowListData.dataSource.totalCount() >= CommonConstants.WATER_FLOW_MAX_COUNT) {
this.footerState = FooterState.End;
return;
}
})
dataSource.totalCount()是BasicDataSource实现的IDataSource方法,返回当前数据条数;WATER_FLOW_MAX_COUNT定义在entry/src/main/ets/constants/CommonConstants.ets:public static readonly WATER_FLOW_MAX_COUNT: number = 20;- 数据只有 20 条,且构造时已一次性
addData完毕(见WaterFlowListData构造函数),所以触底必然命中>= 20分支,footer 切换为 End 态。这里的 onReachEnd 是"加载更多"的标准写法骨架:真实项目中把if内的逻辑换成"请求下一页数据 →addDataArray()追加 → 刷新 footer 状态"即可。
5.1 从 Mock 到真实分页:onReachEnd 的最小改造
既然项目把加载上限设成了 20 条,那真实的分页场景该如何演进?只需三步:
- 删除
if中totalCount() >= WATER_FLOW_MAX_COUNT的判断,改为直接发起网络请求(或读取下一页本地数据); - 请求成功后调用
BasicDataSource.addDataArray(newItems),数据源内部会concat追加并调用notifyDataAdd(len)通知 LazyForEach 增量插入——框架只新增条目,不会重建已有条目; - 根据返回结果把
footerState置为Loading(还有下一页)或End(没有更多)。
WATER_FLOW_MAX_COUNT 这类常量定义在 entry/src/main/ets/constants/CommonConstants.ets,与组件逻辑完全解耦,改阈值只需动常量。这套骨架对任何"上拉加载"列表都成立,区别仅在于数据来源。
6. 性能与滚动细节
.restoreId(1)
.flingSpeedLimit(4800)
.cachedCount(CommonConstants.WATER_FLOW_CACHED_COUNT)
.nestedScroll({ scrollForward: NestedScrollMode.PARENT_FIRST, scrollBackward: NestedScrollMode.SELF_FIRST })
restoreId(1):给滚动容器注册恢复 ID,应用被系统回收重建(如接续、配置变化)时,框架尝试恢复滚动位置;flingSpeedLimit(4800):把惯性滑动最大速度限制在 4800vp/s,防止图片流在快速甩动时"飞"出控制;cachedCount(10):WATER_FLOW_CACHED_COUNT为 10,即视口外上下各预缓存 10 个条目,配合 LazyForEach 与组件复用(下一篇详述),滚动回看几乎无重建开销;nestedScroll({ scrollForward: PARENT_FIRST, scrollBackward: SELF_FIRST }):向下滚动时父级(外层滚动容器)优先消费手势,向上滚动时自身优先。虽然本项目瀑布流外层没有真实滚动父容器,但该配置为将来嵌套滚动预留了正确语义。
7. itemConstraintSize:约束条目尺寸
.itemConstraintSize({
minWidth: CommonConstants.ZERO_PERCENT,
maxWidth: CommonConstants.FULL_PERCENT,
minHeight: CommonConstants.ZERO_PERCENT
})
itemConstraintSize 约束每个 FlowItem 的尺寸范围:宽度在 0%~100% 之间自适应(即列宽),高度最小为 0(允许图片按 aspectRatio 压缩)。这保证了条目完全跟随列宽与图片宽高比布局,不会出现溢出或固定高度。
8. layoutDirection 与整条配置链
配置链的末尾还有一个容易忽略的 .layoutDirection(FlexDirection.Column)。layoutDirection 决定瀑布流的生长方向:FlexDirection.Column 表示条目自上而下逐列填充(纵向瀑布流,也是内容流最常见的形态);如果设置为 FlexDirection.Row,则变为横向瀑布流(如横向滚动的卡片画廊)。本项目使用 Column 方向,配合 rowsGap / columnsGap 的间距设置,20 张卡片在 2/3/5 列下都能自动排布。把整个 WaterFlow 的配置链连起来看:columnsTemplate 定列数、columnsGap/rowsGap 定间距、layoutDirection 定流向、itemConstraintSize 定条目尺寸边界、footer 定尾部、onReachEnd 定触底行为——每个属性都只回答一个问题,组合起来就是一套完整的瀑布流方案。
小结
WaterFlowContentComponent.ets 虽然短小,却是瀑布流开发的完整范式:
- 布局:
columnsTemplate + BreakpointType实现 2/2/3/5 列断点切换,columnsGap/rowsGap控制间距,itemConstraintSize约束条目尺寸; - 加载态:
footerBuilder 配合FooterState枚举切换 Loading/End,onReachEnd判断是否达到WATER_FLOW_MAX_COUNT上限; - 性能:
cachedCount(10)预缓存、flingSpeedLimit(4800)限速、restoreId(1)状态恢复; - 协同:
nestedScroll配置嵌套滚动语义,scroller外传供标题栏bindToScrollable联动。
下一篇文章聚焦瀑布流的另一条主线——LazyForEach 数据懒加载,深入 BasicDataSource 的四个接口方法与数据变更通知机制。
更多推荐



所有评论(0)