HarmonyOS 6.0 卡片式信息流与瀑布流布局

刷微博、刷小红书、刷淘宝------这些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(平板/折叠屏)用三列,手机用两列。

卡片高度问题

瀑布流的核心难题是卡片高度不一致。有两种策略:

  1. 固定高度模式:所有卡片等高,只是内容区高度不同
  2. 动态高度模式:每个卡片高度由内容决定,真正的不规则排列

固定高度最简单:

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把新项放到最短列,不需要你手动计算哪列该放什么。你的工作是提供正确的数据(含预估高度)和响应式的列数配置。其余的交给组件。

相关推荐
luoyayun3614 小时前
【Qt for Harmony】 Qt 5.12.12 鸿蒙版 Windows 交叉编译
qt·harmonyos·qt for harmony
木木子225 小时前
# 鸿蒙 HarmonyOS 应用开发实战(第25期)|骰子(Dice Roller)— Unicode 符号与动画渲染精讲
华为·harmonyos
在水一缸5 小时前
从华为“山海经”商标布局,看技术品牌的命名体系与架构设计
华为·技术架构·商标命名·品牌设计·山海经
qizayaoshuap5 小时前
# HarmonyOS ArkTS 记忆翻牌游戏深度解析 —— Fisher-Yates 洗牌与翻牌匹配机制
游戏·华为·harmonyos
youtootech6 小时前
HarmonyOS 6.0 自定义绘图表盘与仪表
华为·harmonyos
yaoyaoxingzhe16 小时前
HarmonyOS应用《玄象》开发实战:Canvas 绘制星宿连线:moveTo / lineTo / stroke 路径绘制
harmonyos·鸿蒙
echohelloworld1120 小时前
HarmonyOS应用开发实战:猫猫大作战-springMotion 三参数、responsiveSpringMotion 响应式弹簧、与 EaseO
harmonyos·鸿蒙
达子6661 天前
第13章_HarmonyOs开发图解 视频
华为·音视频·harmonyos