系列文章《鸿蒙开发从入门到专家》第 53 篇 · 高级
第 7 篇讲自定义组件与动画时覆盖了基础用法。这篇往工程化方向走:多个动画怎么编排、曲线怎么选才不突兀、复杂手势交互场景下动画怎么管理。
ArkUI 的动画体系分三层:属性动画(.animation)、显式动画(animateTo)、转场动画(.transition)。三层各有适用场景,混用容易出问题。
一、三种动画的边界
核心区别:.animation 是"你改了属性,框架自动给你加动画",animateTo 是"你告诉框架这一段变化要用动画完成"。前者被动,后者主动。
1.1 属性动画
@Entry
@Component
struct PropertyAnimationDemo {
@State expand: boolean = false;
build() {
Column() {
Text('Click to expand')
.fontSize(14)
.padding(16)
.backgroundColor(this.expand ? '#E8E8E8' : '#FFFFFF')
.height(this.expand ? 200 : 48)
.animation({
duration: 300,
curve: Curve.EaseInOut
})
.onClick(() => {
this.expand = !this.expand;
})
}
}
}
expand 变化后,backgroundColor、height 的变化会自动产生 300ms 的过渡动画。代码简洁,但缺点是你无法精确控制动画的开始时机,也无法在动画中做条件判断。
1.2 显式动画
import { curves } from '@kit.ArkUI';
@Entry
@Component
struct AnimateToDemo {
@State scale: number = 1;
@State rotation: number = 0;
@State opacity: number = 1;
build() {
Column() {
Image($r('app.media.icon'))
.width(120)
.height(120)
.scale({ x: this.scale, y: this.scale })
.rotate({ angle: this.rotation })
.opacity(this.opacity)
Button('Play Animation')
.onClick(() => {
this.getUIContext()?.animateTo({
duration: 500,
curve: curves.easeOut,
onFinish: () => {
// 动画结束后的回调
console.info('Animation finished');
}
}, () => {
this.scale = 1.5;
this.rotation = 180;
this.opacity = 0.6;
});
})
}
}
}
animateTo 的闭包内同时修改三个属性,框架会检测到差异,给三个属性都加动画。它们同步开始、同步结束。
二、曲线选择
曲线决定动画的"节奏感"。选错了,动画要么生硬要么拖沓。
2.1 内置曲线
选曲线的口诀:
入场用 EaseOut:元素快速出现再减速停下,自然。
退场用 EaseIn:元素缓慢启动再加速消失,不拖泥带水。
状态切换用 EaseInOut:对称过渡,前后都平滑。
拖拽回弹用 springMotion:有物理感,不像程序控制。
2.2 自定义曲线
内置曲线不够用时,用 curves 模块创建贝塞尔曲线:
import { curves } from '@kit.ArkUI';
// 三阶贝塞尔:cubicBezier(x1, y1, x2, y2)
const customCurve = curves.cubicBezierCurve(0.25, 0.1, 0.25, 1);
// 弹簧曲线:springMotion(response, dampingFraction, overlapDuration)
const springCurve = curves.springMotion(0.5, 0.8, 0);
// 响应式弹簧:responsiveSpringMotion(response, dampingFraction, overlapDuration)
const responsiveSpring = curves.responsiveSpringMotion(0.5, 0.8, 0);
springMotion 和 responsiveSpringMotion 的区别:前者创建固定参数的弹簧曲线,后者会根据当前动画状态自适应调整参数(比如当前正在执行另一个弹簧动画时,会平滑衔接而不是打断)。
三、多段动画编排
实际项目中单个动画很少见,常见的是"先缩小再弹大再回正"这种多段动画。
3.1 嵌套回调法
最直观但不好维护:
private playElasticScale(): void {
// 第一段:缩小(200ms)
this.getUIContext()?.animateTo({
duration: 200,
curve: Curve.EaseIn,
onFinish: () => {
// 第二段:弹大(300ms)
this.getUIContext()?.animateTo({
duration: 300,
curve: Curve.EaseOut,
onFinish: () => {
// 第三段:回正(200ms)
this.getUIContext()?.animateTo({
duration: 200,
curve: Curve.EaseInOut
}, () => {
this.scale = 1;
});
}
}, () => {
this.scale = 1.15;
});
}
}, () => {
this.scale = 0.9;
});
}
三层嵌套,改一个时长要数缩进。不推荐在复杂场景使用。
3.2 状态机法
把动画状态抽象成有限状态机:
enum AnimState {
IDLE,
SHRINK,
BOUNCE,
SETTLE
}
@Entry
@Component
struct StateMachineDemo {
@State scale: number = 1;
@State animState: AnimState = AnimState.IDLE;
private config: Record<AnimState, { duration: number, curve: Curve, target: number, next: AnimState }> = {
[AnimState.IDLE]: { duration: 0, curve: Curve.Linear, target: 1, next: AnimState.SHRINK },
[AnimState.SHRINK]: { duration: 200, curve: Curve.EaseIn, target: 0.9, next: AnimState.BOUNCE },
[AnimState.BOUNCE]: { duration: 300, curve: Curve.EaseOut, target: 1.15, next: AnimState.SETTLE },
[AnimState.SETTLE]: { duration: 200, curve: Curve.EaseInOut, target: 1, next: AnimState.IDLE },
};
playAnimation(): void {
this.advance();
}
private advance(): void {
const conf = this.config[this.animState];
if (conf.duration === 0) {
this.animState = conf.next;
this.advance();
return;
}
this.getUIContext()?.animateTo({
duration: conf.duration,
curve: conf.curve,
onFinish: () => {
if (conf.next !== AnimState.IDLE) {
this.animState = conf.next;
this.advance();
}
}
}, () => {
this.scale = conf.target;
});
}
build() {
Column() {
Image($r('app.media.icon'))
.scale({ x: this.scale, y: this.scale })
Button('Play').onClick(() => this.playAnimation())
}
}
}
状态多了之后维护成本仍然高,但逻辑清晰,每段动画的参数集中管理。
3.3 AnimatedGroup 封装
更工程化的做法是封装一个动画序列管理器:
interface AnimStep {
duration: number;
curve: Curve | ICurve;
apply: () => void;
}
class AnimationSequence {
private steps: AnimStep[] = [];
private currentStep: number = 0;
private onFinish?: () => void;
addStep(duration: number, curve: Curve, apply: () => void): AnimationSequence {
this.steps.push({ duration, curve, apply });
return this;
}
play(uiContext: UIContext, onFinish?: () => void): void {
this.onFinish = onFinish;
this.currentStep = 0;
this.playNext(uiContext);
}
private playNext(uiContext: UIContext): void {
if (this.currentStep >= this.steps.length) {
this.onFinish?.();
return;
}
const step = this.steps[this.currentStep];
uiContext.animateTo({
duration: step.duration,
curve: step.curve,
onFinish: () => {
this.currentStep++;
this.playNext(uiContext);
}
}, step.apply);
}
}
// 使用
const sequence = new AnimationSequence()
.addStep(200, Curve.EaseIn, () => { this.scale = 0.9; })
.addStep(300, Curve.EaseOut, () => { this.scale = 1.15; })
.addStep(200, Curve.EaseInOut, () => { this.scale = 1; });
sequence.play(this.getUIContext()!);
四、手势交互动画
拖拽场景是最需要动画工程化的地方。用户拖动元素时,元素跟随手指;松手时,根据拖拽速度决定回弹或吸附。
import { curves } from '@kit.ArkUI';
@Entry
@Component
struct DragAnimationDemo {
@State offsetX: number = 0;
@State offsetY: number = 0;
@State scale: number = 1;
private startX: number = 0;
private startY: number = 0;
build() {
Stack() {
Image($r('app.media.card'))
.width(200)
.height(280)
.translate({ x: this.offsetX, y: this.offsetY })
.scale({ x: this.scale, y: this.scale })
.gesture(
PanGesture()
.onActionStart((event: GestureEvent) => {
this.startX = this.offsetX;
this.startY = this.offsetY;
// 拖拽开始时放大
this.getUIContext()?.animateTo({
duration: 150,
curve: curves.springMotion(0.3, 0.7)
}, () => {
this.scale = 1.1;
});
})
.onActionUpdate((event: GestureEvent) => {
// 拖拽中实时跟随手指,不用动画
this.offsetX = this.startX + event.offsetX;
this.offsetY = this.startY + event.offsetY;
})
.onActionEnd((event: GestureEvent) => {
// 松手后根据偏移量决定回弹或飞出
if (Math.abs(this.offsetX) > 150) {
// 飞出
this.getUIContext()?.animateTo({
duration: 300,
curve: Curve.EaseIn
}, () => {
this.offsetX = this.offsetX > 0 ? 400 : -400;
this.scale = 0.5;
});
} else {
// 回弹
this.getUIContext()?.animateTo({
duration: 400,
curve: curves.springMotion(0.5, 0.8)
}, () => {
this.offsetX = 0;
this.offsetY = 0;
this.scale = 1;
});
}
})
)
}
.width('100%')
.height('100%')
}
}
关键点:
拖拽中(
onActionUpdate)直接赋值,不用动画。手指移动时每帧都触发回调,如果再套 animateTo 会和手势产生帧冲突。拖拽开始和结束用
springMotion曲线。弹簧曲线的物理感比 EaseOut 自然得多。松手后的逻辑根据偏移量分支处理,类似 Tinder 的卡片滑动。
五、转场动画
组件插入或移除时的过渡效果,用 .transition:
@Entry
@Component
struct TransitionDemo {
@State items: number[] = [1, 2, 3];
build() {
Column() {
Button('Add')
.onClick(() => {
this.getUIContext()?.animateTo({ duration: 300 }, () => {
this.items.push(this.items.length + 1);
});
})
ForEach(this.items, (item: number) => {
Text(`Item ${item}`)
.width('100%')
.height(50)
.textAlign(TextAlign.Center)
.backgroundColor('#F5F5F5')
.transition({
type: TransitionType.All,
opacity: 0,
translate: { y: 30 }
})
}, (item: number) => item.toString())
}
}
}
TransitionType.All 表示插入和移除都触发。插入时从 opacity=0, y=30 过渡到 opacity=1, y=0。移除时反向。
注意:使用 transition 必须在 animateTo 闭包内修改数据源,否则框架检测不到变化。
六、性能注意
动画是性能消耗大户。几条原则:
只动画 transform 和 opacity。 这两个属性走 GPU 合成层,不触发布局重排。动画
width/height/margin会触发布局重新计算,大量消耗。避免动画中触发 build。
animateTo闭包内修改@State会触发组件刷新,但框架做了优化,只刷新受影响的属性。如果在onFinish回调里再修改其他@State,可能触发额外 build。长列表中的动画用
.animation而非animateTo。 列表项被回收时,animateTo 的闭包引用可能泄漏。.animation是声明式的,框架管理生命周期。iterations: -1(无限循环)要小心。 无限循环动画在组件销毁时不会自动停止,需要在aboutToDisappear中清理。
小结
动画工程化的核心是"状态管理 + 曲线选择 + 编排控制"。简单场景用属性动画,精确控制用 animateTo,组件增删用 transition。多段动画优先用序列管理器而非嵌套回调。拖拽交互用 springMotion 曲线获得物理感。
下篇讲复杂表单。动态渲染、校验体系、字段联动,是每个中大应用都绕不过的。
参考阅读:
第 7 篇:鸿蒙自定义组件与动画
第 50 篇:设计模式在鸿蒙里的应用(策略模式部分)
官方文档:curves 模块、animateTo