刷微博、刷小红书、刷淘宝------这些App的信息流都不是整齐的列表,而是高度不等的卡片交错排列。这就是瀑布流(Waterfall Layout)。HarmonyOS的WaterFlow组件就是为这个场景设计的,但它跟List的用法差别不小------数据源要IDataSourceAdapter,layoutMode要配置,卡片高度要预计算。这篇把瀑布流从布局到数据到交互的完整方案讲清楚。
WaterFlow基础
最简单的瀑布流:
typescript
WaterFlow() {
ForEach(this.items, (item: CardItem) => {
FlowItem() {
Column() {
Image(item.coverUrl)
.width('100%')
.height(item.coverHeight)
.objectFit(ImageFit.Cover)
Text(item.title)
.fontSize(14)
.fontColor('#333333')
.maxLines(2)
.padding(8)
}
.backgroundColor(Color.White)
.borderRadius(8)
}
}, (item: CardItem) => item.id)
}
.columnsTemplate('1fr 1fr')
.columnsGap(8)
.rowsGap(8)
.padding({ left: 8, right: 8 })
columnsTemplate='1fr 1fr'表示两列等宽。FlowItem是瀑布流的子项,跟ListItem类似。
关键区别:WaterFlow跟List不同,FlowItem的高度可以不同。 List中所有ListItem高度一致(或者动态计算),WaterFlow中每个FlowItem高度可以不一样,系统自动把新项放到最短的那列。
列数配置
columnsTemplate控制列数和宽度比例:
| Template | 效果 |
|---|---|
| '1fr 1fr' | 两列等宽 |
| '1fr 1fr 1fr' | 三列等宽 |
| '1fr 2fr' | 两列,右侧宽一倍 |
| '1fr 1fr 1fr 1fr' | 四列等宽 |
响应式列数------根据屏幕宽度动态切换:
typescript
@State columnsTemplate: string = '1fr 1fr'
aboutToAppear(): void {
let screenWidth = display.getDefaultDisplaySync().width;
let density = display.getDefaultDisplaySync().densityPixels;
let widthVp = screenWidth / density;
if (widthVp > 600) {
this.columnsTemplate = '1fr 1fr 1fr';
} else {
this.columnsTemplate = '1fr 1fr';
}
}
屏幕宽度超过600vp(平板/折叠屏)用三列,手机用两列。
卡片高度问题
瀑布流的核心难题是卡片高度不一致。有两种策略:
- 固定高度模式:所有卡片等高,只是内容区高度不同
- 动态高度模式:每个卡片高度由内容决定,真正的不规则排列
固定高度最简单:
typescript
FlowItem() {
Column() {
Image(item.coverUrl)
.width('100%')
.height(120)
Text(item.title)
.fontSize(14)
.padding(8)
}
}
.height(180)
所有FlowItem高180vp,图片固定120vp。简单但浪费空间------短标题的卡片有大段空白,长标题被截断。
动态高度需要预计算:
typescript
interface CardItem {
id: string;
coverUrl: string;
coverHeight: number;
title: string;
estimatedHeight: number;
}
// 在数据加载时预计算高度
private calculateEstimatedHeight(item: CardItem): number {
let imageHeight = item.coverHeight * 0.5;
let titleLines = Math.ceil(item.title.length / 14);
let titleHeight = titleLines * 20 + 16;
return imageHeight + titleHeight + 16;
}
coverHeight是图片原始高度(服务端返回),0.5是缩放比例。标题高度按每行14字、行高20vp估算。
注意:预计算高度不要求精确。 WaterFlow会根据实际布局调整位置。预计算只是给系统一个参考值,避免高度0→实际高度的闪烁。
LazyForEach数据源
大数据量用LazyForEach:
typescript
class CardDataSource implements IDataSource {
private data: CardItem[] = [];
private listeners: DataChangeListener[] = [];
totalCount(): number {
return this.data.length;
}
getData(index: number): CardItem {
return this.data[index];
}
registerDataChangeListener(listener: DataChangeListener): void {
if (this.listeners.indexOf(listener) < 0) {
this.listeners.push(listener);
}
}
unregisterDataChangeListener(listener: DataChangeListener): void {
let pos = this.listeners.indexOf(listener);
if (pos >= 0) {
this.listeners.splice(pos, 1);
}
}
pushData(item: CardItem): void {
this.data.push(item);
for (let i = 0; i < this.listeners.length; i++) {
this.listeners[i].onDataAdd(this.data.length - 1);
}
}
addData(index: number, item: CardItem): void {
this.data.splice(index, 0, item);
for (let i = 0; i < this.listeners.length; i++) {
this.listeners[i].onDataAdd(index);
}
}
}
IDataSource的实现跟List的LazyForEach一样。pushData在末尾追加(上拉加载),addData在指定位置插入。
使用:
typescript
private dataSource: CardDataSource = new CardDataSource();
WaterFlow() {
LazyForEach(this.dataSource, (item: CardItem) => {
FlowItem() {
// 卡片内容
}
}, (item: CardItem) => item.id)
}
上拉加载更多
瀑布流的上拉加载:
typescript
@State isLoadingMore: boolean = false
@State hasMore: boolean = true
WaterFlow() {
LazyForEach(this.dataSource, (item: CardItem) => {
FlowItem() {
// 卡片
}
}, (item: CardItem) => item.id)
}
.onReachEnd(() => {
if (!this.isLoadingMore && this.hasMore) {
this.loadMore();
}
})
private async loadMore(): Promise<void> {
this.isLoadingMore = true;
let newItems = await this.fetchCards(this.nextPage);
if (newItems.length === 0) {
this.hasMore = false;
} else {
for (let i = 0; i < newItems.length; i++) {
this.dataSource.pushData(newItems[i]);
}
this.nextPage++;
}
this.isLoadingMore = false;
}
onReachEnd跟List一样。pushData逐项追加,每追加一项触发一次UI更新。批量追加的优化:
typescript
// 优化:批量追加
pushDataList(items: CardItem[]): void {
let startIndex = this.data.length;
for (let i = 0; i < items.length; i++) {
this.data.push(items[i]);
}
for (let i = 0; i < this.listeners.length; i++) {
this.listeners[i].onDataReloaded();
}
}
onDataReloaded通知全量刷新,比逐项onDataAdd效率高。但会重建所有可见项,有短暂闪烁。数据量小时用onDataAdd,大数据量时用onDataReloaded。
下拉刷新
WaterFlow跟Refresh组件配合:
typescript
@State isRefreshing: boolean = false
Refresh({ refreshing: $$this.isRefreshing }) {
WaterFlow() {
LazyForEach(this.dataSource, (item: CardItem) => {
FlowItem() {
// 卡片
}
}, (item: CardItem) => item.id)
}
.columnsTemplate('1fr 1fr')
.columnsGap(8)
.rowsGap(8)
}
.onRefreshing(() => {
this.refreshData();
})
private async refreshData(): Promise<void> {
this.nextPage = 1;
this.hasMore = true;
let items = await this.fetchCards(1);
this.dataSource = new CardDataSource();
for (let i = 0; i < items.length; i++) {
this.dataSource.pushData(items[i]);
}
this.isRefreshing = false;
}
刷新时重建整个dataSource,从第一页加载。重建dataSource是必要的------旧的dataSource里的数据要清空,但IDataSource没有clear方法,不如直接new一个。
卡片点击与详情
卡片点击跳详情,详情返回后刷新该卡片:
typescript
FlowItem() {
Column() {
Image(item.coverUrl)
Text(item.title)
}
.onClick(() => {
this.navPathStack.pushPath({
name: 'CardDetail',
param: { id: item.id }
});
})
}
// 详情页返回后刷新
this.navPathStack.on('pop', (info: PopInfo) => {
if (info.param !== undefined) {
let result = info.param as Record<string, Object>;
let updatedId = result['updatedId'] as string;
this.refreshCard(updatedId);
}
})
private refreshCard(id: string): void {
let index = -1;
for (let i = 0; i < this.allItems.length; i++) {
if (this.allItems[i].id === id) {
index = i;
break;
}
}
if (index >= 0) {
this.allItems[index] = this.allItems[index]; // 触发引用变化
this.dataSource = new CardDataSource();
for (let i = 0; i < this.allItems.length; i++) {
this.dataSource.pushData(this.allItems[i]);
}
}
}
单个卡片刷新代价太大(重建整个dataSource)。更好的方式是在DataSource中添加updateData方法:
typescript
updateData(index: number, item: CardItem): void {
this.data[index] = item;
for (let i = 0; i < this.listeners.length; i++) {
this.listeners[i].onDataChange(index);
}
}
onDataChange只更新指定位置的项,不影响其他项。
标签筛选

顶部标签切换,过滤瀑布流内容:
typescript
@State selectedTag: string = '全部'
Row() {
ForEach(this.tags, (tag: string) => {
Text(tag)
.fontSize(14)
.fontColor(this.selectedTag === tag ? '#1a73e8' : '#666666')
.backgroundColor(this.selectedTag === tag ? '#e3f2fd' : '#f5f5f5')
.borderRadius(16)
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
.margin({ right: 8 })
.onClick(() => {
this.selectedTag = tag;
this.filterByTag(tag);
})
}, (tag: string) => tag)
}
.padding({ left: 12, right: 12, bottom: 8 })
private filterByTag(tag: string): void {
if (tag === '全部') {
this.currentItems = this.allItems;
} else {
this.currentItems = this.allItems.filter(item => item.tag === tag);
}
this.dataSource = new CardDataSource();
for (let i = 0; i < this.currentItems.length; i++) {
this.dataSource.pushData(this.currentItems[i]);
}
}
切换标签时重建dataSource。filter是全量过滤,小数据集没问题,大数据集建议后端分标签分页。
多类型卡片
信息流经常混合不同类型的卡片------图文卡、视频卡、广告卡:
typescript
FlowItem() {
if (item.type === 'image') {
this.imageCard(item)
} else if (item.type === 'video') {
this.videoCard(item)
} else if (item.type === 'ad') {
this.adCard(item)
}
}
@Builder
imageCard(item: CardItem) {
Column() {
Image(item.coverUrl)
.width('100%')
.height(item.coverHeight)
Text(item.title)
.padding(8)
}
.backgroundColor(Color.White)
.borderRadius(8)
}
@Builder
videoCard(item: CardItem) {
Column() {
Stack() {
Image(item.coverUrl)
.width('100%')
.height(160)
Image($r('sys.media.ohos_ic_public_play'))
.width(40)
.height(40)
}
Text(item.title)
.padding(8)
}
.backgroundColor(Color.White)
.borderRadius(8)
}
不同类型卡片的高度和布局完全不同。type字段决定用哪个Builder。视频卡固定高度160vp+播放按钮覆盖层。
踩坑清单
| 问题 | 原因 | 解决 |
|---|---|---|
| 卡片高度不一致但排成等高 | columnsTemplate没配 | WaterFlow会自动不等高,检查FlowItem |
| 图片加载后卡片跳变 | 没预计算高度 | 数据中带estimatedHeight |
| LazyForEach不更新 | IDataSource没通知 | pushData后调onDataAdd |
| 下拉刷新后数据重叠 | 没清空旧dataSource | 刷新时new新dataSource |
| 单项更新太重 | 重建整个dataSource | 用onDataChange只更新一项 |
| 卡片间距不均匀 | rowsGap/columnsGap太小 | 8~12vp间距合适 |
| 多列卡片宽高比变形 | 图片objectFit没设 | 用ImageFit.Cover |
| 上拉加载重复触发 | 没用isLoadingMore防重 | 加布尔锁 |
| 标签切换卡顿 | 全量过滤+重建 | 小数据量OK,大用后端过滤 |
| 滚动位置记忆失败 | 刷新重建了dataSource | 保存firstVisibleId |
瀑布流的核心是"不等高卡片自动排列"------WaterFlow把新项放到最短列,不需要你手动计算哪列该放什么。你的工作是提供正确的数据(含预估高度)和响应式的列数配置。其余的交给组件。