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

一、引言
在 HarmonyOS NEXT 的 ArkTS 开发体系中,布局(Layout)与动画(Animation)是构建优质用户界面的两大基石。标准布局容器如 Column、Row、Flex 能解决大部分常规场景,但当我们遇到非规则的、动态变化的、高度定制的 UI 需求时,就必须掌握"自定义布局"这一进阶能力。与此同时,动画不再是锦上添花的点缀——在鸿蒙的设计语言中,动画是用户体验的核心组成部分,它能传达状态变化、引导用户注意力、提供操作反馈,以及赋予界面生命力。
本文以一个卡片增删改管理器为实战案例,深入剖析鸿蒙 ArkTS 中自定义布局的实现原理,以及如何将三种不同类型的动画(属性动画、过渡动画、布局动画)无缝融合到同一场景中。文章将从设计思想、编码实现到性能优化,逐层展开,帮助读者建立完整的 ArkTS 布局动画知识体系。
1.1 为什么选择这个场景
增删改(CRUD)是应用开发中最常见的操作模式。无论是备忘录应用中的笔记管理、电商应用中的购物车编辑,还是社交应用中的动态发布与删除,都离不开"列表数据的变化"这一基本交互。当这些变化发生在自定义布局中时,动画的作用尤为突出:
- 新增:让用户感知到数据已经成功添加,并看到它从哪里出现
- 删除:让用户确认操作生效,同时保持其他内容的视觉连续性
- 修改:通过平滑的颜色或位置变化展示数据的更新过程
- 批量操作:用动画串联多个变化,避免突兀的瞬间跳变
正是因为这个场景的普遍性和代表性,我们选择它作为 ArkTS 布局动画技术的教学载体。
二、HarmonyOS NEXT 的声明式 UI 体系概览
2.1 ArkTS 的设计哲学
ArkTS 是鸿蒙原生应用开发的推荐语言,基于 TypeScript 构建,但引入了装饰器驱动的声明式 UI 范式。与传统的命令式 UI 开发(如 Java Swing、Android XML + View 体系)不同,声明式 UI 不再需要开发者手动调用 addChild、removeView、invalidate 等命令,而是通过"描述状态与 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 框架会:
- 标记该组件为"脏"
- 在下一个帧循环中重新执行
build()方法 - 对比新旧两棵虚拟 UI 树的差异
- 将差异转化为最小的实际布局/绘制操作
而当变化发生在 animateTo() 回调内部时,框架会额外记录属性的起始值和目标值,在指定的时长内不断插值,驱动组件从起始状态平滑过渡到目标状态。
三、案例需求与架构设计
3.1 场景描述
我们要构建一个卡片管理器应用,包含以下交互操作:
- 新增卡片:点击「新增卡片」按钮,一个新的卡片以动画方式从右下方飞入现有网格的末尾位置,同时已有卡片自动调整位置以保持网格对齐
- 删除卡片:点击卡片右上角的 ✕ 按钮,该卡片向左缩小并淡出,其余卡片平滑前移填补空缺
- 修改卡片颜色:点击「随机改色」按钮,随机选择一张卡片,其左侧色条的颜色从当前色平滑过渡到新颜色
- 清空所有卡片:点击「清空」按钮,所有卡片以动画方式依次消失
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;
}
}
设计思考——为什么 color 用 number 而非 string?
@Prop 装饰器要求属性类型必须是可比较的。当父组件的 animateTo 修改 color 值时,框架需要判断值是否真的发生了变化,从而决定是否触发 CardItemView 的更新。number 类型的值可以精确比较(===),而 string 类型的颜色值(如 "#FF6B81")在比较时需要额外的解析开销。因此,使用数值形式不仅更高效,而且在 ArkTS 的严格类型系统中也更受推荐。
设计思考——width 和 height 为什么放在数据模型里?
大多数布局方案将尺寸信息放在样式中。但在自定义布局中,calcPositions() 函数需要知道每个卡片的尺寸才能计算位置。如果将尺寸信息放在数据模型里,布局算法完全独立于 UI 组件——即使没有 ArkTS 环境,我们也能对 calcPositions 进行纯函数测试。这是一种"关注点分离"的设计决策。
4.2 布局常量定义
/** 每行最大列数 */
const COLUMN_MAX = 3;
/** 列间距(vp) */
const COLUMN_GAP = 16;
/** 行间距(vp) */
const ROW_GAP = 16;
/** 左/上内边距(vp) */
const PADDING = 16;
将这些数值定义为文件级别的常量而非类成员变量,基于两个考虑:
- 纯函数可访问性:
calcPositions和computeStackHeight是文件级函数(非方法),它们不能访问类的成员变量。定义在文件作用域的常量可以被这些纯函数直接引用。 - 集中管理:当我们需要调整布局的间距时,只需修改这些常量,无需在多个方法之间查找硬编码的数字。
在 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;
}
算法逐行解析:
- 边界检测:空列表或宽度无效时返回空数组,避免后续计算除以零
- 计算自适应列数:
availableW:扣除左右内边距后真正可用的宽度availableW / (cardW + COLUMN_GAP):计算理论上可以容纳多少列Math.floor():向下取整,因为列必须是整数Math.max(1, ...):最少保持一列,即使屏幕很窄Math.min(COLUMN_MAX, ...):最多不超过三列,防止过宽
- 双重循环映射(通过取模和整除实现,而非嵌套 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() 中,会破坏声明式结构的清晰度,也增加了单元测试的难度。
注意此处与 calcPositions 中 colCount 的计算逻辑必须完全一致。如果两者计算方式不同(比如一个用了 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()调用中重复计算
每当 cardList 或 containerWidth 发生变化,我们调用 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 的工作原理:
- 当
animateTo被调用时,ArkTS 框架会开启一个"动画事务" - 在事务开启的瞬间,框架快照所有将受影响属性的当前值(起始值)
- 执行回调函数,更新
@State变量(目标值) - 框架对比起始值和目标值,为每个变化的属性创建插值器
- 在 duration(400ms)内,按照 curve(FastOutSlowIn)曲线逐帧计算中间值
- 每帧将插值结果应用到实际的渲染属性上
为什么 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() 调用将一个新的效果叠加到已有的效果链上。在上面的例子中,实际产生的入场动画是:
- 位置从
(x + 60, y + 40)平移到(x, y)——从右下方飞入 - 缩放从 0.6 倍变化到 1.0 倍——从小到大展开
- 透明度从 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();
}
});
}
布局动画的魔法在于属性联动:
animateTo开启事务cardList.push()/splice()改变了数据源updateLayout()重新生成了positions数组@State positions更新,触发build()重新执行- 对于保留下来的
CardItemView,它们的posX/posY@Prop收到了新值 - 因为这一切发生在
animateTo的事务中,框架将这些位置变化视为动画 - 每个卡片从"旧位置"到"新位置"的移动都被自动补间
| 旧卡片数 | 旧位置 | 新卡片数 | 新位置 | 移动方向 |
|---|---|---|---|---|
| 第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 → 节点销毁
│ └── 保留卡片: 到达新位置 → 布局稳定
│
▼
关键洞察:过渡动画与布局动画是并行而非串行的。
被删除的卡片在其离场动画进行的同时,其他保留卡片已经开始向新位置移动。这种并行设计带来了两个好处:
- 视觉连续性:用户不会看到"先等删除动画结束,再看到其他卡片突然移动"的断层感
- 时间效率:总动画时长等于 max(过渡动画时长, 布局动画时长),而不是两者之和
5.5 不同操作的动画参数差异
本例中,三种类型的操作使用的动画参数各不相同:
| 操作 | duration | curve | 动画类型(主角) | 设计理由 |
|---|---|---|---|---|
| 新增卡片 | 350ms | FastOutSlowIn |
布局动画 + 入场过渡 | 新增是积极操作,需要适中的愉悦感;FastOutSlowIn 让卡片"弹"入视野 |
| 删除卡片 | 300ms | FastOutLinearIn |
布局动画 + 离场过渡 | 删除是消极操作,应该干脆利落不拖沓;FastOutLinearIn 快速衰减 |
| 修改颜色 | 400ms | FastOutSlowIn |
属性动画(颜色过渡) | 颜色变化需要充裕的时间展示终点色;400ms 让用户清楚感知"颜色从 A 变成了 B" |
| 清空 | 250ms | FastOutLinearIn |
批量离场过渡 | 批量操作追求效率;250ms 快速完成,不给用户等待感 |
这些时长的差异并非随意选择,而是遵循了两个设计原则:
- 操作语义匹配:创建(积极的)> 删除(消极的)> 清空(批量的),动画时长依次递减
- 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 的渲染管线中:
- 布局阶段(Layout):处理
.position(),确定组件在父容器中的位置 - 绘制阶段(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 })
这个卡片设计包含三个视觉区域:
- 左侧色条(12vp 宽):作为卡片的颜色标识,也是颜色动画的载体。它的圆角只设置左上和左下,与父容器的整体圆角(12vp)搭配,形成"色条从卡片中延伸出来"的视觉效果。
- 中间信息区:使用
layoutWeight(1)填充剩余空间,内部垂直排列三个 Text 组件,展示标题、ID 和提示文字。 - 右侧删除按钮:圆形(
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 的严格模式完全禁用了 any 和 unknown 类型。这与标准的 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(比如 animate 或 animation 的新变种)。在遇到重大变化之前,建议保持使用 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 基类的适用场景:
- 子组件尺寸不统一:如富文本卡片、图文混排项
- 子组件需要自我声明尺寸:如根据内容自动换行的文本块
- 需要支持子组件尺寸变化时的回调:如自适应高度的输入框
Stack + .position() 方案的适用场景:
- 子组件尺寸统一(如本文的卡片)
- 位置完全由算法决定,不依赖子组件自身的尺寸意图
- 需要与 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();
});
}
})
拖拽排序的实现需要:
- 为每个卡片附加拖拽手势识别器
- 在拖拽过程中根据手指位置实时计算目标位置
- 用
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 更新。
第二道防线——纯函数的时间复杂度:
calcPositions 和 computeStackHeight 都是 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 线程分离)。这意味着:
- 即使 UI 线程被其他计算阻塞,动画仍然可以保持流畅的帧率
- 动画的插值计算不会阻塞用户交互响应
- 多个并行动画(如卡片各自的入场动画)由 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 本文要点回顾
-
自定义布局的本质是开发者掌控坐标计算,而非依赖框架的自动排列。ArkTS 中可以通过
Stack + .position()(轻量级)或继承Layout基类(重量级)两种方式实现。前者适合位置由算法决定的场景,后者适合子组件需要表达自身尺寸意图的复杂场景。 -
动画的三层架构——属性动画(颜色过渡)、过渡动画(进出场效果)、布局动画(位置移动)——可以独立使用,也可以在同一操作中并行协作。本文的"删除卡片"操作就同时触发了离场过渡和布局重排两种动画,它们在视觉上无缝融合。
-
ArkTS 严格模式虽然增加了类型注解的工作量,但换来了更可靠的编译期检查和更健壮的运行时行为。三条最常见的问题——ForEach 回调中的非 UI 语法、隐式 any 类型、组件 private 属性的构造传值——都有明确的解决方案。
-
状态驱动布局联动是声明式 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 可能有细微差异,请以官方文档为准。
更多推荐


所有评论(0)