HarmonyOS应用《民族图鉴》开发第47篇:动画与动效基础——属性动画、显式动画、转场动画深度解析

📖 引言
如果说 UI 是应用的"皮囊",那动画就是应用的"灵魂"。
一个没有动画的应用,就像一张静态的海报——信息都在,但没有生气。好的动画能让界面"活"起来:按钮按下的反馈、页面切换的过渡、列表滚动的惯性、加载时的转动……这些细节累积起来,就是"高级感"的来源。
但动画不是越多越好。乱用动画,会让界面显得花哨、廉价,甚至干扰用户操作。好的动画应该是"润物细无声"的——用户感觉到舒服,但说不出来为什么舒服。
ArkUI 提供了非常丰富的动画能力:属性动画、显式动画、转场动画、弹簧动画、路径动画……但很多开发者只会用最简单的 .animation() 加个持续时间,其他的都不太清楚。
本文我们就从动画的本质讲起,深入到 ArkUI 的三大动画体系,结合「民族图鉴」项目中的实际场景(卡片点击效果、页面转场、点赞动画、下拉刷新等),带你系统性地掌握鸿蒙动画开发。
🎯 学习目标
完成本文后,你将能够:
- ✅ 理解动画的本质与核心要素(时间、插值、属性变化)
- ✅ 掌握属性动画(component animation)的原理与使用场景
- ✅ 掌握显式动画(animateTo)的原理与使用技巧
- ✅ 理解转场动画(transition)的三种类型与使用方式
- ✅ 学会弹簧动画、缓动曲线的选择与参数调优
- ✅ 掌握列表动画、共享元素动画等高级效果
- ✅ 避免动画的常见坑,写出流畅、自然、高性能的动画
💡 需求分析
为什么需要动画?
动画不是"锦上添花"的装饰品,它有实实在在的功能价值:
1. 反馈用户操作
- 用户点了按钮,按钮缩一下,用户就知道"我点到了"
- 没有反馈,用户会疑惑"刚才点到了吗?",甚至重复点击
2. 引导用户注意力
- 新内容出现时,淡入 + 从下往上滑,用户自然就注意到了
- 重要的操作结果,用动画强调,用户不容易忽略
3. 表达空间关系
- 页面从右边滑进来 = “这是下一层页面”
- 页面从底部弹上来 = “这是一个临时弹窗”
- 元素从 A 位置过渡到 B 位置 = “这是同一个东西”
4. 提升品牌质感
- 统一的动画风格,是产品调性的一部分
- 动画做得好,用户会觉得"这个 App 很精致"
动画的设计原则
在写代码之前,先搞清楚"什么样的动画是好动画":
| 原则 | 说明 | 反例 |
|---|---|---|
| 快 | 动画时长 200-300ms,不要让用户等 | 一个转场动画 1 秒钟,用户急死 |
| 顺 | 缓动曲线自然,不要生硬的线性动画 | 匀速运动,像机器人一样 |
| 少 | 动画要克制,不要什么都动 | 满屏飞,用户不知道看哪 |
| 意 | 动画有意义,不是为了动而动 | 毫无理由的旋转、缩放 |
| 轻 | 动画不能卡,60fps 是底线 | 掉帧严重,还不如不做 |
💡 动画的黄金法则:用户感觉不到动画的存在,但感觉应用"很流畅"、“很跟手”——这就是最好的动画。
「民族图鉴」的动画场景
「民族图鉴」项目中有很多典型的动画场景:
| 场景 | 动画类型 | 技术方案 |
|---|---|---|
| 卡片按下效果 | 属性动画 | .animation() + 状态变量 |
| 页面切换 | 转场动画 | animateTo + transition |
| 收藏点赞动效 | 显式动画 | animateTo + 弹簧曲线 |
| 数字滚动(浏览数) | 属性动画 | @State + 计算属性 |
| Tab 切换下划线 | 属性动画 | .animation() |
| 下拉刷新 | 组合动画 | 旋转 + 位移动画 |
| 图片加载淡入 | 显式动画 | animateTo + opacity |
| 悬浮窗展开收起 | 转场动画 | transition |
🛠️ 核心实现
步骤1:动画的核心概念
在讲具体的 API 之前,先搞懂动画的几个核心概念。所有动画系统,不管是 Web 的 CSS 动画、Android 的属性动画、还是 iOS 的 CoreAnimation,底层原理都是一样的。
1.1 动画的本质
动画的本质是什么?
动画 = 属性值随时间变化
比如一个位移动画,就是把 translateY 的值,在 300ms 内,从 0 变到 100。每一帧更新一次值,重绘一次,连起来就是动画了。
时间(ms): 0 50 100 150 200 250 300
translateY: 0 10 30 50 70 90 100
每一帧(通常是 16.6ms 一帧,60fps)更新一次属性值,渲染引擎根据新的值重绘界面。因为帧之间的变化很小,人眼就看到了"连续的动画"。
1.2 动画的三要素
任何动画都有三个核心要素:
1. 动画属性(Property)
什么属性在变?位置?大小?透明度?颜色?旋转?
2. 时长(Duration)
动画持续多久?100ms?300ms?1秒?
3. 缓动曲线(Easing / Curve)
属性值怎么随时间变化?匀速?先快后慢?先慢后快?还是弹一下?
1.3 缓动曲线(Easing Curve)
缓动曲线是动画的"灵魂"。同样的时长、同样的属性变化,用不同的曲线,感觉完全不一样。
常见的缓动曲线:
| 曲线 | 特点 | 适用场景 |
|---|---|---|
| Linear(线性) | 匀速变化 | 旋转、进度条 |
| EaseIn(缓入) | 先慢后快 | 元素离场(从屏幕出去) |
| EaseOut(缓出) | 先快后慢 | 元素进场(进入屏幕) |
| EaseInOut(缓入缓出) | 先慢后快再慢 | 大多数场景,最自然 |
| Spring(弹簧) | 超出后弹回来 | 强调性动画,比如点赞 |
| Bounce(弹跳) | 像球落地一样弹 | 趣味性动画,慎用 |
为什么 EaseOut 最常用?
因为现实世界的物体运动大多是"先快后慢"的——你扔一个东西,它因为摩擦力/空气阻力逐渐减速;你开车,从高速刹车到停止,也是先快后慢。
EaseOut 符合人的直觉,所以看起来最自然。
1.4 FPS 与流畅度
动画要流畅,就要达到 60fps(每秒 60 帧)。也就是每一帧只有 16.6ms 的时间。
如果某一帧的计算时间超过了 16.6ms,这一帧就没赶上,就会出现"掉帧"——用户感觉到"卡"。
60fps = 16.6ms/帧
120fps = 8.3ms/帧(高刷屏)
所以,动画的性能很重要。动画过程中不要做耗时计算,不要频繁创建对象,不要触发大量重绘。
💡 体感流畅度:
动画是否流畅,不光看 FPS,还要看曲线。一个 60fps 但用 Linear 的动画,可能还不如一个 30fps 但用 EaseOut 的动画看起来顺。因为人的感知是"非线性"的。
步骤2:属性动画(Component Animation)
属性动画是 ArkUI 中最简单、最常用的动画方式。给组件加一个 .animation() 修饰符,当它的属性变化时,就会自动产生动画。
2.1 基本用法
@Component
struct DemoPage {
@State isEnlarged: boolean = false;
build() {
Column() {
Image($r('app.media.example'))
.width(this.isEnlarged ? 200 : 100)
.height(this.isEnlarged ? 200 : 100)
.borderRadius(this.isEnlarged ? 24 : 12)
.animation({
duration: 300,
curve: Curve.EaseInOut,
})
.onClick(() => {
this.isEnlarged = !this.isEnlarged;
})
}
.width('100%')
.height('100%')
.justifyContent(FlexAlign.Center)
}
}
当 isEnlarged 变化时,所有在 .animation() 之前的可动画属性都会自动过渡。
可动画属性包括:
- 尺寸:width、height、size
- 位置:offset、position、translate
- 变换:scale、rotate、opacity
- 外观:backgroundColor、borderRadius、borderWidth
- 文本:fontSize、fontColor
- ……等等
不可动画属性:
- 子组件的增减
- 组件的类型变化
- 列表的数据源变化(可以用 ForEach 的动画)
2.2 动画的作用范围
.animation() 修饰符遵循一个重要规则:
动画只作用于它前面的属性,以及它所在组件的子组件。
Column() {
Text('Hello')
.fontSize(20) // ❌ 不受下面的 animation 影响(因为 animation 不在 Text 上)
Image($r('app.media.icon'))
.width(100) // ✅ 受 animation 影响
.height(100) // ✅ 受 animation 影响
.animation({ // ⚠️ 这是 Image 的 animation
duration: 300,
})
.backgroundColor(Color.Red) // ❌ 在 animation 后面,不受影响!
}
注意:.animation() 写在哪个组件上,就作用于哪个组件。写在父组件上,不会自动作用于子组件(除非子组件也有 animation)。
2.3 实战:卡片按下效果
这是「民族图鉴」中最常用的动画——卡片按下时缩小,松开时恢复。
@Component
export struct EthnicGridCard {
@State isPressed: boolean = false;
private ethnic: EthnicBrief;
private onClick?: () => void;
build() {
Column({ space: 8 }) {
Image(this.ethnic.imageUrl)
.width(60)
.height(60)
.borderRadius(30)
.objectFit(ImageFit.Cover)
Text(this.ethnic.name)
.fontSize(14)
.fontColor($r('app.color.text_primary'))
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
}
.width('100%')
.padding(12)
.borderRadius(12)
.backgroundColor($r('app.color.card_background'))
// 👇 动画:所有属性变化都用 150ms EaseInOut
.animation({
duration: 150,
curve: Curve.EaseInOut,
})
// 👇 按下时缩小到 95%,透明度 0.8
.scale({ x: this.isPressed ? 0.95 : 1, y: this.isPressed ? 0.95 : 1 })
.opacity(this.isPressed ? 0.8 : 1)
.onTouch((event: TouchEvent) => {
if (event.type === TouchType.Down) {
this.isPressed = true;
} else if (event.type === TouchType.Up || event.type === TouchType.Cancel) {
this.isPressed = false;
if (event.type === TouchType.Up && this.onClick) {
this.onClick();
}
}
})
}
}
为什么用 scale 而不是改 width/height?
因为 scale 是变换属性,不会影响布局,性能更好。如果改 width/height,不仅组件本身要重绘,它周围的组件也可能要重新布局,开销大很多。
为什么时长 150ms?
- 按下反馈要"跟手",不能太慢
- 100ms 以下用户感觉不到,200ms 以上觉得拖沓
- 150ms 是一个比较舒服的数值——快,但不会太突兀
2.4 实战:Tab 切换下划线动画
首页的 Tab 切换,下划线从一个 Tab 平滑滑动到另一个 Tab,也是属性动画的经典应用。
@Component
struct HomeTabs {
@State currentIndex: number = 0;
private tabs: string[] = ['推荐', '民族', '文化', '美食', '服饰'];
build() {
Row() {
ForEach(this.tabs, (tab: string, index: number) => {
Text(tab)
.fontSize(16)
.fontWeight(index === this.currentIndex ? FontWeight.Bold : FontWeight.Normal)
.fontColor(index === this.currentIndex
? $r('app.color.primary')
: $r('app.color.text_secondary'))
.padding({ left: 16, right: 16, top: 12, bottom: 12 })
.onClick(() => {
this.currentIndex = index;
})
}, (tab: string) => tab)
}
.position({ x: 0, y: 0 })
// 👇 下划线
.overlay(
Line()
.width(24)
.height(3)
.backgroundColor($r('app.color.primary'))
.borderRadius(1.5)
// 👇 下划线位置用属性动画
.animateMargin({
left: this.calculateUnderlineOffset(),
})
.animation({
duration: 250,
curve: Curve.EaseInOut,
}),
{ align: Alignment.BottomStart }
)
}
private calculateUnderlineOffset(): number {
// 计算下划线的左偏移
// 每个 Tab 的宽度 * index + 居中偏移
return 0; // 简化,实际计算根据 Tab 宽度
}
}
2.5 属性动画的优缺点
优点:
- ✅ 写法简单,加个修饰符就行
- ✅ 自动追踪属性变化,不用手动控制
- ✅ 性能好,框架做了优化
缺点:
- ❌ 粒度粗——要么所有属性都动,要么都不动
- ❌ 不好控制动画的开始和结束时机
- ❌ 不好做复杂的组合动画
步骤3:显式动画(animateTo)
属性动画是"被动"的——属性变了才动。而显式动画是"主动"的——你告诉框架"接下来这段代码里的状态变化,要用动画过渡"。
3.1 基本用法
@Component
struct DemoPage {
@State isShow: boolean = false;
@State offsetY: number = 0;
build() {
Column() {
Button('点击开始动画')
.onClick(() => {
// 👇 显式动画:花 300ms 用 EaseOut 曲线过渡状态变化
animateTo({
duration: 300,
curve: Curve.EaseOut,
}, () => {
this.isShow = true;
this.offsetY = -50;
});
})
if (this.isShow) {
Text('Hello Animation')
.fontSize(18)
.translate({ y: this.offsetY })
}
}
.width('100%')
.height('100%')
.justifyContent(FlexAlign.Center)
}
}
animateTo 接受两个参数:
- 动画配置:时长、曲线、延迟、重复次数等
- 动画闭包:在这个闭包里修改状态,所有状态变化都会用动画过渡
3.2 动画的回调
有时候你需要知道动画什么时候结束,好做下一步操作:
animateTo({
duration: 300,
curve: Curve.EaseOut,
onFinish: () => {
// 动画结束回调
console.info('动画结束了');
this.doSomethingAfterAnimation();
},
}, () => {
this.isShow = true;
});
注意:onFinish 在动画正常结束时触发。如果动画被打断(比如又触发了新的动画),onFinish 不会触发。
3.3 实战:收藏点赞动画
民族详情页的收藏按钮,点一下,爱心放大又弹回来,这就是典型的弹簧动画。
@Component
struct FavoriteButton {
@State isFavorite: boolean = false;
@State bounceScale: number = 1;
private onClick?: (isFav: boolean) => void;
build() {
Image(this.isFavorite
? $r('app.media.heart_filled')
: $r('app.media.heart_outline'))
.width(28)
.height(28)
.fillColor(this.isFavorite ? '#FF4D4F' : '#999999')
.scale({ x: this.bounceScale, y: this.bounceScale })
.onClick(() => {
this.handleClick();
})
}
private handleClick(): void {
this.isFavorite = !this.isFavorite;
// 👇 点击时的弹跳效果
animateTo({
duration: 150,
curve: Curve.EaseOut,
}, () => {
this.bounceScale = 1.3; // 先放大
});
// 👇 延时弹回来,用弹簧效果
animateTo({
duration: 400,
curve: Curve.Spring, // 弹簧曲线
delay: 150, // 等放大结束再弹回来
onFinish: () => {
this.bounceScale = 1;
},
}, () => {
this.bounceScale = 1;
});
if (this.onClick) {
this.onClick(this.isFavorite);
}
}
}
为什么用两个 animateTo 而不是一个?
因为"放大 → 弹回"是两个阶段。用两个动画串联起来,比用一个曲线更灵活。
弹簧曲线(Spring)的参数:
Spring 曲线有几个参数可以调:
animateTo({
curve: curves.springMotion({
response: 0.3, // 响应速度,越大越慢
dampingFraction: 0.8, // 阻尼系数,1.0 不弹,越小弹得越厉害
overlapDuration: 0,
}),
}, () => {
// ...
});
- response(响应时间):弹簧完成一次完整振动的时间。越大越慢,越小越快。
- dampingFraction(阻尼系数):
- 0 = 完全不衰减,永远弹下去(不要用)
- < 1 = 欠阻尼,会弹几下才停
- = 1 = 临界阻尼,刚好不弹,最快到达
- > 1 = 过阻尼,慢悠悠过去
💡 调弹簧参数的技巧:
先定 response(通常 0.2-0.5s),再调 dampingFraction。
想要弹一下的效果:0.6-0.8
想要干脆的效果:0.9-1.0
太弹了就加阻尼,太慢了就减 response。
3.4 实战:数字滚动动画
浏览量、收藏数这些数字变化时,不要直接跳变,而是"滚"到目标值,更有质感。
@Component
struct AnimatedNumber {
@State displayValue: number = 0;
private targetValue: number = 0;
private duration: number = 500;
aboutToAppear(): void {
this.displayValue = this.targetValue;
}
build() {
Text(Math.round(this.displayValue).toString())
.fontSize(14)
.fontColor($r('app.color.text_secondary'))
}
// 外部调用这个方法,数字就会滚动到目标值
animateToValue(target: number): void {
this.targetValue = target;
animateTo({
duration: this.duration,
curve: Curve.EaseOut,
}, () => {
this.displayValue = target;
});
}
}
原理:用一个 displayValue 状态变量,动画过程中它从旧值平滑过渡到新值,每一帧重新计算并显示。
进阶:格式化数字
如果要显示"1.2万"、"3.4k"这样的格式,可以用 computed 或者 getter:
get formattedValue(): string {
const val = Math.round(this.displayValue);
if (val >= 10000) {
return (val / 10000).toFixed(1) + '万';
} else if (val >= 1000) {
return (val / 1000).toFixed(1) + 'k';
}
return val.toString();
}
3.5 动画的组合与串联
复杂的动画效果,往往需要多个动画组合起来。
并行动画(同时动):
// 在一个 animateTo 里改多个状态,它们会同时动
animateTo({ duration: 300, curve: Curve.EaseOut }, () => {
this.opacity = 1;
this.offsetY = 0;
this.scale = 1;
});
串行动画(一个接一个):
// 用 delay 错开时间
animateTo({ duration: 200, curve: Curve.EaseOut }, () => {
this.opacity = 1;
});
animateTo({ duration: 300, curve: Curve.EaseOut, delay: 100 }, () => {
this.offsetY = 0;
});
或者用 onFinish 串联:
animateTo({
duration: 200,
curve: Curve.EaseOut,
onFinish: () => {
animateTo({ duration: 300, curve: Curve.EaseOut }, () => {
this.offsetY = 0;
});
},
}, () => {
this.opacity = 1;
});
💡 推荐用 delay 而不是 onFinish 串联。
因为 onFinish 只有动画正常结束才会触发,如果动画被打断,后面的就不执行了。而 delay 的方式,每个动画都是独立的,更可控。
3.6 显式动画的优缺点
优点:
- ✅ 控制力强——想让什么动就让什么动
- ✅ 可以做复杂的组合动画
- ✅ 有完成回调,可以串联逻辑
缺点:
- ❌ 写法稍复杂,要用 animateTo 包裹
- ❌ 如果到处都是 animateTo,代码会比较乱
步骤4:转场动画(Transition)
转场动画专门用于处理组件的出现和消失——比如一个组件从无到有、从有到无,怎么过渡?
4.1 基本用法
@Component
struct DemoPage {
@State isShow: boolean = false;
build() {
Column() {
Button('切换显示')
.onClick(() => {
animateTo({ duration: 300, curve: Curve.EaseOut }, () => {
this.isShow = !this.isShow;
});
})
if (this.isShow) {
Text('Hello Transition')
.fontSize(18)
.padding(20)
.backgroundColor(Color.Orange)
.borderRadius(8)
// 👇 转场效果:出现时淡入 + 从下往上,消失时相反
.transition({
type: TransitionType.All,
opacity: 1,
translate: { y: 20 },
})
}
}
.width('100%')
.height('100%')
.justifyContent(FlexAlign.Center)
}
}
.transition() 修饰符定义了组件"出现"和"消失"时的过渡效果。注意:transition 必须配合 animateTo 使用,只改 isShow 不加 animateTo 是不会有动画的。
4.2 转场的三种类型
TransitionType 有三种:
| 类型 | 说明 |
|---|---|
TransitionType.All |
出现和消失都用同一个效果(自动反向) |
TransitionType.Insert |
只定义出现时的效果 |
TransitionType.Remove |
只定义消失时的效果 |
分别定义出现和消失:
.transition(
// 出现时:从左边滑入 + 淡入
TransitionEffect.INSERT
.translate({ x: -100, y: 0 })
.opacity(0)
.animation({ duration: 300, curve: Curve.EaseOut })
)
.transition(
// 消失时:从右边滑出 + 淡出
TransitionEffect.REMOVE
.translate({ x: 100, y: 0 })
.opacity(0)
.animation({ duration: 200, curve: Curve.EaseIn })
)
为什么出现和消失可以用不同的时长?
因为出现是"元素来迎接用户",可以慢一点(300ms),让用户看清;消失是"元素离开",应该快一点(200ms),不要让用户等。
4.3 常用转场效果
1. 淡入淡出
.transition({
type: TransitionType.All,
opacity: 0,
})
最朴素的转场,适合弹窗、提示条等。
2. 滑入滑出
// 从底部滑入
.transition({
type: TransitionType.All,
translate: { y: 100 },
opacity: 0,
})
// 从右侧滑入
.transition({
type: TransitionType.All,
translate: { x: 100 },
opacity: 0,
})
适合页面、抽屉、底部弹窗。
3. 缩放
.transition({
type: TransitionType.All,
scale: { x: 0.8, y: 0.8 },
opacity: 0,
})
适合按钮、气泡、卡片。
4. 组合效果
.transition({
type: TransitionType.All,
opacity: 0,
translate: { y: 20 },
scale: { x: 0.9, y: 0.9 },
})
淡入 + 上移 + 放大,组合起来效果更丰富。这也是 iOS 上很经典的一种转场。
4.4 实战:悬浮播放条的出现/消失
「民族图鉴」音乐页的悬浮播放条,从底部滑上来,消失时滑下去。
@Component
struct MiniPlayer {
@State isVisible: boolean = false;
@State currentSong: MusicInfo | null = null;
build() {
Column() {
// 页面其他内容...
if (this.isVisible && this.currentSong) {
Row() {
Image(this.currentSong.coverUrl)
.width(40)
.height(40)
.borderRadius(6)
Column({ space: 4 }) {
Text(this.currentSong.title)
.fontSize(14)
.fontColor($r('app.color.text_primary'))
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Text(this.currentSong.artist)
.fontSize(12)
.fontColor($r('app.color.text_secondary'))
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
}
.layoutWeight(1)
.margin({ left: 12 })
Image($r('app.media.play'))
.width(28)
.height(28)
.fillColor($r('app.color.primary'))
}
.width('100%')
.padding(12)
.backgroundColor($r('app.color.card_background'))
.borderRadius(12)
.shadow({ radius: 8, color: '#1A000000', offsetY: -2 })
// 👇 转场:从底部滑入 + 淡入
.transition({
type: TransitionType.All,
translate: { y: 80 },
opacity: 0,
})
}
}
.width('100%')
.height('100%')
}
show(song: MusicInfo): void {
this.currentSong = song;
animateTo({ duration: 300, curve: Curve.EaseOut }, () => {
this.isVisible = true;
});
}
hide(): void {
animateTo({ duration: 200, curve: Curve.EaseIn }, () => {
this.isVisible = false;
});
}
}
4.5 转场动画 vs 属性动画
| 维度 | 属性动画 | 显式动画 | 转场动画 |
|---|---|---|---|
| 适用场景 | 属性值变化 | 任意状态变化 | 组件出现/消失 |
| 触发方式 | 属性变化自动触发 | animateTo 包裹 | if/else + animateTo |
| 控制力 | 弱(所有属性都动) | 强(精确控制) | 中(定义进出效果) |
| 复杂度 | 简单 | 中等 | 中等 |
| 性能 | 好 | 好 | 好 |
实际上,这三种动画经常配合使用。比如:
- 用 animateTo 控制 isShow
- 用 transition 定义组件怎么出现
- 用属性动画定义组件内部的属性变化
步骤5:高级动画技巧
5.1 列表动画(ForEach 动画)
列表项的增加、删除、排序,也可以有动画。
@Component
struct AnimatedList {
@State items: string[] = ['A', 'B', 'C', 'D', 'E'];
build() {
List() {
ForEach(this.items, (item: string, index: number) => {
ListItem() {
Text(item)
.fontSize(16)
.width('100%')
.height(56)
.padding({ left: 16 })
.backgroundColor($r('app.color.card_background'))
.borderRadius(8)
}
.transition({
type: TransitionType.All,
opacity: 0,
translate: { x: -50 },
})
.animation({
duration: 300,
curve: Curve.EaseOut,
})
}, (item: string, index: number) => `${item}_${index}`)
}
.width('100%')
.height('100%')
.padding(16)
}
addItem(newItem: string): void {
animateTo({ duration: 300, curve: Curve.EaseOut }, () => {
this.items.unshift(newItem);
});
}
removeItem(index: number): void {
animateTo({ duration: 250, curve: Curve.EaseIn }, () => {
this.items.splice(index, 1);
});
}
}
ForEach 动画的关键:
- 用 animateTo 包裹数据修改
- 给 ListItem 加 transition 定义进出效果
- key 函数要返回稳定且唯一的值
错峰动画(Staggered Animation)
让列表项一个接一个地出现,而不是同时出现,效果更高级:
// 在 ForEach 中,根据 index 加不同的 delay
ForEach(this.items, (item: string, index: number) => {
ListItem() {
// ...
}
.transition({
type: TransitionType.Insert,
opacity: 0,
translate: { y: 20 },
})
.animation({
duration: 300,
curve: Curve.EaseOut,
delay: index * 50, // 每个项延迟 50ms
})
}, (item: string) => item)
5.2 旋转动画
加载指示器、下拉刷新的图标,都需要旋转动画。
@Component
struct LoadingSpinner {
@State rotateAngle: number = 0;
private isSpinning: boolean = false;
build() {
Image($r('app.media.loading'))
.width(24)
.height(24)
.rotate({ angle: this.rotateAngle })
.animation({
duration: 800,
curve: Curve.Linear, // 旋转用线性曲线
iterations: -1, // -1 表示无限循环
playMode: PlayMode.Normal,
})
}
start(): void {
this.isSpinning = true;
this.rotateAngle = 360; // 触发旋转
}
stop(): void {
this.isSpinning = false;
this.rotateAngle = 0;
}
}
注意旋转用 Linear 曲线,因为旋转是匀速的,用 EaseOut 会忽快忽慢,很奇怪。
5.3 共享元素动画
从列表页进入详情页时,列表中的图片"放大"变成详情页的大图——这就是共享元素动画。
// 列表页
@Component
struct ListPage {
@State selectedId: string = '';
@State isShowDetail: boolean = false;
build() {
Stack() {
// 列表
List() {
ForEach(this.items, (item: EthnicBrief) => {
ListItem() {
Image(item.imageUrl)
.width(80)
.height(80)
.borderRadius(8)
// 共享元素 id,要和详情页一致
.sharedTransition(`image_${item.id}`, {
duration: 300,
curve: Curve.EaseInOut,
})
.onClick(() => {
this.selectedId = item.id;
animateTo({ duration: 300, curve: Curve.EaseInOut }, () => {
this.isShowDetail = true;
});
})
}
}, (item: EthnicBrief) => item.id)
}
// 详情页
if (this.isShowDetail) {
DetailPage(ethnicId: this.selectedId)
.transition({ type: TransitionType.All, opacity: 0 })
}
}
.width('100%')
.height('100%')
}
}
// 详情页
@Component
struct DetailPage {
private ethnicId: string;
build() {
Column() {
Image(this.ethnic.imageUrl)
.width('100%')
.height(300)
// 同一个共享元素 id
.sharedTransition(`image_${this.ethnicId}`, {
duration: 300,
curve: Curve.EaseInOut,
})
// ...
}
.width('100%')
.height('100%')
}
}
共享元素动画的本质,就是"两个看起来是同一个东西的元素,在位置和大小之间平滑过渡"。框架会自动计算起点和终点的位置、大小,中间帧自动插值。
5.4 手势驱动动画
很多高级交互效果,是手势和动画结合的——比如手指拖动,跟着手指动;手指松开,弹回原位。
@Component
struct DraggableCard {
@State offsetX: number = 0;
@State offsetY: number = 0;
@State isDragging: boolean = false;
build() {
Column() {
Text('拖动我')
.fontSize(16)
.fontColor(Color.White)
}
.width(200)
.height(120)
.backgroundColor($r('app.color.primary'))
.borderRadius(12)
.justifyContent(FlexAlign.Center)
// 👇 位移用属性动画
.translate({ x: this.offsetX, y: this.offsetY })
.scale({ x: this.isDragging ? 1.05 : 1, y: this.isDragging ? 1.05 : 1 })
.animation({
duration: this.isDragging ? 0 : 300, // 拖动时零延迟(跟手),松开时有动画
curve: Curve.EaseOut,
})
.gesture(
PanGesture()
.onActionStart(() => {
this.isDragging = true;
})
.onActionUpdate((event: GestureEvent) => {
this.offsetX += event.offsetX;
this.offsetY += event.offsetY;
})
.onActionEnd(() => {
this.isDragging = false;
// 弹回原位
animateTo({ duration: 300, curve: Curve.Spring }, () => {
this.offsetX = 0;
this.offsetY = 0;
});
})
)
}
}
手势驱动动画的关键:
- 拖动过程中:零延迟,完全跟手(duration = 0)
- 松开时:用动画过渡到最终位置(带曲线)
- 配合弹簧效果,体验更好
⚠️ 常见问题与解决方案
问题1:动画卡顿,掉帧严重
现象:
动画不流畅,一卡一卡的,特别是低端机上更明显。
可能原因及解决方案:
原因1:动画过程中有大量计算
// ❌ 不好:每次属性变化都计算一次
.animation({ duration: 300 })
.width(this.calculateWidth()) // 如果 calculateWidth 很耗时,动画过程中每帧都算
解决:提前算好,动画过程中只改变量。
// ✅ 好:提前计算好,动画只改简单的值
@State targetWidth: number = 200;
// 在初始化或者点击时计算好
this.targetWidth = this.calculateWidth();
原因2:触发了大量布局重排
改 width、height、padding、margin 这些影响布局的属性,会触发父组件、兄弟组件的重新布局,开销很大。
解决:尽量用 transform 属性(translate、scale、rotate)代替布局属性。
// ❌ 不好:改 width 触发布局
.width(this.isShow ? 200 : 100)
// ✅ 好:用 scale,不影响布局
.scale({ x: this.isShow ? 2 : 1, y: this.isShow ? 2 : 1 })
原因3:动画的元素太多
一整个页面同时做动画,每个元素都要重绘,当然卡。
解决:
- 减少同时动画的元素数量
- 用错峰(stagger),一个一个来
- 不重要的元素直接跳变,不用动画
💡 性能优化第一原则:先测再优化。用 DevEco Studio 的性能分析工具,看看是哪一帧慢,慢在哪里。不要靠猜。
问题2:动画结束后状态不对
现象:
动画结束了,但显示的状态不对——比如应该消失的组件还在,应该显示的没显示。
常见原因:动画被打断了。
比如一个 300ms 的消失动画,刚执行了 100ms,又触发了"显示"的动画。这时候第一个动画的 onFinish 不会执行,如果你的逻辑写在 onFinish 里,就不会执行。
解决:
- 不要过度依赖 onFinish,状态驱动是根本
- 用状态变量控制最终结果,动画只是"过渡过程"
- 如果一定要等动画结束,加个标记位
// ❌ 不好:依赖 onFinish
hide() {
animateTo({
duration: 300,
onFinish: () => {
this.isShow = false; // 如果动画被打断,这行不会执行
},
}, () => {
this.opacity = 0;
});
}
// ✅ 好:状态和动画一起变
hide() {
animateTo({ duration: 300 }, () => {
this.isShow = false; // 状态和动画一起,就算被打断也没关系
this.opacity = 0;
});
}
问题3:转场动画不生效
现象:
加了 .transition(),但组件出现/消失时没有动画,直接跳变。
常见原因:
原因1:忘了加 animateTo
transition 只是定义了"转场效果是什么样的",但你需要用 animateTo 来触发它。
// ❌ 不好:直接改,没动画
this.isShow = !this.isShow;
// ✅ 好:用 animateTo 包裹
animateTo({ duration: 300 }, () => {
this.isShow = !this.isShow;
});
原因2:transition 加在了错误的组件上
transition 要加在"被 if/else 控制的那个组件"上。
// ❌ 不好:transition 加在 Column 上,但 Column 没有被 if 控制
Column() {
if (this.isShow) {
Text('Hello')
}
}
.transition(...) // 没用,Column 一直都在
// ✅ 好:加在被 if 控制的组件上
if (this.isShow) {
Text('Hello')
.transition(...) // 正确,Text 会出现/消失
}
原因3:父组件没有 animation
有时候父组件布局变化的动画,也会影响子组件的转场。
问题4:动画太"假",不自然
现象:
动画是动了,但感觉很生硬、很廉价,不够"高级"。
常见原因及改进方法:
原因1:用了 Linear 曲线
除了旋转和进度条,其他场景尽量不要用 Linear。匀速运动不符合现实世界的物理规律,看起来很"机械"。
解决:用 EaseOut 或者 EaseInOut。
原因2:时长不对
- 太短(< 100ms):感觉不到动画,等于没有
- 太长(> 500ms):拖沓,用户等得着急
推荐时长:
- 微交互(按钮按下、开关):100-200ms
- 元素出现/消失:200-350ms
- 页面转场:300-400ms
- 强调性动画(点赞、提醒):400-600ms
原因3:动画太多太杂
什么都想加动画,结果满屏乱飞,用户不知道看哪里。
解决:
- 动画要有重点,重要的操作才加动画
- 同一时刻,最多 1-2 个元素在动
- 克制,克制,再克制
💡 好的动画,用户感觉不到"动画",只觉得"流畅"。
如果用户注意到"哇,这个动画好酷",那这个动画可能太抢戏了。
问题5:动画在低端机上性能差
现象:
高端机上很流畅,低端机上掉帧严重。
优化策略:
1. 降级策略
检测设备性能,低端机关掉一些非核心动画。
// 简单判断:设备性能等级(实际可以用系统 API 获取)
const isLowEndDevice = this.checkDevicePerformance();
if (!isLowEndDevice) {
// 只有非低端机才开启动画
animateTo({ duration: 300 }, () => {
this.isShow = true;
});
} else {
// 低端机直接跳变
this.isShow = true;
}
2. 减少重绘区域
动画元素加 .renderGroup(true),让它在独立的渲染层里,不影响其他元素。
Image(...)
.width(100)
.height(100)
.translate({ x: this.offsetX })
.renderGroup(true) // 独立渲染层
3. 降低动画复杂度
- 透明度 + 位移 就够了,不要再加阴影、模糊等效果
- 减少同时动画的元素数量
- 缩短动画时长
📝 本章小结
核心知识点
本文从动画的本质讲起,系统介绍了 ArkUI 的三大动画体系:
1. 动画基础概念
- 动画 = 属性值随时间变化
- 三要素:属性、时长、缓动曲线
- 60fps = 16.6ms/帧,流畅度的底线
- EaseOut 是最自然的曲线,大多数场景推荐
2. 属性动画(.animation())
- 被动触发:属性变化自动过渡
- 作用范围:修饰符之前的属性
- 适合:简单的状态变化、按钮反馈、布局调整
- 优点:简单、自动
- 缺点:控制粒度粗
3. 显式动画(animateTo)
- 主动控制:指定闭包内的状态变化用动画
- 有完成回调,可以串联逻辑
- 适合:复杂动画、组合动画、需要精确控制的场景
- 弹簧动画(Spring)适合强调性效果
4. 转场动画(.transition())
- 专门用于组件出现/消失
- 三种类型:All / Insert / Remove
- 必须配合 animateTo 使用
- 常用效果:淡入淡出、滑入滑出、缩放
5. 高级动画
- 列表动画:ForEach + transition
- 共享元素动画:sharedTransition
- 手势驱动动画:拖动 + 松手回弹
- 旋转动画:Linear 曲线 + 无限循环
最佳实践总结
✅ 优先用属性动画,不够用再上显式动画
能简单就简单。属性动画搞定的,就不要用 animateTo。
代码越简单,越不容易出错。
✅ EaseOut 是默认选择
大多数动画用 EaseOut,自然又舒服。
进场用 EaseOut,离场用 EaseIn,中间过渡用 EaseInOut。
旋转、进度条用 Linear。
✅ 动画时长 150-400ms 之间
微交互:100-200ms
元素出现:200-350ms
页面转场:300-400ms
超过 500ms 用户会觉得慢。
✅ 用 transform 代替布局属性
translate 比改 position 好
scale 比改 width/height 好
transform 不影响布局,性能更好
✅ 动画要克制
不是越多越好,而是恰到好处。
重要的操作才加动画,不重要的直接跳变。
好的动画用户感觉不到,只觉得流畅。
✅ 性能优先,不跟手不如没有
卡的动画比没有动画还糟糕。
性能不够就降级,保证流畅是底线。
先测再优化,不要靠感觉。
下一步预告
在下一篇文章中,我们将:
- 🧩 学习如何从零封装一个高质量的自定义组件
- 📐 掌握组件的 Props 设计、事件定义、插槽(Slot)使用
- 🎨 理解组件的样式定制与主题适配
- 🔄 深入组件的生命周期与状态管理
- 🚀 用一个「民族详情页头部组件」的完整案例,带你掌握自定义组件的精髓
🔗 相关链接
- 项目源码: GitCode 仓库
- ArkUI 动画开发指南: 官方文档
- 属性动画: 官方文档
- 显式动画: 官方文档
- 转场动画: 官方文档
- 缓动曲线: Curves API
💡 提示:动画是一个"审美 + 技术"的活。技术好学,审美难练。多看看优秀的 App,注意它们的动画细节——时长、曲线、组合方式。看得多了,自然就知道什么样的动画是好的。然后再回到自己的项目里,一点点打磨,你会发现——动画这东西,差 50ms、换条曲线,感觉就完全不一样。
更多推荐



所有评论(0)