【共创季稿事节】鸿蒙原生 ArkTS 布局实战:用 Flex + FlexWrap 实现瀑布流布局雏形

鸿蒙原生 ArkTS 布局实战:用 Flex + FlexWrap 实现瀑布流布局雏形


一、前言

1.1 写作背景

鸿蒙原生应用开发(HarmonyOS NEXT)自全面推出以来,其 ArkTS 声明式 UI 框架受到了广大开发者的关注和青睐。然而,网上关于 ArkTS 布局实践的中文资料仍然相对稀缺,尤其是涉及到「瀑布流布局」这类综合性较强的场景时,很多开发者只能参考 Android 或 iOS 的实践经验,再自行摸索鸿蒙上的实现方式。

笔者在实际项目开发中深入研究并实践了多种 ArkTS 布局方案,本篇文章将重点分享如何利用鸿蒙原生的 Flex 容器配合 FlexWrap.Wrap 换行特性,以极简的代码量实现一个瀑布流布局的完整雏形

1.2 什么是瀑布流布局

瀑布流(Waterfall Flow / Masonry Layout)是一种多栏错落排列的内容展示布局方式。它的名字源自其视觉特征------像瀑布一样从上往下流淌,高低起伏,错落有致。这一布局最早由 Pinterest 图片社交平台大规模推广使用,随后被各大互联网产品广泛采用。

瀑布流的核心特征包括:

  • 每个内容卡片的宽度保持一致(按列固定)
  • 每个卡片的高度各不相同(由内容多少或图片比例决定)
  • 卡片从左到右、从上到下依次排列
  • 排满一列宽度后自动折行到下一列开始位置
  • 视觉上呈现「高低起伏、参差有致」的自然错落感

1.3 瀑布流的应用场景

瀑布流布局因其视觉冲击力强、信息密度高、浏览体验流畅等优点,在以下场景中应用非常广泛:

应用场景 代表产品 布局特点
图片社区 Pinterest、花瓣网 图片比例各异,天然适合错落排列
电商推荐 淘宝、拼多多 商品图 + 标题 + 价格,卡片高度不一
社交动态 Instagram、小红书 图文混排,每篇内容长度不同
笔记/博客 Notion、简书 文章摘要长度不等,形成自然高度差
标签云 各种兴趣推荐 标签文字长度 + 字号不同

1.4 本文目标

通过本文,你将学到:

  1. 鸿蒙 ArkTS 中 Flex 容器的核心属性与工作原理
  2. FlexWrap.Wrap 换行机制的原理与使用方法
  3. 如何通过「固定宽度 + 不定高卡片 + 自动换行」模拟瀑布流布局
  4. 完整的项目代码逐段拆解与设计思路
  5. 组件化封装的最佳实践(@Component + @Prop
  6. 常见编译错误的排查与修复方法
  7. 从「伪瀑布流」到「严格 Masonry 布局」的进化路线

二、鸿蒙 ArkTS 布局体系全景

2.1 ArkTS 简介

ArkTS 是鸿蒙原生应用的主力开发语言,基于 TypeScript 语法扩展而来,同时兼容 JavaScript 运行时生态。它最大的特点是采用 声明式(Declarative)UI 范式------开发者只需要描述 UI 在任意状态下的「样子」,框架会自动计算出从当前状态到目标状态的最小更新代价。

一个典型的 ArkTS 页面骨架如下:

typescript 复制代码
@Entry
@Component
struct MyPage {
  @State message: string = 'Hello HarmonyOS';

  build() {
    // 在这里以声明方式描述 UI 树
    Column() {
      Text(this.message)
        .fontSize(24)
        .fontWeight(FontWeight.Bold)
      Button('点击更新')
        .onClick(() => {
          this.message = '欢迎来到鸿蒙世界';
        })
    }
    .width('100%')
    .height('100%')
  }
}

关键装饰器和结构说明:

  • @Entry:标记该组件为页面入口,每个 HAP 包中只能有一个 @Entry 组件
  • @Component:声明这是一个可复用的自定义组件
  • @State:声明一个响应式状态变量,当其值变化时,引用该变量的 UI 会自动刷新
  • build():组件的 UI 构建方法,在初始化时会执行一次,之后仅当依赖的状态变量变化时按需重新执行

2.2 ArkUI 布局容器全景图

鸿蒙 ArkUI 框架提供了丰富多样的布局容器,每种容器有其特定的适用场景和布局算法。以下是对比表:

容器名称 布局方向 是否支持换行 是否支持网格 典型应用
Column 纵向(单列) 列表、表单、垂直信息流
Row 横向(单行) 按钮组、导航栏、标签行
Flex 可配置(默认纵向) (FlexWrap.Wrap) 瀑布流、标签云、自适应排列
Grid 二维网格 是(网格概念) 九宫格、相册、网格列表
RelativeContainer 相对定位 精准对齐、复杂叠加布局
Stack 层叠 悬浮按钮、遮罩层、图片上的文字
List 纵向/横向列表 长列表、聊天记录、设置页
Swiper 横向滑动 轮播图、引导页

从表中可以看出,Flex 是唯一一个既支持水平排列又支持换行的一维布局容器,这正是它能用来模拟瀑布流的根本原因。

2.3 Flex 布局深入剖析

2.3.1 构造参数
typescript 复制代码
Flex(options?: FlexOptions)

FlexOptions 接口定义:

typescript 复制代码
interface FlexOptions {
  direction?: FlexDirection;       // 主轴方向
  wrap?: FlexWrap;                 // 是否换行
  justifyContent?: FlexAlign;      // 主轴对齐方式
  alignItems?: ItemAlign;          // 交叉轴单行对齐
  alignContent?: FlexAlign;        // 交叉轴多行对齐
}
2.3.2 主轴方向(direction)

FlexDirection 枚举有四个值:

枚举值 主轴方向 排列效果
FlexDirection.Row 水平从左到右 → → →
FlexDirection.RowReverse 水平从右到左 ← ← ←
FlexDirection.Column 垂直从上到下 ↓ ↓ ↓
FlexDirection.ColumnReverse 垂直从下到上 ↑ ↑ ↑

对于瀑布流场景,我们使用 Row 方向,即水平排列。

2.3.3 换行模式(wrap)

FlexWrap 枚举有三个值:

枚举值 行为 说明
FlexWrap.NoWrap 不换行 所有子项排在同一行,可能会被压缩
FlexWrap.Wrap 允许换行 排满一行后自动折行到下一行
FlexWrap.WrapReverse 反向换行 换行后交叉轴方向反转

瀑布流最关键的就是 FlexWrap.Wrap。只有开启了换行,当第一行的卡片总宽度超过容器宽度时,后续卡片才会「流」到下一行,从而形成多行布局。

2.3.4 主轴对齐(justifyContent)

FlexAlign 枚举控制主轴方向上的对齐方式:

枚举值 效果 适用场景
FlexAlign.Start 起始对齐 默认,左对齐
FlexAlign.Center 居中对齐 居中排列
FlexAlign.End 末尾对齐 右对齐
FlexAlign.SpaceBetween 两端对齐 两列瀑布流,均匀分布
FlexAlign.SpaceAround 环绕间距 每个子项两侧间距相等
FlexAlign.SpaceEvenly 均匀间距 子项之间、首尾与容器之间间距相等

注意 :当 wrap: FlexWrap.Wrap 生效时,如果一行中实际只有一列(例如屏幕很宽但卡片也宽),SpaceBetween 会将子项推到两端,可能不是你想要的效果。但在两列场景下,SpaceBetween 是最佳选择。

2.3.5 交叉轴对齐(alignItems vs alignContent)

这两个属性容易混淆,它们的区别如下:

属性 控制对象 生效条件 类比
alignItems 单行内子项在交叉轴上的对齐 始终生效 行内对齐
alignContent 多行作为一个整体在交叉轴上的分布 需要 wrap: Wrap 行间分布

如果 wrap: NoWrap,则只有 alignItems 生效,alignContent 会被忽略。

在我们的瀑布流场景中:

  • alignContent: FlexAlign.Start:多行从顶部开始排列(这是默认且合理的行为)
  • alignItems 可以不设置(默认是 ItemAlign.Start),让每一行内的卡片顶部对齐------这正是我们想要的效果

2.4 State 管理与 UI 刷新

ArkTS 提供了多种装饰器来实现状态管理:

装饰器 作用范围 数据流向 说明
@State 当前组件 单向(自身) 最基本的响应式状态
@Prop 父→子 单向(父→子) 子组件接收父组件传参
@Link 父子双向 双向 父子组件共享状态
@Provide / @Consume 跨层级 单向/双向 祖先与后代传递状态
@StorageLink 应用全局 双向 与应用级存储同步
@LocalStorageLink 页面级 双向 与页面级存储同步

在我们的瀑布流示例中:

  • 页面级状态 items 使用 @State 管理
  • 卡片组件的 item 使用 @Prop 接收父组件传入的数据

之所以卡片不能用 private 声明属性,是因为 ArkTS 的编译要求所有从父组件接收数据的属性必须用装饰器显式标注,否则编译器无法生成正确的更新逻辑。


三、FlexWrap 瀑布流的实现原理

3.1 核心设计思想

在深入代码之前,我们先理清一个关键问题:为什么 FlexWrap 能做出瀑布流效果?

传统瀑布流(Masonry)的算法复杂度在于:

  1. 需要维护 N 列各自当前的高度
  2. 每次新插入一个卡片时,选择当前高度最低的那一列
  3. 将卡片放置在该列底部,并更新该列高度
  4. 不断重复上述过程

这是一个典型的「贪心算法」,每次都将新卡片放在最矮的列上,以保证各列高度最终尽可能均衡。

而我们的 FlexWrap 方案绕过了这个复杂算法,它利用了一个更简单的原理:

  • 所有卡片按从左到右、从上到下的顺序依次排列
  • 每张卡片的宽度固定(48%,即两列布局)
  • 由于卡片高度各不相同,在第一行中,A 列和 B 列的卡片高度就已经产生了差异
  • 当排列到第二行时,第 3 张卡片自然紧接着第 1 张卡片的下方(而不是对齐第 2 张的底部)
  • 这种差异在视觉上就形成了「瀑布流」的错落效果

让我们用一个更直观的例子来说明:

复制代码
第一行:
┌─────────────┐  ┌─────────────┐
│  卡片 1      │  │  卡片 2      │
│  (高 80vp)   │  │  (高 160vp)  │
│             │  │             │
│             │  │             │
└─────────────┘  │             │
                 │             │
                 └─────────────┘
第二行:
┌─────────────┐  ┌─────────────┐
│  卡片 3      │  │  卡片 4      │
│  (高 120vp)  │  │  (高 100vp)  │
│             │  │             │
└─────────────┘  └─────────────┘

注意看:卡片 1 高度只有 80vp,卡片 2 高达 160vp,因此卡片 3 在第二行左列(卡片 1 下方)与卡片 2 形成了一段「高度差」。这个高度差就是瀑布流「错落感」的来源。

3.2 与严格 Masonry 的对比

对比维度 FlexWrap 瀑布流 严格 Masonry
算法复杂度 O(1) O(N)(遍历所有列找最矮)
代码量 约 30 行核心逻辑 需独立维护列高追踪逻辑
视觉效果 同一行顶部对齐,行间错落 每列独立,各行不对齐
列数调整 修改卡片宽度百分比 修改列追踪逻辑
数据顺序 严格按给定顺序排列 优化排列使列均衡
适用场景 标签云、简短摘要卡片 图片流、长内容卡片

简单来说:FlexWrap 方案是「轻量级瀑布流」,视觉上够用,实现上极简。对于大多数信息流场景,它的效果已经足够好。

3.3 为什么选择 Flex 而不是 List 或 Grid

有些开发者可能会问:为什么不使用 ListGrid 组件实现瀑布流?

容器 可行性 复杂度 推荐度
List 标准 List 只能单列排列,瀑布流不可用 ---
Grid Grid 支持网格,但自定义高度跨度较复杂 ⚠️
Flex 原生支持换行,结合不定高实现错落

实际上,鸿蒙的 Grid 组件确实可以通过 GridItemrowStart / rowEnd 实现类似 masonry 的效果,但需要配合 JS 计算出每个卡片应该跨越的行数,实现复杂度和 FlexWrap 不是一个量级。

因此,Flex 是实现瀑布流雏形的最佳起点


四、项目搭建与环境准备

4.1 创建 HarmonyOS 工程

在 DevEco Studio 中创建新工程:

  1. File → New → Create Project
  2. 选择模板:Empty Ability
  3. 配置项目:
    • Project Name:WaterfallDemo(可自定义)
    • Bundle Name:com.example.waterfalldemo
    • Save Location:自定义
    • Compile SDK:选择 API 24
    • Model:Stage(HarmonyOS NEXT 推荐模式)
    • Language:ArkTS

4.2 项目目录结构

复制代码
WaterfallDemo/
├── AppScope/
│   └── app.json5                    # 应用级别配置
├── entry/
│   ├── build-profile.json5           # 模块构建配置
│   ├── src/main/
│   │   ├── ets/
│   │   │   ├── entryability/
│   │   │   │   └── EntryAbility.ets  # Ability 生命周期
│   │   │   └── pages/
│   │   │       ├── Index.ets         # 首页(导航入口)
│   │   │       └── WaterfallFlex.ets # ★ 瀑布流布局演示页
│   │   ├── module.json5              # 模块清单文件
│   │   └── resources/
│   │       └── base/profile/
│   │           └── main_pages.json   # 路由注册
│   └── oh-package.json5
├── build-profile.json5               # 项目级别构建配置
├── hvigorfile.ts
└── oh-package.json5

4.3 页面路由注册

entry/src/main/resources/base/profile/main_pages.json 中注册两个页面:

json 复制代码
{
  "src": [
    "pages/Index",
    "pages/WaterfallFlex"
  ]
}

这样,首页 Index.ets 即可通过 router.pushUrl({ url: 'pages/WaterfallFlex' }) 跳转到瀑布流页面。

4.4 编译运行

在 DevEco Studio 中:

  1. 连接真机或启动模拟器
  2. 点击运行按钮(绿色三角)或使用快捷键 Shift + F10
  3. 等待编译完成,应用自动安装并启动

也可以使用命令行编译:

bash 复制代码
hvigorw assembleApp --no-daemon

编译产物位于 entry/build/default/outputs/default/ 目录下。


五、核心代码逐段精讲

这一章是整篇文章的核心。我们将逐段、逐行地拆解 WaterfallFlex.ets 的每一处设计决策和实现细节。

5.1 数据模型 ------ WaterfallItem 接口

typescript 复制代码
/**
 * 瀑布流数据模型
 * 每个卡片包含:标题、描述、高度比例、颜色
 */
interface WaterfallItem {
  id: number;
  title: string;       // 卡片标题
  desc: string;        // 卡片描述文字
  heightRatio: number; // ★ 高度权重(1.0 ~ 2.0),数值越大卡片越高
  color: string;       // 卡片主色调(十六进制色值,如 '#FFB7C5')
}

设计要点

  • id:唯一标识符,在 ForEach 中作为 key 使用,帮助框架高效识别哪个 item 被修改/删除/新增
  • heightRatio制造瀑布流错落感的核心参数 。每张卡片图片区的高度为 80 × heightRatio,取值范围 1.0 到 1.9 之间。1.0 的卡片只有 80vp 高,而 1.9 的卡片高达 152vp,几乎是前者的两倍
  • color:使用十六进制字符串而非 Color 枚举,因为 14 张卡片需要 14 种不同的颜色,Color 枚举只有有限几个预定义颜色

为什么不把 heightRatio 直接改为具体高度值(如 imageHeight: number)?

使用「比例值」的语义更清晰------它表达了「这张卡片相对于最矮卡片的高度倍数」。如果要统一调整所有卡片的高度基准值,只需修改公式中的 80 这个基数即可,而不需要逐一修改每个卡片的数据。

5.2 数据源 ------ @State 装饰的数据数组

typescript 复制代码
@State private items: WaterfallItem[] = [
  { id: 1,  title: '春日樱花', desc: '粉色的花瓣随风飘落,铺满整条小径。',
    heightRatio: 1.2, color: '#FFB7C5' },
  { id: 2,  title: '夏日海滩', desc: '阳光、沙滩、海浪,还有冰镇的椰子汁。',
    heightRatio: 1.6, color: '#87CEEB' },
  { id: 3,  title: '秋日枫叶', desc: '满山红叶层林尽染,秋意正浓时。',
    heightRatio: 1.0, color: '#FF8C42' },
  { id: 4,  title: '冬日雪景', desc: '白雪皑皑覆盖大地,世界变得安静。',
    heightRatio: 1.8, color: '#B0C4DE' },
  { id: 5,  title: '星空物语', desc: '浩瀚银河横跨天际,繁星点点闪烁。',
    heightRatio: 1.4, color: '#6A5ACD' },
  { id: 6,  title: '森林秘境', desc: '古木参天,藤蔓缠绕,阳光透过叶隙洒下。',
    heightRatio: 1.1, color: '#6B8E23' },
  { id: 7,  title: '城市夜景', desc: '华灯初上,车水马龙,霓虹照亮不夜城。',
    heightRatio: 1.7, color: '#2F4F4F' },
  { id: 8,  title: '花海田园', desc: '薰衣草田一望无际,微风吹过紫色波浪。',
    heightRatio: 1.3, color: '#9370DB' },
  { id: 9,  title: '极光之舞', desc: '绿色光带在夜空中流转,如梦似幻。',
    heightRatio: 1.9, color: '#00CED1' },
  { id: 10, title: '山水墨韵', desc: '远山如黛,近水含烟,泼墨山水画中游。',
    heightRatio: 1.5, color: '#708090' },
  { id: 11, title: '落日余晖', desc: '夕阳将天空染成金红色,海面波光粼粼。',
    heightRatio: 1.2, color: '#FF6347' },
  { id: 12, title: '雨后天晴', desc: '彩虹横跨天际,空气中弥漫着泥土的清香。',
    heightRatio: 1.6, color: '#48D1CC' },
  { id: 13, title: '古镇小巷', desc: '青石板路蜿蜒曲折,古色古香的建筑诉说着历史。',
    heightRatio: 1.0, color: '#BC8F8F' },
  { id: 14, title: '云雾山巅', desc: '云海翻涌如浪,山峰若隐若现宛如仙境。',
    heightRatio: 1.8, color: '#9ACD32' },
];

数据设计思路

  1. 14 个卡片,14 种颜色:涵盖粉色、蓝色、橙色、紫色、绿色、青色、灰色等,色彩丰富,视觉上不单调
  2. 高度比例分布:1.0(矮)×2、1.1 ×1、1.2 ×2、1.3 ×1、1.4 ×1、1.5 ×1、1.6 ×2、1.7 ×1、1.8 ×2、1.9 ×1。既有矮卡片也有高卡片,错落效果明显
  3. 主题统一:每个卡片都是一幅「风景主题」,配对应的 emoji 图标,增强阅读体验
  4. @State 的作用 :如果未来需要动态加载更多数据(如下拉刷新加载新卡片),只需向 items 数组追加数据,UI 会自动更新

5.3 卡片子组件 ------ WaterfallCard

将卡片抽取为独立组件是软件工程中「关注点分离」原则的体现。我们来看完整代码:

typescript 复制代码
/**
 * 卡片子组件:独立渲染一张瀑布流卡片
 * 将卡片抽成独立 @Component,以便在图片区直接嵌入 Emoji 文字,
 * 避免在 Row 上使用 .overlay(Text(...)) 导致的类型不兼容问题。
 */
@Component
struct WaterfallCard {
  /**
   * @Prop 装饰器:父组件传参到此子组件,数据为单向同步(父→子)。
   * 必须显式标记为 @Prop,不能使用 private ------ 否则编译报错
   * "Property has no initializer" 且无法通过构造函数初始化。
   */
  @Prop item: WaterfallItem;

  build() {
    // 卡片容器:Column 垂直排布,固定宽度 48%(两列布局)
    Column() {
      // ---- 图片占位区(带颜色的圆角方块 + 居中 Emoji) ----
      Row() {
        // 在色块中央显示主题 emoji 图标
        Text(this.getItemEmoji(this.item.title))
          .fontSize(32)
          .fontColor('#FFFFFF')
      }
      .width('100%')
      // ★ 关键:高度 = 基础值 × heightRatio,每个卡片高度不同,产生错落感
      .height(80 * this.item.heightRatio)
      .borderRadius(12)
      .backgroundColor(this.item.color)
      .justifyContent(FlexAlign.Center)
      .alignItems(VerticalAlign.Center)
      .margin({ bottom: 6 })

      // ---- 标题文字 ----
      Text(this.item.title)
        .fontSize(16)
        .fontWeight(FontWeight.Medium)
        .width('100%')
        .textAlign(TextAlign.Start)
        .margin({ bottom: 4 })

      // ---- 描述文字(最多 2 行,超长省略号) ----
      Text(this.item.desc)
        .fontSize(13)
        .fontColor('#666666')
        .width('100%')
        .textAlign(TextAlign.Start)
        .lineHeight(18)
        .maxLines(2)
        .textOverflow({ overflow: TextOverflow.Ellipsis })
    }
    .width('48%')
    .backgroundColor('#FAFAFA')
    .borderRadius(14)
    .padding(10)
    .margin({ bottom: 12 })
    .shadow({
      radius: 6,
      offsetX: 0,
      offsetY: 2,
      color: 'rgba(0, 0, 0, 0.08)'
    })
  }

  /**
   * 根据标题返回对应的 emoji 图标
   */
  private getItemEmoji(title: string): string {
    const emojiMap: Record<string, string> = {
      '春日樱花': '🌸',
      '夏日海滩': '🏖️',
      '秋日枫叶': '🍁',
      '冬日雪景': '❄️',
      '星空物语': '🌌',
      '森林秘境': '🌲',
      '城市夜景': '🌃',
      '花海田园': '🌸',
      '极光之舞': '🌠',
      '山水墨韵': '⛰️',
      '落日余晖': '🌅',
      '雨后天晴': '🌈',
      '古镇小巷': '🏘️',
      '云雾山巅': '☁️',
    };
    return emojiMap[title] ?? '📸';
  }
}

布局层级示意

复制代码
Column (.width('48%'), 圆角白色背景 + 阴影)
├── Row (.height = 80 × heightRatio, 彩色背景, 圆角)  ← 模拟图片
│   └── Text (Emoji 图标, 白色, 32号字, 居中)
├── Text (标题, 16号字, 左对齐)
└── Text (描述, 13号字, 灰色, 最多2行)

每个属性的作用

  • .width('48%'):控制列宽。48% 意味着两列分别占 48% + 48% = 96%,剩下的 4% 成为两列之间的间隙。注意这里没有用 50%,因为如果两列各占 50%,加上 justifyContent: SpaceBetween 后,它们会紧密贴合两侧边缘,中间留出很大间隙------视觉效果不如 48% 紧凑
  • .height(80 * this.item.heightRatio)瀑布流核心公式。80 是基础高度(vp 单位),乘以比例因子后产生差异化高度
  • .borderRadius(12/14):内层图片 12vp 圆角,外层卡片 14vp 圆角------外层稍大,形成完美的双层圆角嵌套
  • .shadow(...):卡片阴影参数分别为:阴影模糊半径 6vp,水平偏移 0,垂直偏移 2vp,颜色为半透明黑色。效果柔和,不太过夸张
  • .maxLines(2).textOverflow({ overflow: TextOverflow.Ellipsis }):描述文字最多两行,超出部分以省略号截断。保证卡片高度不会因为文字过长而失控

5.4 核心 Flex 容器

这是整个瀑布流布局的「心脏」:

typescript 复制代码
Flex({
  direction: FlexDirection.Row,         // 主轴为水平方向
  wrap: FlexWrap.Wrap,                  // ★ 允许换行 ------ 瀑布流的关键
  justifyContent: FlexAlign.SpaceBetween, // 两列两端对齐,均匀分布
  alignContent: FlexAlign.Start,        // 多行从顶部开始排列
}) {
  ForEach(this.items, (item: WaterfallItem) => {
    WaterfallCard({ item: item })
  }, (item: WaterfallItem) => item.id.toString())
}
.width('100%')
.padding({ left: 12, right: 12 })       // 左右留 12vp 安全边距

执行流程

  1. ForEach 遍历 items 数组,依次创建 WaterfallCard 组件
  2. 每张卡片宽度 48%,两张一排占 96%
  3. Flex 容器水平排列卡片,当第二张卡片放完时,一行已满
  4. 第三张卡片自动换行到第二行
  5. SpaceBetween 将两张卡片推向左右两侧,中间留出均匀间距
  6. 由于不同卡片的 heightRatio 不同,每张卡片的高度不一致
  7. 第二行的起始位置对齐第一行的「下一行起点」,但由于第一行左右两卡片高度不同,第二行左列卡片的上边缘与右列的上边缘形成了「视觉错位」

关键认知

FlexWrap.Wrap 的换行逻辑是:当前行放不下下一个子项时,就新起一行,新行的起始位置在交叉轴方向上紧挨着上一行。这个「紧挨」是指新行顶部对齐上一行底部,但因为上一行左右卡片高度不同,新行的左右卡片自然就「错开」了。

5.5 页面外壳 ------ Scroll + Column

typescript 复制代码
Scroll() {
  Column({ space: 12 }) {
    // ---------- 页面标题 ----------
    Text('🌊 瀑布流布局(FlexWrap 实现)')
      .fontSize(20)
      .fontWeight(FontWeight.Bold)
      .width('100%')
      .textAlign(TextAlign.Center)
      .margin({ top: 16, bottom: 4 })

    // ---------- 说明文字 ----------
    Text('利用 Flex + FlexWrap 换行 + 不定高卡片,自然形成错落瀑布流效果')
      .fontSize(13)
      .fontColor('#888888')
      .width('100%')
      .textAlign(TextAlign.Center)
      .margin({ bottom: 8 })

    // ---------- ★ 核心 ------ Flex 容器 ----------
    Flex({
      direction: FlexDirection.Row,
      wrap: FlexWrap.Wrap,
      justifyContent: FlexAlign.SpaceBetween,
      alignContent: FlexAlign.Start,
    }) {
      ForEach(this.items, (item: WaterfallItem) => {
        WaterfallCard({ item: item })
      }, (item: WaterfallItem) => item.id.toString())
    }
    .width('100%')
    .padding({ left: 12, right: 12 })
  }
  .width('100%')
}
.width('100%')
.height('100%')
.backgroundColor('#F0F0F0')   // 浅灰背景,柔和护眼

结构分析

复制代码
Scroll(纵向滚动,100% × 100%)
  └── Column(space = 12vp)
      ├── Text(主标题)
      ├── Text(副标题/说明)
      └── Flex(★ 瀑布流核心容器)
          ├── WaterfallCard(卡片 1)
          ├── WaterfallCard(卡片 2)
          ├── WaterfallCard(卡片 3)
          └── ...(直至卡片 14)

为什么需要 Scroll

如果去掉 Scroll,当瀑布流内容总高度超出屏幕高度时,超出部分将被裁剪,用户将无法看到下方的卡片。Scroll 提供了纵向滚动的能力,这是任何内容列表类页面都需要的标配组件。

为什么 Column 的 space 是 12?

space: 12 控制 Column 内各子组件(标题区与 Flex 区)之间的垂直间距,12vp 在鸿蒙 UI 中是一个标准的舒适间距值。

5.6 首页导航页面

typescript 复制代码
import { router } from '@kit.ArkUI';

@Entry
@Component
struct Index {
  build() {
    // Column + Blank 实现垂直居中,替代 RelativeContainer + alignRules
    Column() {
      // 顶部弹性占位
      Blank()

      // ---- 主标题 ----
      Text('鸿蒙 ArkTS 布局示例')
        .fontSize(22)
        .fontWeight(FontWeight.Bold)
        .textAlign(TextAlign.Center)
        .width('100%')

      // ---- 副标题 ----
      Text('Flex 实现瀑布流布局雏形')
        .fontSize(16)
        .fontColor('#666666')
        .width('100%')
        .textAlign(TextAlign.Center)
        .margin({ top: 8, bottom: 40 })

      // ---- 进入瀑布流按钮 ----
      Button('🏞️  查看瀑布流效果')
        .width(220)
        .height(48)
        .fontSize(18)
        .fontWeight(FontWeight.Medium)
        .backgroundColor('#FF6B6B')
        .borderRadius(24)
        .onClick(() => {
          router.pushUrl({ url: 'pages/WaterfallFlex' });
        })

      // 底部弹性占位
      Blank()
    }
    .height('100%')
    .width('100%')
    .justifyContent(FlexAlign.Center)
    .alignItems(HorizontalAlign.Center)
  }
}

设计要点

  1. Blank() :Flex 布局中的弹性空白组件,它会占据剩余空间。上下两个 Blank() 将中间的内容「推」到垂直中央
  2. .justifyContent(FlexAlign.Center) + .alignItems(HorizontalAlign.Center) :Column 的主轴是垂直方向,justifyContent: Center 使内容在垂直方向居中;alignItems: Center 使内容在水平方向居中
  3. 按钮样式 :红色圆角按钮(#FF6B6B),圆角 24vp(高度 48vp 的一半,形成胶囊形状),220vp 宽度,视觉上突出且友好
  4. router.pushUrl:标准页面跳转 API。弃用警告是由于新版 API 调整路由接口所致,不影响功能

六、完整可运行代码

为了方便读者直接复制使用,以下提供两个文件的完整代码。

6.1 WaterfallFlex.ets

typescript 复制代码
/*
 * 鸿蒙原生 ArkTS 布局方式之 Flex 实现瀑布流布局雏形
 *
 * 核心思路:
 *   利用 Flex 容器的 FlexWrap.Wrap 换行能力 + 每个 item 高度不一致,
 *   让子项在水平排列时自然出现「参差不齐」的错落效果,
 *   形成视觉上的瀑布流(Waterfall)雏形。
 *
 * 布局要点:
 *   1. Flex 容器:direction = FlexDirection.Row(主轴水平)
 *      wrap = FlexWrap.Wrap(允许换行)
 *   2. 每个卡片 item 固定宽度(如 48%),高度随机变化
 *   3. 配合 justifyContent: FlexAlign.SpaceBetween 让两列分布均匀
 *   4. 卡片内使用 Column 垂直布局
 */

interface WaterfallItem {
  id: number;
  title: string;
  desc: string;
  heightRatio: number;
  color: string;
}

@Component
struct WaterfallCard {
  @Prop item: WaterfallItem;

  build() {
    Column() {
      Row() {
        Text(this.getItemEmoji(this.item.title))
          .fontSize(32)
          .fontColor('#FFFFFF')
      }
      .width('100%')
      .height(80 * this.item.heightRatio)
      .borderRadius(12)
      .backgroundColor(this.item.color)
      .justifyContent(FlexAlign.Center)
      .alignItems(VerticalAlign.Center)
      .margin({ bottom: 6 })

      Text(this.item.title)
        .fontSize(16)
        .fontWeight(FontWeight.Medium)
        .width('100%')
        .textAlign(TextAlign.Start)
        .margin({ bottom: 4 })

      Text(this.item.desc)
        .fontSize(13)
        .fontColor('#666666')
        .width('100%')
        .textAlign(TextAlign.Start)
        .lineHeight(18)
        .maxLines(2)
        .textOverflow({ overflow: TextOverflow.Ellipsis })
    }
    .width('48%')
    .backgroundColor('#FAFAFA')
    .borderRadius(14)
    .padding(10)
    .margin({ bottom: 12 })
    .shadow({
      radius: 6,
      offsetX: 0,
      offsetY: 2,
      color: 'rgba(0, 0, 0, 0.08)'
    })
  }

  private getItemEmoji(title: string): string {
    const emojiMap: Record<string, string> = {
      '春日樱花': '🌸',
      '夏日海滩': '🏖️',
      '秋日枫叶': '🍁',
      '冬日雪景': '❄️',
      '星空物语': '🌌',
      '森林秘境': '🌲',
      '城市夜景': '🌃',
      '花海田园': '🌸',
      '极光之舞': '🌠',
      '山水墨韵': '⛰️',
      '落日余晖': '🌅',
      '雨后天晴': '🌈',
      '古镇小巷': '🏘️',
      '云雾山巅': '☁️',
    };
    return emojiMap[title] ?? '📸';
  }
}

@Entry
@Component
struct WaterfallFlex {
  @State private items: WaterfallItem[] = [
    { id: 1, title: '春日樱花',
      desc: '粉色的花瓣随风飘落,铺满整条小径。',
      heightRatio: 1.2, color: '#FFB7C5' },
    { id: 2, title: '夏日海滩',
      desc: '阳光、沙滩、海浪,还有冰镇的椰子汁。',
      heightRatio: 1.6, color: '#87CEEB' },
    { id: 3, title: '秋日枫叶',
      desc: '满山红叶层林尽染,秋意正浓时。',
      heightRatio: 1.0, color: '#FF8C42' },
    { id: 4, title: '冬日雪景',
      desc: '白雪皑皑覆盖大地,世界变得安静。',
      heightRatio: 1.8, color: '#B0C4DE' },
    { id: 5, title: '星空物语',
      desc: '浩瀚银河横跨天际,繁星点点闪烁。',
      heightRatio: 1.4, color: '#6A5ACD' },
    { id: 6, title: '森林秘境',
      desc: '古木参天,藤蔓缠绕,阳光透过叶隙洒下。',
      heightRatio: 1.1, color: '#6B8E23' },
    { id: 7, title: '城市夜景',
      desc: '华灯初上,车水马龙,霓虹照亮不夜城。',
      heightRatio: 1.7, color: '#2F4F4F' },
    { id: 8, title: '花海田园',
      desc: '薰衣草田一望无际,微风吹过紫色波浪。',
      heightRatio: 1.3, color: '#9370DB' },
    { id: 9, title: '极光之舞',
      desc: '绿色光带在夜空中流转,如梦似幻。',
      heightRatio: 1.9, color: '#00CED1' },
    { id: 10, title: '山水墨韵',
      desc: '远山如黛,近水含烟,泼墨山水画中游。',
      heightRatio: 1.5, color: '#708090' },
    { id: 11, title: '落日余晖',
      desc: '夕阳将天空染成金红色,海面波光粼粼。',
      heightRatio: 1.2, color: '#FF6347' },
    { id: 12, title: '雨后天晴',
      desc: '彩虹横跨天际,空气中弥漫着泥土的清香。',
      heightRatio: 1.6, color: '#48D1CC' },
    { id: 13, title: '古镇小巷',
      desc: '青石板路蜿蜒曲折,古色古香的建筑诉说着历史。',
      heightRatio: 1.0, color: '#BC8F8F' },
    { id: 14, title: '云雾山巅',
      desc: '云海翻涌如浪,山峰若隐若现宛如仙境。',
      heightRatio: 1.8, color: '#9ACD32' },
  ];

  build() {
    Scroll() {
      Column({ space: 12 }) {
        Text('🌊 瀑布流布局(FlexWrap 实现)')
          .fontSize(20)
          .fontWeight(FontWeight.Bold)
          .width('100%')
          .textAlign(TextAlign.Center)
          .margin({ top: 16, bottom: 4 })

        Text('利用 Flex + FlexWrap 换行 + 不定高卡片,自然形成错落瀑布流效果')
          .fontSize(13)
          .fontColor('#888888')
          .width('100%')
          .textAlign(TextAlign.Center)
          .margin({ bottom: 8 })

        Flex({
          direction: FlexDirection.Row,
          wrap: FlexWrap.Wrap,
          justifyContent: FlexAlign.SpaceBetween,
          alignContent: FlexAlign.Start,
        }) {
          ForEach(this.items, (item: WaterfallItem) => {
            WaterfallCard({ item: item })
          }, (item: WaterfallItem) => item.id.toString())
        }
        .width('100%')
        .padding({ left: 12, right: 12 })
      }
      .width('100%')
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#F0F0F0')
  }
}

6.2 Index.ets

typescript 复制代码
import { router } from '@kit.ArkUI';

@Entry
@Component
struct Index {
  build() {
    Column() {
      Blank()

      Text('鸿蒙 ArkTS 布局示例')
        .fontSize(22)
        .fontWeight(FontWeight.Bold)
        .textAlign(TextAlign.Center)
        .width('100%')

      Text('Flex 实现瀑布流布局雏形')
        .fontSize(16)
        .fontColor('#666666')
        .width('100%')
        .textAlign(TextAlign.Center)
        .margin({ top: 8, bottom: 40 })

      Button('🏞️  查看瀑布流效果')
        .width(220)
        .height(48)
        .fontSize(18)
        .fontWeight(FontWeight.Medium)
        .backgroundColor('#FF6B6B')
        .borderRadius(24)
        .onClick(() => {
          router.pushUrl({ url: 'pages/WaterfallFlex' });
        })

      Blank()
    }
    .height('100%')
    .width('100%')
    .justifyContent(FlexAlign.Center)
    .alignItems(HorizontalAlign.Center)
  }
}

6.3 main_pages.json

json 复制代码
{
  "src": [
    "pages/Index",
    "pages/WaterfallFlex"
  ]
}

七、编译踩坑与修复记录

在开发这个示例项目时,我们遇到了几个典型的 ArkTS 编译错误。这些经验对其他鸿蒙开发者同样有参考价值,因此详细记录下来。

7.1 alignRules 类型不匹配

错误等级:❌ ERROR

完整错误信息

复制代码
ArkTS Compiler Error
No overload matches this call.
  Overload 1 of 2, '(value: AlignRuleOption): TextAttribute',
  gave the following error.
    Type 'HorizontalAlign' is not assignable to type 'VerticalAlign'.

错误代码

typescript 复制代码
RelativeContainer() {
  Text('标题')
    .alignRules({
      center: { anchor: '__container__', align: HorizontalAlign.Center },
      middle: { anchor: '__container__', align: VerticalAlign.Top }
    })
}

产生原因

在较早版本的 HarmonyOS SDK 中,AlignRule 接口的 align 字段类型是 VerticalAlign | HorizontalAlign,即两者皆可。但在 API 24 版本中,AlignRuleOption 接口的类型定义发生了变化------align 字段的期望类型随着属性 key 的不同而分化,导致 HorizontalAlignVerticalAlign 不能混用。

解决方案

放弃使用 RelativeContainer + alignRules,改用 Column + Blank + justifyContent + alignItems 实现居中布局。新的方案代码量更少,跨版本兼容性更好,且更容易理解和维护。

typescript 复制代码
// 替代方案:Column + Blank 弹性布局
Column() {
  Blank()
  Text('标题')
  Button('按钮')
  Blank()
}
.height('100%')
.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center)

7.2 overlay() 参数类型错误

错误等级:❌ ERROR

完整错误信息

复制代码
ArkTS Compiler Error
Argument of type 'TextAttribute' is not assignable to
parameter of type 'string | CustomBuilder | ComponentContent<Object>'.
  Type 'TextAttribute' is missing the following properties
  from type 'ComponentContent<Object>': update, recycle, dispose, ...

错误代码

typescript 复制代码
Row()
  .width('100%')
  .height(80 * item.heightRatio)
  .overlay(
    Text('🌸')
      .fontSize(32)
      .fontColor('#FFFFFF')
  )

产生原因

.overlay() 方法接受两类参数:要么是一个普通字符串(如 '🌸'),要么是一个 CustomBuilder 构建函数。而直接链式调用的 Text('🌸').fontSize(32) 返回的是 TextAttribute 类型------它既不是字符串也不是 CustomBuilder,所以类型检查不通过。

解决方案

将 emoji 图标从 .overlay() 改为直接作为 Row 的子组件嵌套:

typescript 复制代码
Row() {
  Text('🌸')
    .fontSize(32)
    .fontColor('#FFFFFF')
}
.width('100%')
.height(80 * item.heightRatio)

这样既实现了「居中显示图标」的效果,又避免了类型问题。同时将卡片抽为独立 WaterfallCard 组件,结构更清晰。

7.3 子组件 @Prop 声明问题

错误等级:❌ ERROR(实际报了两个相关错误)

完整错误信息

复制代码
Error 1: Property 'item' has no initializer and is not
         definitely assigned in the constructor.
Error 2: Property 'item' is private and can not be
         initialized through the component constructor.

错误代码

typescript 复制代码
@Component
struct WaterfallCard {
  private item: WaterfallItem;  // ❌ 编译错误
  // ...
}

产生原因

在 ArkTS 中,子组件通过构造函数接收父组件传参时,编译器要求:

  1. 属性必须有初始值,或者使用 ! 非空断言(definite assignment assertion)
  2. 属性不能声明为 private,因为构造函数初始化需要外部访问
  3. 更重要的是,必须使用装饰器(@Prop / @Link / @State 等)显式标记属性的响应式角色

解决方案

typescript 复制代码
@Component
struct WaterfallCard {
  @Prop item: WaterfallItem;  // ✅ @Prop 声明,父→子单向数据同步
  // ...
}

@Prop 的作用:

  • 告诉编译器:这是一个从父组件接收的输入属性
  • 父组件数据变化时,子组件自动刷新
  • 子组件内部不能修改 @Prop 属性(遵守单向数据流原则)

延伸理解 :ArkTS 的装饰器不仅是「标记」,它们同时承担了「代码生成」的功能。编译器在看到 @Prop 时,会生成相应的属性更新逻辑。如果仅用 private 声明,编译器无法生成这些逻辑,自然就会报错。

7.4 router.pushUrl 弃用警告

错误等级:⚠️ WARN(不影响编译和运行)

完整警告信息

复制代码
ArkTS:WARN File: Index.ets:39:18
'pushUrl' has been deprecated.

产生原因

在 API 24 版本中,鸿蒙路由框架进行了重构,引入了新的路由接口。旧的 router.pushUrl() 虽然仍然可用,但已被标记为弃用。

解决方案(如果不希望看到警告):

方案一:使用新的路由 API

typescript 复制代码
import { router } from '@kit.ArkUI';

// 使用新的 pushUrl 接口
router.pushUrl({ url: 'pages/WaterfallFlex' })
  .then(() => {
    console.info('页面跳转成功');
  })
  .catch((err: BusinessError) => {
    console.error('页面跳转失败: ' + JSON.stringify(err));
  });

方案二:改用 Navigation 组件 + NavPathStack

typescript 复制代码
import { Navigation, NavPathStack } from '@kit.ArkUI';

@Entry
@Component
struct Index {
  private stack: NavPathStack = new NavPathStack();

  build() {
    Navigation(this.stack) {
      Button('跳转')
        .onClick(() => {
          this.stack.pushPath({ name: 'WaterfallFlex' });
        })
    }
  }
}

Navigation 方式是鸿蒙推荐的页面路由方式,支持更丰富的转场动画和参数传递,但在简单场景下 router.pushUrl 仍然是最便捷的选择。

7.5 调试建议

在开发 SparkTS 应用时,如果遇到编译错误,建议按以下步骤排查:

  1. 查看完整的错误输出hvigorw assembleApp --no-daemon --stacktrace 可以看到更详细的错误栈
  2. 定位到具体行号 :错误信息通常会给出文件路径和行号,如 File: Index.ets:39:18
  3. 缩小问题范围:注释掉可疑代码块,确认问题是否消失,然后逐步恢复
  4. 查阅官方 API 文档:鸿蒙开发者官网的 API 文档是最权威的参考
  5. 留意版本差异:不同 API 版本之间的接口可能有差异,确保参考对应版本的文档

八、FlexWrap 瀑布流的优缺点与优化方向

8.1 优点

  1. 代码极简:核心瀑布流逻辑仅需约 30 行代码(Flex 配置 + ForEach 遍历),加上卡片子组件也不到 100 行有效代码
  2. 纯原生 API :完全基于 ArkUI 内置的 FlexColumnText 等组件,零第三方依赖
  3. 编译时类型安全:ArkTS 是静态类型语言,所有属性传参都有类型检查,运行时极少出现因类型错误导致的崩溃
  4. 性能优良:Flex 布局的渲染计算由 ArkUI 引擎层完成,效率远高于 JS 方案
  5. 响应式:Flex 容器宽度自适应,在不同屏幕尺寸下都能正确排列
  6. 易于扩展:增加卡片只需要往数据数组中添加新 item,无需修改布局逻辑

8.2 局限性

  1. 行内顶部对齐:同一行内的卡片顶部是在同一水平线上对齐的,而不是像严格 Masonry 那样每列独立对齐------这意味着同一行中矮卡片的下方会留白,高卡片则填满整行
  2. 列数硬编码 :当前通过 .width('48%') 固定为两列,如果要改为三列或四列,需要手动调整卡片宽度和列高追踪逻辑
  3. 数据顺序敏感:卡片严格按照数据源中的顺序排列,无法像 Masonry 算法那样智能地将卡片分配到「当前最矮的列」以优化整体平衡
  4. 无法实现跨列卡片:如果某张卡片需要占据两列宽度(类似 Pinterest 的特色 Pin),FlexWrap 无法直接支持

8.3 优化方向

方向一:支持更多列数

通过计算容器宽度动态设置卡片宽度:

typescript 复制代码
getCardWidth(containerWidth: number): string {
  if (containerWidth >= 600) return '23%';  // 4列
  if (containerWidth >= 400) return '31%';  // 3列
  return '48%';                              // 2列
}

但需要注意,这只是改变了卡片宽度,并未真正实现「列高度均衡」的 Masonry 布局。

方向二:多 Column 严格 Masonry
typescript 复制代码
// 将数据均衡分配到多列
function distributeToColumns(
  items: WaterfallItem[],
  columnCount: number
): WaterfallItem[][] {
  const columns: WaterfallItem[][] = Array.from(
    { length: columnCount }, () => []
  );
  const heights: number[] = new Array(columnCount).fill(0);

  for (const item of items) {
    // 找到当前最矮的那一列
    const minHeight = Math.min(...heights);
    const targetCol = heights.indexOf(minHeight);

    columns[targetCol].push(item);
    // 估算该卡片高度,累加到该列
    heights[targetCol] += 80 * item.heightRatio + 100 + 12;
  }

  return columns;
}

然后渲染 N 个 Column:

typescript 复制代码
Row() {
  ForEach(this.columns, (column: WaterfallItem[], colIndex: number) => {
    Column() {
      ForEach(column, (item: WaterfallItem) => {
        WaterfallCard({ item: item })
      }, (item: WaterfallItem) => item.id.toString())
    }
    .width(1 / this.columnCount * 100 + '%')
    .alignItems(HorizontalAlign.Center)
  })
}
.width('100%')
方向三:使用 LazyForEach 实现性能优化

当数据量庞大时(数百甚至上千条),ForEach 会一次性渲染所有卡片,造成性能问题。应替换为 LazyForEach,实现按需渲染:

typescript 复制代码
class WaterfallDataSource extends BasicDataSource<WaterfallItem> {
  // 实现数据源接口 ...
}

@Entry
@Component
struct WaterfallFlex {
  private dataSource: WaterfallDataSource = new WaterfallDataSource();

  build() {
    LazyForEach(this.dataSource, (item: WaterfallItem) => {
      WaterfallCard({ item: item })
    }, (item: WaterfallItem) => item.id.toString())
  }
}

LazyForEach 只渲染当前可见区域内的卡片,当用户滚动时动态创建和回收组件,内存占用大幅降低。


九、延伸思考

9.1 ArkTS 布局的未来演进

随着 HarmonyOS NEXT 的持续演进,ArkUI 框架也在不断丰富布局能力。从社区动态和官方路标来看,以下几个方向值得关注:

  1. 更丰富的 Flex API :未来可能提供 gap 属性(类似于 CSS 的 gap)来替代复杂的 SpaceBetween + margin 组合
  2. 原生 Masonry 容器 :有推测说鸿蒙团队正在考虑加入原生的瀑布流容器组件,类似于 Flutter 的 MasonryGridView
  3. 布局动画增强transitionanimateTo 的配合越来越流畅,使得卡片增删、排序的过渡动画可以轻松实现

9.2 声明式 UI 的思考

使用 ArkTS 进行布局开发时,一个值得深思的问题是:声明式 UI 到底给我们带来了什么?

在传统的命令式 UI 中:你需要手动创建视图对象,设置属性,添加到父视图,处理布局变更通知......每一步都需要明确的代码指令。

在声明式 UI 中:你只需要描述 UI 的最终形态,框架负责推断从当前形态到目标形态的变换路径。这种思维方式的转变带来的好处:

  1. 减少 boilerplate 代码 :不需要写 createsetLayoutParamsaddView 等重复代码
  2. 状态驱动 UI :UI 是状态的纯函数,UI = f(state),减少不一致性
  3. 更少的 bug:状态变化的中间步骤由框架管理,开发者不需要担心忘记更新某个视图
  4. 更好的可读性build() 方法中的 UI 树结构一目了然

9.3 社区资源推荐

学习和深入鸿蒙 ArkTS 布局开发,以下资源值得收藏:


十、总结

10.1 本文要点回顾

本文通过一个完整的鸿蒙 ArkTS 示例项目,详细讲解了如何利用 Flex + FlexWrap.Wrap 实现瀑布流布局雏形。以下是核心知识点的回顾:

  1. Flex 布局核心

    • direction: FlexDirection.Row --- 主轴水平排列
    • wrap: FlexWrap.Wrap --- 允许换行,这是瀑布流的关键
    • justifyContent: FlexAlign.SpaceBetween --- 两列均匀分布
  2. 不定高卡片设计

    • 通过 heightRatio 参数控制每张卡片的高度
    • 高度差异化是瀑布流错落感的来源
    • 卡片内使用 Column 垂直排列:图片区 → 标题 → 描述
  3. 组件化封装

    • WaterfallCard 子组件通过 @Prop 接收数据
    • 独立的 getItemEmoji() 方法管理 Emoji 映射
  4. 避坑指南

    • alignRules 中的 HorizontalAlign / VerticalAlign 不能混用
    • .overlay() 不接受链式的 Text 组件
    • private 属性不能用于接收父组件传参,必须用 @Prop

10.2 下一步学习建议

如果你已经掌握了本文的内容,建议继续探索:

  1. 将数据源改为网络请求:从 REST API 或 GraphQL 获取真实数据
  2. 添加图片加载 :用 Image 组件替代彩色方块,加载真实图片
  3. 实现下拉刷新 + 上拉加载 :结合 @ohos.animator 或社区组件实现
  4. 尝试严格 Masonry:基于第九章的多 Column 方案实现真正的列高度均衡
  5. 加入点击事件:点击卡片跳转到详情页,或弹出模态框

10.3 写在最后

鸿蒙原生应用开发正处于高速发展期,ArkTS 作为主力语言,其声明式 UI 框架的设计哲学与现代前端框架(React、SwiftUI、Flutter 等)一脉相承,但又融合了鸿蒙独有的特性和优化。

瀑布流布局只是 ArkTS 布局能力的一个小小缩影。Flex容器的灵活性和表现力远超本文所展示的内容------FlexDirection.Column + FlexWrap.Wrap 可以实现类似 iOS 的「标签流布局」;justifyContent.SpaceEvenly 可以轻松实现「完美居中排列」;alignItems.Stretch 可以让同一行内的卡片等高排列......

希望本文能够帮助你在鸿蒙原生开发的道路上少走一些弯路。布局是 UI 开发的地基,打好地基,才能盖出漂亮的应用大楼。

如果你在实践过程中遇到任何问题,或者有更好的实现方案,欢迎留言交流。让我们共同推动鸿蒙开发者社区的发展!


附录

A. 完整项目文件清单

复制代码
WaterfallDemo/
├── AppScope/
│   └── app.json5
├── entry/
│   ├── build-profile.json5
│   ├── src/main/
│   │   ├── ets/
│   │   │   ├── entryability/
│   │   │   │   └── EntryAbility.ets
│   │   │   └── pages/
│   │   │       ├── Index.ets
│   │   │       └── WaterfallFlex.ets
│   │   ├── module.json5
│   │   └── resources/base/profile/
│   │       └── main_pages.json
│   └── oh-package.json5
├── build-profile.json5
├── hvigorfile.ts
├── hvigor-config.json5
├── oh-package.json5
└── oh-package-lock.json5

B. 常见属性速查表

Flex 容器常用配置组合

用途 direction wrap justifyContent alignContent
瀑布流(本文) Row Wrap SpaceBetween Start
标签云 Row Wrap Start Start
居中换行列 Row Wrap Center Center
底部导航 Row NoWrap SpaceAround ---
纵向换行 Column Wrap Start Start

卡片圆角搭配建议

卡片外层圆角 内部子组件圆角 效果
14vp 12vp 完美嵌套(外层略大)
16vp 12vp 嵌套留边明显
12vp 12vp 可能产生锯齿
0 8vp 只有内部圆角

颜色搭配参考(背景色与卡片色):

整体背景色 卡片背景色 效果
#F0F0F0 #FAFAFA 柔和护眼(本文采用)
#FFFFFF #F5F5F5 明亮简洁
#1A1A2E #16213E 深色模式
#F8F9FA #FFFFFF 高对比度

C. 参考文档


本文为鸿蒙原生应用开发系列教程之一,配套代码已通过 hvigorw assembleApp 编译验证(API 24 / SDK 6.1.0),在 DevEco Studio 中可直接编译运行。文中所有代码均遵循 Apache 2.0 开源协议,欢迎自由使用、修改和分享。

如果您觉得本文有帮助,欢迎点赞、收藏和转发,让更多鸿蒙开发者受益。如有问题或建议,请留言讨论。