引言
ArkUI 的动画能力分成三条相对独立的线:属性动画描述「某个属性值怎么变」,转场动画描述「组件怎么进出」,手势描述「用户怎么触发变化」。三条线经常被混在一起写,结果是动画抖动、手势不响应、或者同一个交互在两台设备上表现完全不同。
真正的难点在时序与优先级。一次点击可能同时触发属性动画、转场动画和状态更新,谁先谁后由框架决定;一次滑动可能同时被父容器的滚动和子组件的 Pan 手势捕获,谁赢由手势竞争规则决定。不理解这两套规则,就只能靠试,改一个参数崩另一个地方。
本文按「属性动画、转场动画、手势、性能」四块展开。状态管理与刷新机制是动画的基础,建议先读 ArkUI 声明式 UI 与状态管理 ;布局容器的用法见 ArkUI 布局系统与自定义组件 ,本篇不重复布局内容。
目录
- ArkUI 动画体系总览
- 属性动画与 animation 属性
- 显式动画 animateTo
- 关键帧动画 keyframeAnimateTo
- 转场动画 transition 与 TransitionEffect
- 共享元素转场 geometryTransition
- 手势体系与基础手势
- 手势组合 GestureGroup
- 手势竞争与优先级
- 拖拽与响应区域
- 动画性能与丢帧排查
- 权衡取舍
- 常见坑清单
- 小结
1. ArkUI 动画体系总览
先建立一张分类表,后面每一节都对应其中一行。
| 类别 | 触发方式 | 代表 API | 作用对象 | 典型用途 |
|---|---|---|---|---|
| 属性动画 | 状态变化自动触发 | animation | 组件属性 | 尺寸、颜色、透明度渐变 |
| 显式动画 | 代码主动调用 | animateTo | 闭包内的属性变化 | 点击后的整体位移动画 |
| 关键帧动画 | 代码主动调用 | keyframeAnimateTo | 闭包内的属性变化 | 多段式路径动画 |
| 转场动画 | 组件出现或消失 | transition | 组件整体 | 弹窗、列表项进出 |
| 共享元素 | 页面切换 | geometryTransition | 跨页面同一元素 | 列表项放大到详情页 |
选择顺序很明确:能用属性动画解决就不要用显式动画,能用显式动画就不要自己算插值。框架提供的动画都跑在渲染线程上,自己用定时器算插值既耗电又容易丢帧。
需要区分两个概念:animation 是「声明式」的,它挂在组件上,只要该组件的属性值发生变化就自动补间;animateTo 是「命令式」的,它包住一段会修改状态的代码,只有这段代码引起的变化才有动画。前者的作用范围是本组件,后者可以跨多个组件。
2. 属性动画与 animation 属性
属性动画的写法是在组件上追加 animation,它必须放在会变化的属性之后。
@Entry
@Component
struct ToggleCard {
@State expanded: boolean = false;
build() {
Column() {
Text('展开内容')
.fontSize(16)
}
.width(this.expanded ? 320 : 160)
.height(this.expanded ? 240 : 80)
.backgroundColor(this.expanded ? '#FF6B00' : '#CCCCCC')
.animation({ duration: 300, curve: Curve.EaseInOut })
}
}
animation 的位置是硬约束:它只对写在它之前的属性生效。如果把 animation 写在 width 之前,宽度变化就不会有动画,而颜色变化有——这类「一半属性有动画」的现象几乎都是位置写错导致的。
animation 的参数里最值得调的是 curve 与 delay:
| 参数 | 含义 | 常用取值 |
|---|---|---|
| duration | 动画时长(毫秒) | 150 到 400,超过 500 会显得拖沓 |
| curve | 缓动曲线 | Curve.EaseInOut、Curve.Friction、Curve.Spring |
| delay | 延迟开始 | 列表项错峰出场时用 |
| iterations | 播放次数 | 默认 1,-1 表示无限循环 |
| playMode | 播放方向 | Normal、Reverse、Alternate |
Curve.Friction 适合「松手后减速」这类物理感场景,Curve.Spring 适合有回弹的交互。不要所有动画都用 Curve.Linear,匀速运动在人眼看来是机械的,而缓动曲线几乎不增加成本。
3. 显式动画 animateTo
animateTo 把一段代码包裹起来,这段代码引起的所有属性变化都会带上动画。
@Entry
@Component
struct ListPage {
@State offsetX: number = 0;
@State scale: number = 1;
private playEnter(): void {
this.getUIContext().animateTo({
duration: 300,
curve: Curve.EaseOut,
onFinish: () => {
console.info('animation finished');
}
}, () => {
// 这个闭包里对状态变量的所有修改都会产生动画
this.offsetX = 40;
this.scale = 1.1;
});
}
build() {
Column() {
Text('卡片')
.translate({ x: this.offsetX })
.scale({ x: this.scale, y: this.scale })
Button('播放').onClick(() => this.playEnter())
}
}
}
两个关键点:其一,必须用 this.getUIContext().animateTo,全局的 animateTo 在 API 12 之后已不推荐,多实例场景下会作用到错误的 UI 实例上;其二,闭包里只能改状态变量,直接操作组件对象不会产生动画。
animateTo 的三个回调值得记住:onFinish 在动画正常结束时触发,onCancel(API 12 起)在被新动画打断时触发。动画被打断是常态而非异常——用户快速连点两次,第一次动画会被第二次打断,因此清理逻辑不能只写在 onFinish 里。
4. 关键帧动画 keyframeAnimateTo
需要在一条时间轴上定义多个阶段时用 keyframeAnimateTo,它把动画拆成若干关键帧,每帧有自己的时长与曲线。
private playShake(): void {
this.getUIContext().keyframeAnimateTo({ delay: 0 }, [
{ duration: 80, curve: Curve.Sharp, event: () => { this.offsetX = -8; } },
{ duration: 160, curve: Curve.Sharp, event: () => { this.offsetX = 8; } },
{ duration: 80, curve: Curve.Sharp, event: () => { this.offsetX = 0; } }
]);
}
这段代码实现「输入错误时左右抖动」的反馈:向左 8vp、向右 16vp、回正,总时长 320ms。关键帧动画的价值在于「一条时间轴多个目标点」,用 animateTo 串三次会因为每次都是独立动画而出现停顿。
注意关键帧之间是相对独立的:每一帧只描述「在这段时间内属性从当前值变到目标值」,它不记录绝对时间点。因此帧的顺序与时长决定了整体节奏,而起始值取决于进入该帧时的属性值。
5. 转场动画 transition 与 TransitionEffect
transition 处理组件的出现与消失,它必须配合 if 条件渲染或 ForEach 的增删使用。
@Entry
@Component
struct DialogDemo {
@State show: boolean = false;
build() {
Stack() {
Button('切换').onClick(() => { this.show = !this.show; })
if (this.show) {
Column() {
Text('这是一个弹窗').fontSize(18)
}
.width(240)
.height(160)
.backgroundColor(Color.White)
.borderRadius(12)
.transition(TransitionEffect.OPACITY
.combine(TransitionEffect.scale({ x: 0.8, y: 0.8 }))
.animation({ duration: 250, curve: Curve.EaseOut }))
}
}
.width('100%')
.height('100%')
}
}
TransitionEffect 的三种构造方式是选型的关键:
| 构造方式 | 语义 | 适用场景 |
|---|---|---|
| TransitionEffect.OPACITY | 透明度过渡 | 通用淡入淡出 |
| TransitionEffect.translate | 位移过渡 | 抽屉、侧滑面板 |
| TransitionEffect.scale | 缩放过渡 | 弹窗、气泡 |
| TransitionEffect.rotate | 旋转过渡 | 图标状态切换 |
| TransitionEffect.asymmetric | 进出使用不同效果 | 进场缩放、出场淡出 |
asymmetric 是最实用的一个,因为「进场」与「出场」往往需要不同的时长与曲线:进场稍慢让人看清内容,出场要快避免拖沓。
.transition(TransitionEffect.asymmetric(
TransitionEffect.OPACITY.combine(TransitionEffect.translate({ y: 40 })),
TransitionEffect.OPACITY.animation({ duration: 120 })
))
一个必须记住的限制:transition 只对组件自身的挂载与卸载生效。如果只是把组件 visibility 改成 Hidden,它仍然在组件树上,转场动画不会触发。这也是为什么「用 visibility 代替 if」的做法会让动画失效——这与 鸿蒙原生应用性能优化与调试
里强调的「用条件渲染而不是隐藏」是同一条建议,只是动机会有所不同。
6. 共享元素转场 geometryTransition
共享元素转场让同一个元素在两个页面之间平滑过渡,例如列表项点击后放大成详情页头图。
// 列表页
@Entry
@Component
struct ListPage {
@State selectedId: string = '';
build() {
Column() {
ForEach(['a', 'b', 'c'], (id: string) => {
Image($r('app.media.cover'))
.width(80)
.height(80)
.geometryTransition(`cover_${id}`)
.onClick(() => {
this.selectedId = id;
this.getUIContext().getRouter().pushUrl({ url: 'pages/Detail' });
})
}, (id: string) => id)
}
}
}
两端的 geometryTransition 参数必须完全一致才能匹配上,而且两端都要处于已挂载状态。常见的失败场景是:列表页在跳转时立即销毁,导致框架找不到起点。解决方式是把跳转放在 onClick 的末尾,让框架先完成一次布局再执行页面切换。
共享元素对布局的约束比较严格:两个页面上该元素的父容器结构越接近,过渡越自然。如果一端是 Column 里的固定尺寸,另一端是 Stack 里的百分比尺寸,过渡过程中会出现明显的形变与跳动。稳妥做法是两端都用固定宽高比加 aspectRatio,把差异留给内容而不是尺寸。
7. 手势体系与基础手势
ArkUI 的手势通过 .gesture() 绑定,基础手势有六种。
| 手势 | 识别条件 | 关键参数 | 典型用途 |
|---|---|---|---|
| TapGesture | 点击 | count(连续点击次数) | 单击、双击 |
| LongPressGesture | 长按 | duration(默认 500ms) | 长按菜单、拖拽前置 |
| PanGesture | 拖动 | direction、distance | 侧滑、拖拽排序 |
| PinchGesture | 双指捏合 | distance | 图片缩放 |
| SwipeGesture | 快速滑动 | speed(默认 100vp/s) | 列表项快捷操作 |
| RotationGesture | 双指旋转 | angle | 图片旋转 |
@Entry
@Component
struct GestureDemo {
@State scale: number = 1;
@State angle: number = 0;
build() {
Image($r('app.media.photo'))
.width(240)
.height(240)
.scale({ x: this.scale, y: this.scale })
.rotate({ angle: this.angle })
.gesture(
GestureGroup(GestureMode.Parallel,
PinchGesture()
.onActionUpdate((event: GestureEvent) => {
this.scale = event.scale;
}),
RotationGesture()
.onActionUpdate((event: GestureEvent) => {
this.angle = event.angle;
})
)
)
}
}
onActionUpdate 与 onActionEnd 的配合是缩放类交互的关键:onActionUpdate 在手指移动过程中持续触发,onActionEnd 在手势结束时触发一次。在 onActionUpdate 里做重计算或发请求是常见错误,它每秒可能触发几十次,必须只做属性赋值。
PinchGesture 的 event.scale 是相对本次手势起点的比例,不是累计值。因此把 this.scale 直接赋值为 event.scale 会导致手势结束后缩放回弹到 1,正确做法是在手势开始时记录基准值,结束时把最终值固化。
8. 手势组合 GestureGroup
多个手势绑在同一组件上时,默认会互相竞争,用 GestureGroup 可以显式指定组合方式。
| 模式 | 语义 | 结果 |
|---|---|---|
| Sequence | 按顺序依次识别 | 前一个成功后才会识别下一个 |
| Parallel | 同时识别 | 多个手势可以同时生效 |
| Exclusive | 互斥,只有一个胜出 | 最先满足条件的胜出 |
Sequence 最典型的用法是「长按后拖拽」:先识别 LongPressGesture,成功后再识别 PanGesture,这样普通滑动不会触发拖拽。
.gesture(
GestureGroup(GestureMode.Sequence,
LongPressGesture({ repeat: false })
.onAction(() => { this.dragging = true; }),
PanGesture({ direction: PanDirection.All })
.onActionUpdate((event: GestureEvent) => {
this.offsetX += event.offsetX;
})
.onActionEnd(() => { this.dragging = false; })
)
)
Exclusive 适合「单击与双击共存」的场景:双击的前半段会先触发单击,用 Exclusive 并配合 TapGesture({ count: 2 }) 可以避免单击误触发——框架会等待双击的判定窗口(默认 300ms)结束后再决定把事件给谁。这 300ms 的延迟是必要的代价,如果不需要双击,就不要声明它,否则单击响应会明显变慢。
9. 手势竞争与优先级
当子组件与父组件都绑定了手势时,默认的竞争规则是子组件优先。想改变这个行为要用 priorityGesture 与 parallelGesture。
| 绑定方式 | 优先级 | 父子的关系 | 适用场景 |
|---|---|---|---|
| gesture | 子组件优先 | 子组件先响应,父组件被阻断 | 常规交互 |
| priorityGesture | 父组件优先 | 父组件先响应,可阻断子组件 | 容器级手势,如整页返回 |
| parallelGesture | 同时响应 | 父子都收到事件 | 需要叠加效果的场景 |
Column() {
// 子组件:普通点击
Text('子内容').onClick(() => { console.info('child click'); })
}
// 父组件用 priorityGesture 抢占,子组件的点击不再触发
.priorityGesture(
TapGesture().onAction(() => { console.info('parent wins'); })
)
一个高频踩坑场景是 Scroll 内的 PanGesture:默认子组件优先,PanGesture 会抢走滑动事件,导致列表无法滚动。解决方案有两种,一是给 PanGesture 设置 distance 阈值让它不那么容易触发,二是改用 parallelGesture 让两者共存,由业务逻辑判断该响应谁。
手势冲突没有万能解,只能按「谁的意图更明确谁优先」来判断。列表滚动的意图比单项拖动更常见,因此应该让滚动优先;而拖拽排序的意图更明确,此时用长按前置(Sequence)把冲突消解在触发条件上,比调整优先级更可靠。
10. 拖拽与响应区域
拖拽是手势的一个特殊分支,它有独立的 onDragStart / onDrop 体系,可以跨组件甚至跨应用传递数据。
@Entry
@Component
struct DragDemo {
@State items: string[] = ['A', 'B', 'C'];
build() {
Row() {
ForEach(this.items, (item: string) => {
Text(item)
.width(64)
.height(64)
.textAlign(TextAlign.Center)
.backgroundColor('#EEEEEE')
.draggable(true)
.onDragStart(() => {
// 返回值会被传给接收方的 onDrop
return { extraInfo: item };
})
.onDrop((event: DragEvent, extraParams: string) => {
console.info(`dropped: ${extraParams}`);
event.setResult(DragResult.DROP_ENABLED);
})
}, (item: string) => item)
}
}
}
拖拽有两个容易忽略的配置:draggable(true) 必须显式开启,默认不可拖;event.setResult(DragResult.DROP_ENABLED) 决定是否接受本次放置,不调用它会导致放置后被弹回。
响应区域用 responseRegion 与 hitTestBehavior 控制,它们解决「点击热区太小」与「透明区域要不要响应」两类问题:
Text('删除')
.width(48)
.height(48)
// 视觉上 48x48,实际热区扩大到 60x60
.responseRegion({ x: -6, y: -6, width: 60, height: 60 })
responseRegion 的坐标是相对组件左上角的,负值表示向外扩展。小图标按钮必须设置它,因为 48vp 是视觉可接受的最小尺寸,但触摸友好尺寸要求至少 44vp 以上,边缘图标更需要额外外扩。这与 前端可访问性与国际化
里讲的触达区域原则是一致的。
hitTestBehavior 有三个取值:Default(默认,正常参与命中测试)、Block(自身响应且阻断下层)、Transparent(自身响应且允许穿透到下层)。做叠加层时用 Transparent 能让上层透明区域的点击落到下层,这在自定义遮罩场景里很实用。
11. 动画性能与丢帧排查
动画丢帧的根因只有两类:渲染线程负担过重,或者主线程被长任务阻塞。
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 动画整体卡顿 | 组件层级过深、过度绘制 | 用 Profiler 的帧率泳道看每帧耗时 |
| 动画开始时卡一下 | 首帧构建成本高 | 检查动画组件是否首次创建 |
| 动画中段掉帧 | 主线程有长任务 | 看 CPU 泳道的主线程占用 |
| 手势跟手性差 | onActionUpdate 里做重活 | 只保留属性赋值 |
三条优化手段的收益最明显:
- 优先动画化 transform 类属性(
translate、scale、rotate、opacity),它们可以只触发绘制而不用重新布局;动画化width、height、margin会触发重新测量与布局,成本高一个量级。 - 用
renderGroup把子树合成到一张纹理,适合整体位移的场景,代价是子树内部的变化不再独立刷新。 - 避免在
onActionUpdate里修改会引起其他组件刷新的状态,一个手势更新触发整页重排是最常见的性能陷阱。
排查流程与 鸿蒙原生应用性能优化与调试
里讲的一致:先用帧率泳道定位丢帧时间点,再切到 CPU 泳道看那一刻主线程在跑什么。不要凭感觉调 duration,把 300ms 改成 200ms 只会让卡顿看起来更快,不会减少丢帧。
权衡取舍
动画与手势的取舍集中在「表现力」与「成本」之间。
| 决策点 | 方案 A | 方案 B | 建议 |
|---|---|---|---|
| 属性动画 vs 显式动画 | animation(声明式) | animateTo(命令式) | 单组件用 A,跨组件用 B |
| 转场 vs 手动插值 | transition | 定时器算插值 | 一律用 A,手写插值必然丢帧 |
| 手势冲突 | 调优先级 | 改触发条件 | 优先 B,用长按前置消解冲突 |
| 单击 + 双击共存 | 都声明,用 Exclusive | 只声明单击 | 无双击需求就别声明,省 300ms 延迟 |
| 动画时长 | 短(150 到 200ms) | 长(400ms 以上) | 反馈类用 A,引导类可用 B |
一条容易被忽略的原则:动画应该服务于「状态变化的可理解性」,而不是装饰。一个 300ms 的位移如果能让用户看清「这个元素从哪来、到哪去」,它就值得;如果只是让页面看起来活泼,那它在低端设备上换来的只会是丢帧。同理,手势的阈值也不宜过小——过小的阈值会让用户误触,这在单手操作与运动中使用的场景下尤其明显。
常见坑清单
animation写在属性之前。 只有部分属性有动画,表现为「一半动一半不动」。- 用全局
animateTo。 多 UI 实例下作用到错误的实例,API 12 起应改用this.getUIContext().animateTo。 - 只处理
onFinish不处理onCancel。 动画被打断时清理逻辑不执行,状态残留。 - 用
visibility代替if做显隐。 组件仍在树上,transition转场不会触发。 geometryTransition两端参数不一致。 共享元素过渡失效,退化成普通页面切换。PinchGesture的scale直接赋值给状态。 它是相对起点的比例,手势结束后缩放回弹到 1。- 在
onActionUpdate里做重计算或发请求。 每秒触发几十次,直接造成掉帧。 Scroll内的PanGesture抢走滑动。 默认子组件优先,需调distance阈值或改parallelGesture。draggable未显式开启。 拖拽完全不生效,且没有任何提示。onDrop未调用setResult。 放置后元素被弹回原位。- 小图标按钮未设
responseRegion。 热区过小,边缘点击经常落空。 - 动画化
width与margin。 触发重新布局,成本远高于 transform 类属性。
小结
ArkUI 的动画与手势是两套规则体系,分开理解就清楚了。动画侧记住三条:属性动画管单组件、显式动画管跨组件、转场动画管挂载与卸载,三者不要混用;手势侧记住三条:默认子组件优先、组合模式决定并发关系、冲突优先靠调整触发条件而不是调优先级。性能侧只有一条底线:只动画化 transform 与 opacity 这类合成属性,手势回调里只做属性赋值。
最后一条建议:动画与手势是用户感知最强的部分,也是最容易在低端设备上暴露问题的部分。开发期就在目标低端机上验证一遍,比在旗舰机上把动画调到丝滑再上线要稳妥得多。想继续深入渲染与内存侧的排查方法,可以回看 鸿蒙原生应用性能优化与调试 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。