瀑布流 WaterFlow 详解

引言

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

img

在《ContinuePublish》中,瀑布流组件实现在 entry/src/main/ets/view/contentBrowse/WaterFlowContentComponent.ets。这个文件虽然只有一百多行,却浓缩了瀑布流开发的大部分要点:列数断点适配、Footer 加载态、触底回调、惯性滑动限速、预加载缓存、嵌套滚动联动等。本文逐一拆解这些 API 在本项目中的真实用法。

知识点讲解

1. WaterFlow 与 FlowItem

WaterFlow 是 ArkUI 提供的瀑布流容器,语法与 ListGrid 同源:外层 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 而不是 ListGrid?三者各司其职:

  • 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,供 ContentBrowsePagebindToScrollable 联动标题栏使用;
  • 条目组件是 WaterFlowImageViewentry/src/main/ets/view/contentBrowse/WaterFlowImageView.ets),数据来自 waterFlowListDataBasicDataSourceentry/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 列。columnsGaprowsGap 引用资源 water_flow_column_gap / water_flow_row_gap,控制列间距与行间距。

currentBreakpoint@StorageLink(BreakpointConstants.BREAKPOINT_NAME) 绑定的全局断点(在 BreakpointSystem.etsupdateBreakpoint() 中按窗口宽度 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),保证两种状态下整体高度一致,切换状态时瀑布流不会跳动。

初始 footerStateFooterState.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.etspublic static readonly WATER_FLOW_MAX_COUNT: number = 20;
  • 数据只有 20 条,且构造时已一次性 addData 完毕(见 WaterFlowListData 构造函数),所以触底必然命中 >= 20 分支,footer 切换为 End 态。这里的 onReachEnd 是"加载更多"的标准写法骨架:真实项目中把 if 内的逻辑换成"请求下一页数据 → addDataArray() 追加 → 刷新 footer 状态"即可。

5.1 从 Mock 到真实分页:onReachEnd 的最小改造

既然项目把加载上限设成了 20 条,那真实的分页场景该如何演进?只需三步:

  1. 删除 iftotalCount() >= WATER_FLOW_MAX_COUNT 的判断,改为直接发起网络请求(或读取下一页本地数据);
  2. 请求成功后调用 BasicDataSource.addDataArray(newItems),数据源内部会 concat 追加并调用 notifyDataAdd(len) 通知 LazyForEach 增量插入——框架只新增条目,不会重建已有条目
  3. 根据返回结果把 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 虽然短小,却是瀑布流开发的完整范式:

  1. 布局columnsTemplate + BreakpointType 实现 2/2/3/5 列断点切换,columnsGap/rowsGap 控制间距,itemConstraintSize 约束条目尺寸;
  2. 加载态footer Builder 配合 FooterState 枚举切换 Loading/End,onReachEnd 判断是否达到 WATER_FLOW_MAX_COUNT 上限;
  3. 性能cachedCount(10) 预缓存、flingSpeedLimit(4800) 限速、restoreId(1) 状态恢复;
  4. 协同nestedScroll 配置嵌套滚动语义,scroller 外传供标题栏 bindToScrollable 联动。

下一篇文章聚焦瀑布流的另一条主线——LazyForEach 数据懒加载,深入 BasicDataSource 的四个接口方法与数据变更通知机制。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐