鸿蒙 ArkTS 自定义布局与动画深度实践:从零构建增删改动画卡片网格


在这里插入图片描述在这里插入图片描述
在这里插入图片描述

一、引言

在 HarmonyOS NEXT 的 ArkTS 开发体系中,布局(Layout)与动画(Animation)是构建优质用户界面的两大基石。标准布局容器如 ColumnRowFlex 能解决大部分常规场景,但当我们遇到非规则的、动态变化的、高度定制的 UI 需求时,就必须掌握"自定义布局"这一进阶能力。与此同时,动画不再是锦上添花的点缀——在鸿蒙的设计语言中,动画是用户体验的核心组成部分,它能传达状态变化、引导用户注意力、提供操作反馈,以及赋予界面生命力。

本文以一个卡片增删改管理器为实战案例,深入剖析鸿蒙 ArkTS 中自定义布局的实现原理,以及如何将三种不同类型的动画(属性动画、过渡动画、布局动画)无缝融合到同一场景中。文章将从设计思想、编码实现到性能优化,逐层展开,帮助读者建立完整的 ArkTS 布局动画知识体系。

1.1 为什么选择这个场景

增删改(CRUD)是应用开发中最常见的操作模式。无论是备忘录应用中的笔记管理、电商应用中的购物车编辑,还是社交应用中的动态发布与删除,都离不开"列表数据的变化"这一基本交互。当这些变化发生在自定义布局中时,动画的作用尤为突出:

  • 新增:让用户感知到数据已经成功添加,并看到它从哪里出现
  • 删除:让用户确认操作生效,同时保持其他内容的视觉连续性
  • 修改:通过平滑的颜色或位置变化展示数据的更新过程
  • 批量操作:用动画串联多个变化,避免突兀的瞬间跳变

正是因为这个场景的普遍性和代表性,我们选择它作为 ArkTS 布局动画技术的教学载体。


二、HarmonyOS NEXT 的声明式 UI 体系概览

2.1 ArkTS 的设计哲学

ArkTS 是鸿蒙原生应用开发的推荐语言,基于 TypeScript 构建,但引入了装饰器驱动的声明式 UI 范式。与传统的命令式 UI 开发(如 Java Swing、Android XML + View 体系)不同,声明式 UI 不再需要开发者手动调用 addChildremoveViewinvalidate 等命令,而是通过"描述状态与 UI 的映射关系",让框架自动推导出需要执行的变更。

其核心理念可以用三个关键词概括:

概念 说明 对应装饰器/API
声明式 描述 UI 应该是怎样的,而非怎样去构建它 @Component 标记组件,build() 方法返回 UI 树
状态驱动 UI 是状态(State)的函数,状态变则 UI 变 @State 标记内部状态,@Prop 接收外部传入的响应式数据
响应式 框架自动追踪状态依赖,最小化更新范围 animateTo 驱动动画,.transition() 定义进出场效果

这与 React 的 “UI = f(state)” 理念、SwiftUI 的 @State@Binding 属性包装器一脉相承,但 ArkTS 在类型系统上更加严格——所有类型必须在编译期明确,不允许隐式的 any。这虽然带来了更多的类型标注工作,但也换来了更可靠的编译期检查和更好的 IDE 代码补全体验。

2.2 布局体系的分层架构

ArkTS 的布局体系可以视为三个抽象层次,开发者可以根据需求选择不同的层级:

第一层:标准布局容器(高抽象,适合常规场景)
  ├── Column —— 垂直排列,子组件从上到下依次排列
  ├── Row —— 水平排列,子组件从左到右依次排列
  ├── Flex —— 弹性布局,支持 flexWrap 实现自动换行
  ├── Grid —— 网格布局,适合固定行列数的场景
  ├── Stack —— 层叠布局(Z 轴叠加),支持通过 position/align 定位
  └── RelativeContainer —— 相对布局,通过锚点(anchor)实现组件间的相对定位

第二层:自定义布局基类(中等抽象,适合复杂测量需求)
  └── 继承 Layout 基类,重写 onMeasureSize + onPlaceChildren
      → 适用于需要精确测量每个子组件尺寸的复杂场景
      → 例如:子组件尺寸不统一、需要根据内容动态调整布局的瀑布流

第三层:手动坐标定位(低抽象,适合动态计算场景)
  └── Stack + .position({ x, y })
      → 适用于位置由算法实时计算、频繁变动的场景
      → 本文选用的方案,与 animateTo 配合最为灵活

为什么本文选择第三层?

第一层的标准容器虽然方便,但它们有固定的排列策略。当我们需要"卡片增删时其他卡片自动重排并带动画"时,Column 不会自动将剩余卡片前移(它只是按顺序排列),Flex 虽然支持 wrap 但无法精细控制位置过渡动画。第二层的 Layout 基类虽然功能强大,但在卡片尺寸统一、位置完全由算法决定的场景中显得有些过度设计。第三层的 Stack + .position() 方案恰到好处——它把位置控制权完全交给开发者,同时与 animateTo 的配合最为自然。

2.3 ArkTS 的状态管理机制

在深入代码之前,有必要理解 ArkTS 的状态管理机制,因为它是自定义布局和动画的基础。

@State ──→ build() 重新执行 ──→ UI 树重建 ──→ 差异对比 ──→ 最小化 DOM 更新
   │
   └── animateTo() 包裹时 ──→ 属性变化被追踪 ──→ 插值器生成中间帧

当一个 @State 变量发生变化时,ArkTS 框架会:

  1. 标记该组件为"脏"
  2. 在下一个帧循环中重新执行 build() 方法
  3. 对比新旧两棵虚拟 UI 树的差异
  4. 将差异转化为最小的实际布局/绘制操作

而当变化发生在 animateTo() 回调内部时,框架会额外记录属性的起始值和目标值,在指定的时长内不断插值,驱动组件从起始状态平滑过渡到目标状态。


三、案例需求与架构设计

3.1 场景描述

我们要构建一个卡片管理器应用,包含以下交互操作:

  1. 新增卡片:点击「新增卡片」按钮,一个新的卡片以动画方式从右下方飞入现有网格的末尾位置,同时已有卡片自动调整位置以保持网格对齐
  2. 删除卡片:点击卡片右上角的 ✕ 按钮,该卡片向左缩小并淡出,其余卡片平滑前移填补空缺
  3. 修改卡片颜色:点击「随机改色」按钮,随机选择一张卡片,其左侧色条的颜色从当前色平滑过渡到新颜色
  4. 清空所有卡片:点击「清空」按钮,所有卡片以动画方式依次消失

3.2 非功能性需求

除了功能需求外,我们还需要满足以下质量属性:

  • 自适应:布局容器应自动适应屏幕宽度的变化——横竖屏切换时列数自动调整
  • 平滑性:所有状态变化都必须有动画包裹,不允许出现突兀的跳变
  • 性能:布局算法必须是纯函数,O(n) 时间复杂度,不包含任何副作用
  • 可扩展:布局算法应易于修改——例如从等宽网格改为瀑布流,只需修改一个函数

3.3 架构设计

┌────────────────────────────────────────────────────────────┐
│  CustomLayoutDemo(@Entry 主页面)                            │
│  ┌────────────────────────────────────────────────────────┐ │
│  │  @State                                                │ │
│  │  ├── cardList: CardItem[]        ← 卡片数据源          │ │
│  │  ├── containerWidth: number      ← 布局容器实时宽度    │ │
│  │  └── positions: CardPosition[]   ← 预计算的位置数组    │ │
│  │                                                        │ │
│  │  方法                                                    │ │
│  │  ├── calcPositions()             ← 纯函数布局算法       │ │
│  │  ├── computeStackHeight()        ← 纯函数高度计算       │ │
│  │  ├── updateLayout()              ← 联动 positions 更新  │ │
│  │  ├── addCard()          ─ animateTo ─ push + update    │ │
│  │  ├── deleteCard()       ─ animateTo ─ splice + update  │ │
│  │  ├── modifyRandomCard() ─ animateTo ─ color 赋值       │ │
│  │  └── clearAll()         ─ animateTo ─ reset + update   │ │
│  └────────────────────────────────────────────────────────┘ │
│                     ↓                                      │
│  ┌────────────────────────────────────────────────────────┐ │
│  │  UI 树                                                  │ │
│  │  Column                                                │ │
│  │  ├── 标题 + 描述文字                                    │ │
│  │  ├── 操作按钮栏(3 个 Button)                           │ │
│  │  ├── 统计信息(当前卡片数)                              │ │
│  │  └── Scroll ── Stack(自定义布局容器)                   │ │
│  │       └── ForEach ── CardItemView × N                  │ │
│  │           ├── @Prop cardData: CardItem                  │ │
│  │           ├── @Prop posX / posY: number                │ │
│  │           ├── .position({ x, y }) ← 精确坐标             │ │
│  │           └── .transition(...)    ← 进出场动画           │ │
│  └────────────────────────────────────────────────────────┘ │
└────────────────────────────────────────────────────────────┘

上述架构的核心设计模式是数据驱动布局——cardList 的变化是一切动画的起点,整个数据流如下:

用户操作 → 方法调用 → animateTo 包裹
  → cardList 变化
  → updateLayout() 重算 positions
  → @State positions 更新
  → build() 重新执行
  → ForEach diff → 新增/移除/保留 CardItemView
  → 保留的 CardItemView posX/posY 变化 → animateTo 插值补间
  → 新增的 CardItemView 触发 transition 入场
  → 移除的 CardItemView 触发 transition 离场

这条数据流水线的每一步都是清晰、可预测、可调试的。这正是声明式 UI 的魅力所在——开发者只需要关心"数据的变化"和"UI 的终极状态",中间过程由框架自动处理。


四、自定义布局的完整实现

4.1 数据模型定义

/** 单个卡片的二维坐标——由布局算法计算得出 */
interface CardPosition {
  x: number;
  y: number;
}

/** 卡片数据项——承载卡片的所有数据属性 */
class CardItem {
  /** 唯一标识符,用于 ForEach 的 keyGenerator */
  id: number = 0;
  /** 卡片标题,显示在卡片中间区域 */
  title: string = '';
  /** 背景色(数值格式 0xAARRGGBB,例如 0xFF6B81 表示珊瑚粉) */
  color: number = 0;
  /** 卡片宽度(单位 vp,声明为 readonly 防止外部误修改) */
  readonly width: number = 150;
  /** 卡片高度(单位 vp) */
  readonly height: number = 80;

  constructor(id: number, title: string, color: number) {
    this.id = id;
    this.title = title;
    this.color = color;
  }
}

设计思考——为什么 colornumber 而非 string

@Prop 装饰器要求属性类型必须是可比较的。当父组件的 animateTo 修改 color 值时,框架需要判断值是否真的发生了变化,从而决定是否触发 CardItemView 的更新。number 类型的值可以精确比较(===),而 string 类型的颜色值(如 "#FF6B81")在比较时需要额外的解析开销。因此,使用数值形式不仅更高效,而且在 ArkTS 的严格类型系统中也更受推荐。

设计思考——widthheight 为什么放在数据模型里?

大多数布局方案将尺寸信息放在样式中。但在自定义布局中,calcPositions() 函数需要知道每个卡片的尺寸才能计算位置。如果将尺寸信息放在数据模型里,布局算法完全独立于 UI 组件——即使没有 ArkTS 环境,我们也能对 calcPositions 进行纯函数测试。这是一种"关注点分离"的设计决策。

4.2 布局常量定义

/** 每行最大列数 */
const COLUMN_MAX = 3;
/** 列间距(vp) */
const COLUMN_GAP = 16;
/** 行间距(vp) */
const ROW_GAP = 16;
/** 左/上内边距(vp) */
const PADDING = 16;

将这些数值定义为文件级别的常量而非类成员变量,基于两个考虑:

  1. 纯函数可访问性calcPositionscomputeStackHeight 是文件级函数(非方法),它们不能访问类的成员变量。定义在文件作用域的常量可以被这些纯函数直接引用。
  2. 集中管理:当我们需要调整布局的间距时,只需修改这些常量,无需在多个方法之间查找硬编码的数字。

在 ArkTS 编译优化中,const 声明的原始类型值还会被内联(inlined),没有任何运行时开销。

4.3 核心布局算法

布局算法的本质是将一个一维数组映射到二维平面。我们的策略是"从左到右、逐行排列,每行最多三列":

function calcPositions(cardList: CardItem[], parentWidth: number): CardPosition[] {
  const positions: CardPosition[] = [];
  if (cardList.length === 0 || parentWidth <= 0) {
    return positions;
  }

  const cardW = cardList[0].width;
  const cardH = cardList[0].height;
  const availableW = parentWidth - PADDING * 2;
  const colCount = Math.min(COLUMN_MAX,
                     Math.max(1, Math.floor(availableW / (cardW + COLUMN_GAP))));

  for (let i = 0; i < cardList.length; i++) {
    const col = i % colCount;
    const row = Math.floor(i / colCount);
    positions.push({
      x: PADDING + col * (cardW + COLUMN_GAP),
      y: PADDING + row * (cardH + ROW_GAP),
    });
  }
  return positions;
}

算法逐行解析:

  1. 边界检测:空列表或宽度无效时返回空数组,避免后续计算除以零
  2. 计算自适应列数
    • availableW:扣除左右内边距后真正可用的宽度
    • availableW / (cardW + COLUMN_GAP):计算理论上可以容纳多少列
    • Math.floor():向下取整,因为列必须是整数
    • Math.max(1, ...):最少保持一列,即使屏幕很窄
    • Math.min(COLUMN_MAX, ...):最多不超过三列,防止过宽
  3. 双重循环映射(通过取模和整除实现,而非嵌套 for 循环):
    • i % colCount:计算出当前卡片在第几列(0-based)
    • Math.floor(i / colCount):计算出当前卡片在第几行(0-based)
    • 这两条数学表达式等价于嵌套循环中的 col++row++,但代码更简洁

时间复杂度和空间复杂度
calcPositions 是 O(n) 时间、O(n) 空间的纯函数——对 cardList 进行一次遍历,为每个元素产生一个 CardPosition。没有递归、没有嵌套循环、没有副作用。这样的复杂度保证了即使有数百张卡片,布局计算也在一毫秒内完成。

4.4 容器高度计算

function computeStackHeight(cardList: CardItem[], parentWidth: number): number {
  if (cardList.length === 0 || parentWidth <= 0) {
    return 0;
  }
  const cardW = cardList[0].width;
  const cardH = cardList[0].height;
  const availableW = parentWidth - PADDING * 2;
  const colCount = Math.min(COLUMN_MAX,
    Math.max(1, Math.floor(availableW / (cardW + COLUMN_GAP))));
  const rowCount = Math.ceil(cardList.length / colCount);
  return PADDING + rowCount * (cardH + ROW_GAP) - ROW_GAP + PADDING;
}

为什么将高度计算单独抽离?

在 ArkTS 的 build() 方法中,Stack 组件需要显式设置 .height() 才能让外层的 Scroll 容器正确工作。如果我们将高度计算的逻辑内联在 build() 中,会破坏声明式结构的清晰度,也增加了单元测试的难度。

注意此处与 calcPositionscolCount 的计算逻辑必须完全一致。如果两者计算方式不同(比如一个用了 Math.floor 另一个用了 Math.round),就会导致 Stack 的实际子组件覆盖范围超出了它的显式高度,从而出现部分卡片被裁剪的问题。这就是我们使用纯函数而非内联计算的好处——可以在单元测试中同时验证这两个函数是否对齐。

computeStackHeight 的计算公式解析:

  • PADDING(顶部内边距)+
  • rowCount * (cardH + ROW_GAP)(每行高度 + 行间距,乘以行数)-
  • ROW_GAP(最后一行下方不应该有额外的间距)+
  • PADDING(底部内边距)

4.5 状态管理与布局联动

在主页面组件中,我们维护三个核心的 @State 变量:

@State private cardList: CardItem[] = [];      // 数据源——驱动一切变化的起点
@State private containerWidth: number = 360;    // 容器实时宽度——自适应布局的依据
@State private positions: CardPosition[] = [];  // 预计算的位置数组——减少 ForEach 中的计算

这三个 @State 变量各司其职:

  • cardList数据层,存储了所有卡片的数据
  • containerWidth布局层,反映了容器的实际可用宽度
  • positions缓存层,存储了布局算法的计算结果,避免在每一次 build() 调用中重复计算

每当 cardListcontainerWidth 发生变化,我们调用 updateLayout() 同步更新 positions

private updateLayout(): void {
  this.positions = calcPositions(this.cardList, this.containerWidth);
}

containerWidth 的实时获取通过 Stack 的 onAreaChange 回调实现:

Stack()
  .onAreaChange((_oldValue: Area, newValue: Area) => {
    const w: number = newValue.width as number;
    if (w > 0 && Math.abs(w - this.containerWidth) > 0.5) {
      this.containerWidth = w;
      this.updateLayout();
    }
  })

onAreaChange 是 ArkTS 中监听组件尺寸变化的回调接口。当组件首次渲染或布局发生变化时,该回调会被触发,传入新旧两个 Area 对象。我们从中提取 width 更新状态。

防抖阈值 0.5vp:为什么需要这个判断?因为 onAreaChange 在布局的初始阶段可能被连续触发多次——比如字体加载完成后文本宽度变化导致布局微调,或者某些子组件的异步渲染导致尺寸抖动。0.5vp(虚拟像素)的阈值在视觉上完全不可察觉,但能有效过滤掉微小的无效更新,避免 positions 被不必要的频繁重算。


五、动画系统的三层应用

动画是本文的核心主题之一。ArkTS 的动画体系可以视为三个同心圆,从最内层(属性级)到最外层(容器级),层层递进:

┌─────────────────────────────────────────────────────┐
│  布局动画(Layout Animation)                         │
│                                                      │
│  触发条件:@State 变化导致子组件位置/尺寸变化         │
│  实现方式:animateTo 包裹数据操作 + updateLayout      │
│  表现效果:整网格卡片平滑移动到新位置                 │
│  对应操作:新增卡片、删除卡片、清空                   │
│                                                      │
│  ┌─────────────────────────────────────────────┐     │
│  │  过渡动画(Transition)                       │     │
│  │                                                │     │
│  │  触发条件:组件挂载(进入)/ 卸载(离开)      │     │
│  │  实现方式:.transition(TransitionEffect)       │     │
│  │  表现效果:新卡片飞入、被删卡片淡出            │     │
│  │  对应操作:新增卡片(入场)、删除/清空(离场)   │     │
│  │                                                │     │
│  │  ┌─────────────────────────────────────┐       │     │
│  │  │  属性动画(Property Animation)       │       │     │
│  │  │                                        │       │     │
│  │  │  触发条件:单个属性值在 animateTo 内变化│       │     │
│  │  │  实现方式:animateTo + @Prop 绑定       │       │     │
│  │  │  表现效果:颜色平滑渐变                 │       │     │
│  │  │  对应操作:随机改色                     │       │     │
│  │  └─────────────────────────────────────┘       │     │
│  └─────────────────────────────────────────────┘     │
└─────────────────────────────────────────────────────┘

5.1 属性动画——颜色切换

属性动画是最内层、最细粒度的动画类型。它关注的是单个属性值从旧值到新值的变化过程。

private modifyRandomCard(): void {
  if (this.cardList.length === 0) return;
  const idx: number = Math.floor(Math.random() * this.cardList.length);
  const newColor: number = this.randomColor();

  animateTo({ duration: 400, curve: Curve.FastOutSlowIn }, () => {
    this.cardList[idx].color = newColor;
  });
}

CardItemView 中,左侧色条的背景色直接绑定在 @Prop 上:

Column()
  .width(12)
  .height('100%')
  .borderRadius({ topLeft: 12, bottomLeft: 12 })
  .backgroundColor(this.cardData.color)

animateTo 的工作原理:

  1. animateTo 被调用时,ArkTS 框架会开启一个"动画事务"
  2. 在事务开启的瞬间,框架快照所有将受影响属性的当前值(起始值)
  3. 执行回调函数,更新 @State 变量(目标值)
  4. 框架对比起始值和目标值,为每个变化的属性创建插值器
  5. 在 duration(400ms)内,按照 curve(FastOutSlowIn)曲线逐帧计算中间值
  6. 每帧将插值结果应用到实际的渲染属性上

为什么 color 的动画会平滑过渡?

backgroundColor 属性接收的是数值格式的颜色(0xAARRGGBB)。当起始值(比如 0x5B8FF9 天空蓝)和目标值(比如 0xF56C6C 番茄红)都是数值时,框架可以对 ARGB 四个通道分别进行插值,然后合成为中间色。这种按通道分解 + 独立插值 + 合成的方式,产生的颜色过渡比简单的透明度叠加更加自然。

Curve.FastOutSlowIn 曲线的特性:

这是 Material Design 中推荐的标准加速-减速曲线。其速度变化趋势如下:

速度
  ↑
  │         ╱╲
  │       ╱    ╲
  │     ╱        ╲
  │   ╱            ╲
  │ ╱                ╲
  └────────────────────→ 时间
  0        200ms      400ms
  • 前段(0→100ms):速度从 0 迅速增加到峰值——“快出”
  • 中段(100→250ms):保持较高速度——最明显的变化发生在此阶段
  • 后段(250→400ms):速度逐渐降低到 0——“慢进”,给人优雅的减速感

相比之下,线性曲线(Curve.Linear)全程匀速,在动画结束时显得突兀;FastOutSlowIn 的减速结尾让用户能感知到终点状态的"安顿感"。

5.2 过渡动画——增删卡片的进出场

过渡动画关注的是组件的生命周期事件——挂载时如何出现,卸载时如何消失。

.transition(
  TransitionEffect.translate({ x: 60, y: 40 })
    .combine(TransitionEffect.scale({ x: 0.6, y: 0.6 }))
    .combine(TransitionEffect.opacity(0))
    .animation({ duration: 400, curve: Curve.FastOutSlowIn })
)

TransitionEffect 的组合机制:

TransitionEffect 使用链式调用的组合模式。每个 .combine() 调用将一个新的效果叠加到已有的效果链上。在上面的例子中,实际产生的入场动画是:

  1. 位置从 (x + 60, y + 40) 平移到 (x, y)——从右下方飞入
  2. 缩放从 0.6 倍变化到 1.0 倍——从小到大展开
  3. 透明度从 0 变化到 1——从透明到可见

这三种效果同时进行,共同构成了"卡片从右下方放大显现"的复合效果。TransitionEffect 的组合是乘法性质的——最终效果是所有单个效果的叠加。

入场与离场的关系:

transition 定义的效果是双向的:

  • 入场:从定义的效果的初始值(translate(x:60, y:40) + scale(0.6) + opacity(0))过渡到组件的自然状态
  • 离场:从组件的自然状态过渡到定义的效果的初始值——即反向播放入场动画

开发者不需要为入场和离场分别定义不同的效果!除非你需要入场和离场不一样(例如入场从右边飞入,离场从左边飞走),这时可以使用以下方式:

// 如果需要入场和离场效果不同
.transition(
  TransitionEffect.asymmetric(
    TransitionEffect.translate({ x: 60, y: 40 })
      .combine(TransitionEffect.opacity(0)),
    TransitionEffect.translate({ x: -60, y: 0 })
      .combine(TransitionEffect.opacity(0))
  )
  .animation({ duration: 350 })
)

TransitionEffect.asymmetric() 接受两个参数:第一个是入场效果,第二个是离场效果。但本示例中我们不需要这种区分,入场离场用相同的逆向效果即可。

5.3 布局动画——整网重排

布局动画是最外层、最宏观的动画级别。当新增或删除卡片时,不仅仅是卡片本身在变化——所有卡片的位置都可能因为重排而发生变化

// 新增卡片
private addCard(): void {
  const color: number = this.randomColor();
  const newCard: CardItem = new CardItem(this.nextId++, '卡片 #' + this.nextId, color);

  animateTo({ duration: 350, curve: Curve.FastOutSlowIn }, () => {
    this.cardList.push(newCard);
    this.updateLayout();
  });
}

// 删除卡片
private deleteCard(id: number): void {
  animateTo({ duration: 300, curve: Curve.FastOutLinearIn }, () => {
    const idx: number = this.cardList.findIndex(c => c.id === id);
    if (idx !== -1) {
      this.cardList.splice(idx, 1);
      this.updateLayout();
    }
  });
}

布局动画的魔法在于属性联动:

  1. animateTo 开启事务
  2. cardList.push() / splice() 改变了数据源
  3. updateLayout() 重新生成了 positions 数组
  4. @State positions 更新,触发 build() 重新执行
  5. 对于保留下来的 CardItemView,它们的 posX/posY @Prop 收到了新值
  6. 因为这一切发生在 animateTo 的事务中,框架将这些位置变化视为动画
  7. 每个卡片从"旧位置"到"新位置"的移动都被自动补间
旧卡片数 旧位置 新卡片数 新位置 移动方向
第0个 (16, 16) 第0个 (16, 16) 不变
第1个 (182, 16) 第1个 (182, 16) 不变
第2个 (348, 16) 第2个 (348, 16) 不变
第3个 (16, 112) 第3个 (16, 112) 不变
第4个 (182, 112) 第4个 (182, 112) 不变
新卡片 (348, 112) 新增

上表是新增卡片前的布局对照。如果删除第2个卡片,则:

旧卡片数 旧位置 新卡片数 新位置 移动方向
第0个 (16, 16) 第0个 (16, 16) 不变
第1个 (182, 16) 第1个 (182, 16) 不变
第2个 (348, 16) ——被删—— —— 离场
第3个 (16, 112) 第2个 (16, 112) 不变
第4个 (182, 112) 第3个 (182, 112) 不变

如果删除第0个卡片,则所有后续卡片都向前移动一行:

旧卡片数 旧位置 新卡片数 新位置 移动方向
第0个 (16, 16) ——被删—— —— 离场
第1个 (182, 16) 第0个 (16, 16) ←左移
第2个 (348, 16) 第1个 (182, 16) ←左移
第3个 (16, 112) 第2个 (348, 16) ←左移+上移
第4个 (182, 112) 第3个 (16, 112) ←左移

可以看到,删掉一个卡片后,后面的卡片会向左、向上移动,填补空缺。这些移动都是并行的、平滑的、被 animateTo 驱动的补间动画。

5.4 三种动画的协作时序——以"删除"操作为例

理解三种动画在"删除"操作中的协作时序,是吃透本文示例的关键:

时间轴
 │
T+0ms ─── 用户点击 ✕ 按钮
 │
 │  ① animateTo({ duration: 300 }) 开始 → 开启动画事务
 │  ② splice 移除数据 → updateLayout 重算 positions
 │
 │  ┌── ③ 被移除卡片:trigger 离场 transition ──┐
 │  │    ├── opacity: 1.0 → 0.5                  │
 │  │    ├── scale: 1.0 → 0.8                    │
 │  │    └── translate: (0,0) → (-30, 0)         │
 │  │                                            │
 │  ├── ④ 保留卡片: position 属性变化 ─────────┤
 │  │    └── 从旧 (x, y) 过渡到新 (x', y')      │
 │  └────────────────────────────────────────    │
 │
T+50ms ─── 过渡动画进行中
 │  ┌── 被删卡片: opacity=0.75, scale=0.9, x=-15 │
 │  └── 保留卡片: 已完成约 1/6 的路程            │
 │
T+100ms ─── 过渡动画进行中
 │  ┌── 被删卡片: opacity=0.50, scale=0.8, x=-30 │
 │  └── 保留卡片: 已完成约 1/3 的路程            │
 │
T+200ms ─── 过渡动画进入后半段
 │  ┌── 被删卡片: opacity=0.25, scale=0.7, x=-45 │
 │  └── 保留卡片: 已完成约 2/3 的路程            │
 │
T+300ms ─── 动画结束
 │  ├── 被删卡片: opacity=0, scale=0.6, x=-60    → 节点销毁
 │  └── 保留卡片: 到达新位置                       → 布局稳定
 │
 ▼

关键洞察:过渡动画与布局动画是并行而非串行的。

被删除的卡片在其离场动画进行的同时,其他保留卡片已经开始向新位置移动。这种并行设计带来了两个好处:

  1. 视觉连续性:用户不会看到"先等删除动画结束,再看到其他卡片突然移动"的断层感
  2. 时间效率:总动画时长等于 max(过渡动画时长, 布局动画时长),而不是两者之和

5.5 不同操作的动画参数差异

本例中,三种类型的操作使用的动画参数各不相同:

操作 duration curve 动画类型(主角) 设计理由
新增卡片 350ms FastOutSlowIn 布局动画 + 入场过渡 新增是积极操作,需要适中的愉悦感;FastOutSlowIn 让卡片"弹"入视野
删除卡片 300ms FastOutLinearIn 布局动画 + 离场过渡 删除是消极操作,应该干脆利落不拖沓;FastOutLinearIn 快速衰减
修改颜色 400ms FastOutSlowIn 属性动画(颜色过渡) 颜色变化需要充裕的时间展示终点色;400ms 让用户清楚感知"颜色从 A 变成了 B"
清空 250ms FastOutLinearIn 批量离场过渡 批量操作追求效率;250ms 快速完成,不给用户等待感

这些时长的差异并非随意选择,而是遵循了两个设计原则:

  1. 操作语义匹配:创建(积极的)> 删除(消极的)> 清空(批量的),动画时长依次递减
  2. Fitts 定律的扩展:人机交互中,重要的反馈需要更多的视觉持续时间。颜色修改需要用户辨识新旧颜色的差异,所以给最长的 400ms;清空只是"全部消失",250ms 足矣

六、CardItemView 子组件深度解析

6.1 @Prop 装饰器与组件通信

@Component
struct CardItemView {
  /** @Prop 允许父组件在构造时传入数据,并建立响应式绑定 */
  @Prop cardData: CardItem = new CardItem(0, '', 0);
  @Prop posX: number = 0;
  @Prop posY: number = 0;
  /** 回调函数不需要装饰器——它不参与响应式更新 */
  onDelete?: () => void;

关键规则: 在 ArkTS 中,只有使用 @Prop@Link@Param 等装饰器的属性才能从父组件的构造参数中传值。普通的 private 属性是组件的内部状态,不能在构造时被外部初始化。

为什么 onDelete 不需要装饰器?

onDelete 是一个回调函数(() => void),它传递的是行为逻辑而非数据。回调函数不参与响应式更新——父组件不会因为传递了不同的回调而触发子组件的重新渲染。因此,它不需要装饰器,直接声明为普通的可选属性即可。

6.2 布局与动画的绑定链

.position({ x: this.posX, y: this.posY })
.transition(
  TransitionEffect.translate({ x: 60, y: 40 })
    .combine(TransitionEffect.scale({ x: 0.6, y: 0.6 }))
    .combine(TransitionEffect.opacity(0))
    .animation({ duration: 400, curve: Curve.FastOutSlowIn })
)

这两行链式调用的顺序并非随意——先 .position().transition()。在 ArkTS 的渲染管线中:

  1. 布局阶段(Layout):处理 .position(),确定组件在父容器中的位置
  2. 绘制阶段(Paint):处理 .transition(),应用进出场动画的视觉效果

如果顺序颠倒(transition 在前,position 在后),虽然在当前 SDK 版本中可能不会报错,但从语义上和渲染管线的自然流程来看,先定位再应用效果是更合理的选择。

6.3 卡片 UI 的细节设计

Row() {
  // 左侧色条
  Column()
    .width(12).height('100%')
    .borderRadius({ topLeft: 12, bottomLeft: 12 })
    .backgroundColor(this.cardData.color)

  // 中间信息区(含标题、ID、提示文字)

  // 右侧删除按钮
  Button() { Text('✕').fontSize(16).fontColor('#ff4444') }
    .width(36).height(36)
    .backgroundColor('rgba(255,68,68,0.08)')
    .borderRadius(18)
    .onClick(() => { this.onDelete?.(); })
}
.width(this.cardData.width).height(this.cardData.height)
.backgroundColor(Color.White)
.borderRadius(12)
.shadow({ radius: 6, color: 'rgba(0,0,0,0.10)', offsetX: 0, offsetY: 2 })

这个卡片设计包含三个视觉区域:

  1. 左侧色条(12vp 宽):作为卡片的颜色标识,也是颜色动画的载体。它的圆角只设置左上和左下,与父容器的整体圆角(12vp)搭配,形成"色条从卡片中延伸出来"的视觉效果。
  2. 中间信息区:使用 layoutWeight(1) 填充剩余空间,内部垂直排列三个 Text 组件,展示标题、ID 和提示文字。
  3. 右侧删除按钮:圆形(borderRadius(18) 是宽高 36 的一半),背景色微红透明(rgba(255,68,68,0.08)),悬浮感强烈但不刺眼。

七、ArkTS 严格模式下的避坑指南

在编写和调试本例的过程中,我遇到了几条典型的 ArkTS 编译错误。这里整理出来,希望帮助读者避免同样的弯路。

7.1 ForEach 回调中禁止非 UI 语法

错误信息:

ERROR: Only UI component syntax can be written here. At File: CustomLayoutDemo.ets:370:13

错误写法:

ForEach(this.cardList, (item: CardItem, index?: number) => {
  // ❌ ArkTS 禁止在 ForEach 回调中声明变量或调用计算函数
  const pos = calcPositions(this.cardList, this.containerWidth);
  CardItemView({ ... })
})

根因分析:

ArkTS 编译期对 ForEach 的回调体做了严格的语法限制——只允许嵌套的组件声明语法。这是 ArkTS 为了优化运行时性能所做的设计选择:编译器需要能够静态分析 UI 树的结构,以便进行差异比较和最小化更新。如果允许在回调中执行任意算法逻辑,编译器就无法在编译期确定组件的数量和布局。

解决方案:

将布局计算提前到 @State 变量中,在 ForEach 中仅做取值操作:

// ✅ 正确写法——在 ForEach 之外预计算,回调中只做取值
ForEach(this.cardList, (item: CardItem, index?: number) => {
  CardItemView({
    posX: (index !== undefined && index < this.positions.length)
      ? this.positions[index].x : 0,
    posY: (index !== undefined && index < this.positions.length)
      ? this.positions[index].y : 0,
    onDelete: () => { this.deleteCard(item.id); },
  })
}, (item: CardItem) => item.id.toString())

这里的 this.positions 已经是预计算好的 @State 数组,三元表达式(?:)在 ArkTS 中是允许的——它不是函数调用,而是编译期的条件表达式。

7.2 禁止隐式 any 类型

错误信息:

Use explicit types instead of "any", "unknown".

错误写法:

const colors = [0xFF6B81, 0x5B8FF9];  // ❌ 类型被推导为 any[]

根因分析:

ArkTS 的严格模式完全禁用了 anyunknown 类型。这与标准的 TypeScript 不同——TypeScript 中 let x; 默认是 any,而 ArkTS 中不允许模糊类型。这一设计决策源于鸿蒙的性能优化策略:明确类型可以让编译器在编译期生成更高效的字节码,避免运行时类型检查的开销。

解决方案:

const colors: number[] = [0xFF6B81, 0x5B8FF9];  // ✅ 显式标注类型

7.3 组件属性不可用 private 修饰

错误信息:

Property 'cardData' is private and can not be initialized through the component constructor.

错误写法:

@Component
struct CardItemView {
  private cardData: CardItem = ...;  // ❌ 编译报错
}

根因分析:

在 ArkTS 中,组件构造参数传递遵循"装饰器协议":

  • @Prop:告诉框架"这个值来自父组件,需要建立单向响应式绑定"
  • @Link:告诉框架"这个值来自父组件,需要建立双向响应式绑定"
  • private:告诉框架"这个值是组件内部自有的,外部不应该知道"

private 和构造参数传值的语义是矛盾的——private 意味着对外部不可见,但构造参数传值恰恰是"从外部向内部传递数据"。ArkTS 编译器严格禁止这种语义冲突。

解决方案:

@Component
struct CardItemView {
  @Prop cardData: CardItem = new CardItem(0, '', 0);  // ✅ 使用 @Prop
  @Prop posX: number = 0;
  @Prop posY: number = 0;
  onDelete?: () => void;  // 回调不需要装饰器
}

7.4 animateTo 弃用警告

警告信息:

'animateTo' has been deprecated.

这是一个警告而非错误。在 API 24 中,animateTo 仍然可以正常工作,但鸿蒙官方可能引入了新的动画 API(比如 animateanimation 的新变种)。在遇到重大变化之前,建议保持使用 animateTo,因为它仍然是文档中推荐的主要动画方式。如果后续版本中 animateTo 被完全移除,可以按照官方迁移指南替换为新 API,但核心的动画逻辑——使用 animateTo 包裹状态变化——这一模式不会改变。


八、自定义布局的扩展思考

8.1 从固定网格到瀑布流

本文实现的布局是"等高等宽网格"。如果要改成瀑布流效果(如 Pinterest),只需修改 calcPositions 算法:

/**
 * 瀑布流布局算法
 *
 * 核心策略:始终将下一张卡片放在当前"最矮"的列下方
 * 这称为"最短列优先"(Shortest Column First)策略
 */
function calcWaterfallPositions(cardList: CardItem[], parentWidth: number): CardPosition[] {
  if (cardList.length === 0 || parentWidth <= 0) return [];

  const colCount = 2;  // 瀑布流通常固定 2 列
  const cardW = cardList[0].width;
  const colHeights: number[] = new Array(colCount).fill(PADDING);

  return cardList.map((card) => {
    // 找到当前最矮的列
    let shortestCol = 0;
    for (let c = 1; c < colCount; c++) {
      if (colHeights[c] < colHeights[shortestCol]) {
        shortestCol = c;
      }
    }

    const pos: CardPosition = {
      x: PADDING + shortestCol * (cardW + COLUMN_GAP),
      y: colHeights[shortestCol],
    };

    // 更新该列的高度
    colHeights[shortestCol] += card.height + ROW_GAP;
    return pos;
  });
}

这个算法与本文使用的网格布局算法的核心差异仅在列选择策略上:

策略 网格布局 瀑布流布局
选择下一列的方式 i % colCount(按顺序轮转) 选当前最短的列
每列是否等高 是(所有行等高) 否(每列独立增长)
适用场景 统一卡片尺寸的图库 不同卡片尺寸的内容流
视觉效果 整齐的矩形网格 错落有致的自然排列

这个对比充分体现了 “自定义布局” 抽象层的威力——只需改变 calcPositions 这一个函数的逻辑,整个 UI 的排列方式就完全不同了。

8.2 从手动定位到 Layout 基类

对于需要双向交互(父组件根据子组件尺寸调整布局,子组件根据父容器约束调整自身尺寸)的复杂场景,ArkTS 提供了 Layout 基类(位于 @ohos.arkui.node 包),支持完整的测量-放置两阶段协议:

阶段 方法 职责 类比
测量 onMeasureSize(constraint: LayoutConstraint): MeasureResult 根据父容器约束测量自身应该占多大空间 “量体裁衣”——先量尺寸
放置 onPlaceChildren(children: Layoutable[], constraint: LayoutConstraint): void 遍历子组件,对每个子组件调用其 layout(x, y) 方法 “对号入座”——再安排位置

Layout 基类的适用场景:

  1. 子组件尺寸不统一:如富文本卡片、图文混排项
  2. 子组件需要自我声明尺寸:如根据内容自动换行的文本块
  3. 需要支持子组件尺寸变化时的回调:如自适应高度的输入框

Stack + .position() 方案的适用场景:

  1. 子组件尺寸统一(如本文的卡片)
  2. 位置完全由算法决定,不依赖子组件自身的尺寸意图
  3. 需要与 animateTo 紧密配合position 属性的变化天然支持动画补间

8.3 拖拽排序的可能性

在自定义布局的基础上,再加上手势识别,可以实现卡片的拖拽排序。核心思路如下:

// 伪代码——拖拽排序的核心逻辑
GestureGroup(GestureMode.Sequence, DragGesture(), ...)
  .onActionUpdate((event: GestureEvent) => {
    // 计算手指位置对应的格子索引
    const dragIndex = this.findNearestIndex(event.fingerX, event.fingerY);
    // 如果拖到了不同的格子,交换位置
    if (dragIndex !== this.currentDragIndex) {
      animateTo({ duration: 200 }, () => {
        this.swapCards(this.currentDragIndex, dragIndex);
        this.currentDragIndex = dragIndex;
        this.updateLayout();
      });
    }
  })

拖拽排序的实现需要:

  1. 为每个卡片附加拖拽手势识别器
  2. 在拖拽过程中根据手指位置实时计算目标位置
  3. animateTo 驱动被拖拽卡片的跟随运动和其他卡片的换位动画

这是"自定义布局 + 动画 + 手势"三者融合的进一步进阶方向。


九、性能考量与优化

9.1 避免不必要的布局重算

布局重算(调用 updateLayout())是动画过程中的核心开销,需要精打细算。

第一道防线——onAreaChange 防抖阈值:

if (w > 0 && Math.abs(w - this.containerWidth) > 0.5) {
  this.containerWidth = w;
  this.updateLayout();
}

0.5vp 的容差阈值过滤了因子组件内边距微调、字体渲染变化等导致的微小宽度波动。这些微调在视觉上不可察觉,但如果不加过滤,每一次微调都会触发完整的 updateLayout(),包括 calcPositions 的计算和 ForEach 子组件的 @Prop 更新。

第二道防线——纯函数的时间复杂度:

calcPositionscomputeStackHeight 都是 O(n) 纯函数。在 ArkTS 引擎的 JIT 优化下,对于数百张卡片的规模,这些函数的执行时间通常不超过 0.1ms。即使每次动画帧都执行一次(通常每秒 60 帧),也不会有可感知的性能压力。

第三道防线——@State 的精准触发:

更新 positions 时,框架只会触发 @Prop 值发生变化的 CardItemView 实例的更新。如果一个卡片的位置没有变化(如新增场景中第一行的卡片),它的 CardItemView 不会被重新渲染。这种"按需更新"的机制是声明式 UI 框架性能优越的核心原因。

9.2 ForEach 的 keyGenerator 重要性

ForEach(this.cardList, (item: CardItem) => {
  CardItemView({ ... })
}, (item: CardItem) => {
  return item.id.toString();  // ✅ 用唯一且稳定的 id 做 key
})

如果没有提供正确的 key,或者使用数组索引作为 key:

// ❌ 错误做法——使用索引作为 key
ForEach(this.cardList, (item, index) => {
  CardItemView({ ... })
}, (item, index) => index.toString())

当使用索引作为 key 时,删除第一个卡片会导致以下问题:

  • 删除前:key 为 “0”, “1”, “2”, “3”, “4”
  • 删除后:key 为 “0”, “1”, “2”, “3”, “4”(但内容已经全部错位了!)

框架会认为所有卡片都"保留"了(key 相同),只是内容变化了——这意味着所有卡片都会执行"内容更新"而非"被移除卡片的离场动画 + 保留卡片的补间动画"。结果是所有卡片同时原地闪烁更新内容,而不是平滑地移动位置和淡出。

使用 id 作为 key 时:

  • 删除前:key 为 “1”, “2”, “3”, “4”, “5”
  • 删除后:key 为 “1”, “2”, “4”, “5”(框架识别出 key “3” 不见了)

框架能准确识别:

  • key “3” 被移除 → 触发 CardItemView(3) 的离场 transition
  • 其他 key 保留但属性变化 → 触发布局动画补间

正确、稳定的 keyGenerator 是保证动画正确的必要条件。

9.3 动画性能的硬性保障

ArkTS 的动画引擎运行在独立的渲染线程上(与 UI 线程分离)。这意味着:

  1. 即使 UI 线程被其他计算阻塞,动画仍然可以保持流畅的帧率
  2. 动画的插值计算不会阻塞用户交互响应
  3. 多个并行动画(如卡片各自的入场动画)由 GPU 统一调度

对于本例这种轻量级的卡片动画场景(卡片数量通常不会超过 50 张),动画性能完全不需要担心。只有当动画涉及复杂的遮罩、模糊、大尺寸图片缩放时,才需要考虑 GPU 渲染优化。


十、完整源码

以下为本文示例的完整可编译源码(已通过 HarmonyOS NEXT API 24 编译验证)。你可以直接将其复制到项目中运行:

/**
 * 自定义布局与动画结合示例 —— 增删改动画效果场景
 *
 * ========== 布局要点 ==========
 *
 * 【什么是自定义布局?】
 * 在 ArkTS 中,"自定义布局" 是指开发者不依赖标准容器(如 Column、Row、Flex)
 * 的默认排列规则,而是自行计算并指定每个子组件的位置与尺寸。
 *
 * 【本示例的实现方式】
 * 使用 Stack 作为布局容器,通过 .position() 对每个子组件进行精确的
 * (x, y) 坐标定位,从而实现"自适应网格"排列效果。这是最底层、
 * 最灵活的"自定义布局"方案 —— 所有位置完全由代码决定。
 *
 * 【动画的三层应用】
 * 1. 属性动画:animateTo 包裹状态变化,驱动背景色、位置等属性平滑过渡
 * 2. 过渡动画:.transition() 定义组件出现/消失时的进出场效果
 * 3. 布局动画:增删数据时,所有卡片的位置重新计算并自动补间
 *
 * 【布局策略】
 * - 卡片以自适应网格排列,每行最多 3 列,列间距 12vp,行间距 16vp
 * - 新增卡片从右下缩放淡入
 * - 删除卡片向左缩小淡出
 * - 修改卡片颜色时有色彩渐变过渡
 */

// ---------- 数据模型 ----------

/** 单个卡片的二维坐标(由布局计算得出) */
interface CardPosition {
  x: number;
  y: number;
}

/** 卡片数据项 */
class CardItem {
  id: number = 0;
  title: string = '';
  color: number = 0;          // 0xAARRGGBB 格式,例如 0xFF6B81
  readonly width: number = 150;
  readonly height: number = 80;

  constructor(id: number, title: string, color: number) {
    this.id = id;
    this.title = title;
    this.color = color;
  }
}

// ---------- 布局常量 ----------

const COLUMN_MAX = 3;         // 每行最多 3 列
const COLUMN_GAP = 16;        // 列间距 16vp
const ROW_GAP = 16;           // 行间距 16vp
const PADDING = 16;           // 内边距 16vp

/**
 * 核心布局算法:计算所有卡片的 (x, y) 位置
 *
 * 策略:从左到右、从上到下排列,每行最多 COLUMN_MAX 列。
 * 列数根据父容器宽度自适应 —— 这是"自定义布局"的精髓:
 * 框架不替我们决定排列方式,而是由开发者完全掌控。
 */
function calcPositions(cardList: CardItem[], parentWidth: number): CardPosition[] {
  const positions: CardPosition[] = [];
  if (cardList.length === 0 || parentWidth <= 0) return positions;

  const cardW = cardList[0].width;
  const cardH = cardList[0].height;
  const availableW = parentWidth - PADDING * 2;
  const colCount = Math.min(COLUMN_MAX,
    Math.max(1, Math.floor(availableW / (cardW + COLUMN_GAP))));

  for (let i = 0; i < cardList.length; i++) {
    const col = i % colCount;            // 当前在第几列
    const row = Math.floor(i / colCount); // 当前在第几行
    positions.push({
      x: PADDING + col * (cardW + COLUMN_GAP),
      y: PADDING + row * (cardH + ROW_GAP),
    });
  }
  return positions;
}

/** 计算布局容器的总高度(供 Scroll 使用) */
function computeStackHeight(cardList: CardItem[], parentWidth: number): number {
  if (cardList.length === 0 || parentWidth <= 0) return 0;
  const cardW = cardList[0].width;
  const cardH = cardList[0].height;
  const availableW = parentWidth - PADDING * 2;
  const colCount = Math.min(COLUMN_MAX,
    Math.max(1, Math.floor(availableW / (cardW + COLUMN_GAP))));
  const rowCount = Math.ceil(cardList.length / colCount);
  return PADDING + rowCount * (cardH + ROW_GAP) - ROW_GAP + PADDING;
}

// ---------- 卡片子组件 ----------

@Component
struct CardItemView {
  /** @Prop 允许父组件通过构造参数传值 */
  @Prop cardData: CardItem = new CardItem(0, '', 0);
  @Prop posX: number = 0;
  @Prop posY: number = 0;
  /** 回调函数不需要装饰器 */
  onDelete?: () => void;

  build() {
    Row() {
      // 左侧色条 —— 颜色变化时 @Prop 驱动自动过渡
      Column()
        .width(12).height('100%')
        .borderRadius({ topLeft: 12, bottomLeft: 12 })
        .backgroundColor(this.cardData.color)

      // 中间信息区
      Column() {
        Text(this.cardData.title)
          .fontSize(16).fontWeight(FontWeight.Medium)
          .textAlign(TextAlign.Start).width('100%')
        Text('ID: ' + this.cardData.id)
          .fontSize(12).fontColor('#888888')
          .textAlign(TextAlign.Start).width('100%').margin({ top: 6 })
        Text('点击「随机改色」可切换颜色')
          .fontSize(11).fontColor('#aaaaaa')
          .textAlign(TextAlign.Start).width('100%').margin({ top: 4 })
      }
      .height('100%').layoutWeight(1)
      .padding({ left: 10, right: 8, top: 8, bottom: 8 })
      .alignItems(HorizontalAlign.Start)

      // 删除按钮
      Button() { Text('✕').fontSize(16).fontColor('#ff4444') }
        .width(36).height(36)
        .backgroundColor('rgba(255,68,68,0.08)')
        .borderRadius(18).margin({ right: 8 })
        .onClick(() => { this.onDelete?.(); })
    }
    .width(this.cardData.width).height(this.cardData.height)
    .backgroundColor(Color.White).borderRadius(12)
    .shadow({ radius: 6, color: 'rgba(0,0,0,0.10)', offsetX: 0, offsetY: 2 })
    // 【自定义布局核心】精确坐标定位
    .position({ x: this.posX, y: this.posY })
    // 【过渡动画】入场缩放+位移+淡出,离场自动反向播放
    .transition(
      TransitionEffect.translate({ x: 60, y: 40 })
        .combine(TransitionEffect.scale({ x: 0.6, y: 0.6 }))
        .combine(TransitionEffect.opacity(0))
        .animation({ duration: 400, curve: Curve.FastOutSlowIn })
    )
  }
}

// ---------- 主页面 ----------

@Entry
@Component
struct CustomLayoutDemo {
  @State private cardList: CardItem[] = [];
  private nextId: number = 1;
  @State private containerWidth: number = 360;
  /** 预计算的位置数组 —— 在 ForEach 中不能做计算,所以提前算好 */
  @State private positions: CardPosition[] = [];

  private readonly colorPalette: number[] = [
    0xFF6B81, 0x5B8FF9, 0x67C23A, 0xE6A23C,
    0x909399, 0xF56C6C, 0x36CFC9, 0x9B59B6,
  ];

  aboutToAppear(): void {
    const initColors: number[] = [0x5B8FF9, 0x67C23A, 0xE6A23C, 0xFF6B81, 0x36CFC9];
    for (let i = 0; i < initColors.length; i++) {
      this.cardList.push(new CardItem(this.nextId++, '卡片 #' + this.nextId, initColors[i]));
    }
    this.updateLayout();
  }

  /** 状态变化后重新计算布局 */
  private updateLayout(): void {
    this.positions = calcPositions(this.cardList, this.containerWidth);
  }

  private randomColor(): number {
    const idx: number = Math.floor(Math.random() * this.colorPalette.length);
    return this.colorPalette[idx];
  }

  /** 新增卡片 —— animateTo 驱动布局动画 + 子组件入场过渡 */
  private addCard(): void {
    const color: number = this.randomColor();
    const newCard: CardItem = new CardItem(this.nextId++, '卡片 #' + this.nextId, color);
    animateTo({ duration: 350, curve: Curve.FastOutSlowIn }, () => {
      this.cardList.push(newCard);
      this.updateLayout();
    });
  }

  /** 删除卡片 —— animateTo 驱动离场过渡 + 其余卡片位置补间 */
  private deleteCard(id: number): void {
    animateTo({ duration: 300, curve: Curve.FastOutLinearIn }, () => {
      const idx: number = this.cardList.findIndex(c => c.id === id);
      if (idx !== -1) {
        this.cardList.splice(idx, 1);
        this.updateLayout();
      }
    });
  }

  /** 改色 —— animateTo 驱动颜色属性过渡 */
  private modifyRandomCard(): void {
    if (this.cardList.length === 0) return;
    const idx: number = Math.floor(Math.random() * this.cardList.length);
    const newColor: number = this.randomColor();
    animateTo({ duration: 400, curve: Curve.FastOutSlowIn }, () => {
      this.cardList[idx].color = newColor;
    });
  }

  private clearAll(): void {
    animateTo({ duration: 250, curve: Curve.FastOutLinearIn }, () => {
      this.cardList = [];
      this.updateLayout();
    });
  }

  build() {
    Column() {
      // ---- 标题 ----
      Text('自定义布局 · 增删改动画')
        .fontSize(20).fontWeight(FontWeight.Bold)
        .margin({ top: 12, bottom: 4 })
      Text('卡片以自适应网格排列,每行最多 ' + COLUMN_MAX + ' 列')
        .fontSize(13).fontColor('#888888').margin({ bottom: 12 })

      // ---- 操作按钮栏 ----
      Row() {
        Button('➕ 新增卡片')
          .fontSize(14).fontColor(Color.White)
          .backgroundColor('#5B8FF9').borderRadius(20).height(38)
          .layoutWeight(1).margin({ right: 6 })
          .onClick(() => { this.addCard(); })
        Button('🎨 随机改色')
          .fontSize(14).fontColor(Color.White)
          .backgroundColor('#67C23A').borderRadius(20).height(38)
          .layoutWeight(1).margin({ left: 6, right: 6 })
          .onClick(() => { this.modifyRandomCard(); })
        Button('🗑 清空')
          .fontSize(14).fontColor(Color.White)
          .backgroundColor('#F56C6C').borderRadius(20).height(38)
          .layoutWeight(1).margin({ left: 6 })
          .onClick(() => { this.clearAll(); })
      }.width('100%').padding({ left: 16, right: 16 }).margin({ bottom: 8 })

      // ---- 统计 ----
      Row() {
        Text('当前卡片数:' + this.cardList.length)
          .fontSize(13).fontColor('#888888')
      }.width('100%').padding({ left: 16 }).margin({ bottom: 6 })

      // ---- 核心:自定义布局区域 ----
      Scroll() {
        Stack() {
          ForEach(this.cardList, (item: CardItem, index?: number) => {
            CardItemView({
              cardData: item,
              posX: (index !== undefined && index < this.positions.length)
                ? this.positions[index].x : 0,
              posY: (index !== undefined && index < this.positions.length)
                ? this.positions[index].y : 0,
              onDelete: () => { this.deleteCard(item.id); },
            })
          }, (item: CardItem) => item.id.toString())
        }
        .width('100%')
        .height(computeStackHeight(this.cardList, this.containerWidth))
        .onAreaChange((_oldValue: Area, newValue: Area) => {
          const w: number = newValue.width as number;
          if (w > 0 && Math.abs(w - this.containerWidth) > 0.5) {
            this.containerWidth = w;
            this.updateLayout();
          }
        })
      }.width('100%').layoutWeight(1).backgroundColor('#F0F2F5')
    }.width('100%').height('100%').backgroundColor('#F0F2F5')
  }
}

十一、总结与展望

11.1 本文要点回顾

  1. 自定义布局的本质是开发者掌控坐标计算,而非依赖框架的自动排列。ArkTS 中可以通过 Stack + .position()(轻量级)或继承 Layout 基类(重量级)两种方式实现。前者适合位置由算法决定的场景,后者适合子组件需要表达自身尺寸意图的复杂场景。

  2. 动画的三层架构——属性动画(颜色过渡)、过渡动画(进出场效果)、布局动画(位置移动)——可以独立使用,也可以在同一操作中并行协作。本文的"删除卡片"操作就同时触发了离场过渡和布局重排两种动画,它们在视觉上无缝融合。

  3. ArkTS 严格模式虽然增加了类型注解的工作量,但换来了更可靠的编译期检查和更健壮的运行时行为。三条最常见的问题——ForEach 回调中的非 UI 语法、隐式 any 类型、组件 private 属性的构造传值——都有明确的解决方案。

  4. 状态驱动布局联动是声明式 UI 框架的核心模式:@State 数据变化 → 布局算法重算 → 响应式绑定更新 → 动画引擎补间。理解这个数据流,就理解了 ArkTS 声明式编程的精髓。

11.2 进一步探索方向

  • 拖拽排序:在自定义布局的基础上,结合 PanGesture 手势识别和 animateTo,可以实现卡片拖拽交换位置。关键点在于:拖拽过程中实时计算手指位置对应的目标索引,用 animateTo 驱动其他卡片的避让动画。

  • LazyForEach + 虚拟列表:当卡片数量达到数百张时,使用 LazyForEach 替代 ForEach 实现按需渲染,确保列表的流畅滚动性能。需要配合 cachedCount 参数控制预渲染缓冲区的数量。

  • SpringEffect 弹性动画:替代标准缓动曲线,让卡片移动时带有弹簧回弹效果。在 ArkTS 中可以通过自定义 Curve 实现弹簧物理模拟,或者使用 springMotion 相关 API。

  • TransitionEffect 的更多组合:尝试旋转(rotate)、模糊(blur)、色彩滤镜(colorFilter)等更多过渡效果。TransitionEffect 的组合能力非常强大,可以实现类似 Keynote 中 Magic Move 的效果。

  • 响应式布局断点:结合 MediaQuery 监听设备类型(手机、平板、折叠屏),在不同断点下使用不同的列数配置(如手机 2 列、平板 4 列),实现真正的自适应布局。

11.3 写在最后

自定义布局与动画是 ArkTS 开发中的进阶话题,但也是最能提现鸿蒙原生开发魅力的领域。当你能够完全掌控每个组件的位置和行为,并辅以流畅细腻的动画过渡时,你的应用将在用户体验上达到一个新的高度。

希望本文的实践案例和深入分析能够帮助你在 ArkTS 的开发道路上更进一步。如果你有任何问题或见解,欢迎在评论区分享交流。


本文所有代码已在 HarmonyOS NEXT(API 24)DevEco Studio 5.0+ 中编译并运行验证。不同 SDK 版本的 API 可能有细微差异,请以官方文档为准。

Logo

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

更多推荐