鸿蒙从零到一 ArkUI 动画与转场实战:状态驱动、组件过渡与页面衔接
前言
动画的价值不是让界面"动起来",而是帮助用户理解状态变化:按钮为什么不可用、内容从哪里出现、页面之间是什么关系。没有语义的动画只会拖慢操作,有明确因果关系的动画才能改善反馈和空间连续性。
本文以一个任务看板为例,从属性动画开始,逐步实现组件进退场、列表增删、共享元素衔接和 Navigation 页面转场,并讨论中断处理、性能、无障碍与工程封装。示例使用 ArkTS、ArkUI 和 Stage 模型,具体接口请以项目所使用的 SDK 类型定义为准。
一、先建立 ArkUI 动画的心智模型
ArkUI 是状态驱动 UI。普通渲染关注"状态改变后界面长什么样",动画则补充"界面怎样从旧状态过渡到新状态"。常用能力可以分成四类:
| 场景 | 常用能力 | 适合解决的问题 |
|---|---|---|
| 单个属性持续变化 | animation() |
缩放、透明度、偏移等连续变化 |
| 一组状态同步变化 | animateTo() |
展开面板、切换选中态、联动多个属性 |
| 组件创建和销毁 | transition() |
弹层、空状态、列表项进退场 |
| 页面或元素空间衔接 | Navigation 转场、geometryTransition() |
页面切换、卡片到详情的连续变形 |
选择能力时先问三个问题:
- 动画由哪个状态变化触发?
- 动画结束后,真实业务状态是什么?
- 用户连续操作导致动画中断时,界面应该停在哪里?
如果这三个问题答不清楚,代码通常会出现状态与画面不一致、重复点击失效或退出动画不执行等问题。
二、用 animation 构建属性动画
animation() 修饰它之前声明的可动画属性。当这些属性依赖的状态发生改变时,ArkUI 会在新旧值之间插值。
ts
@Component
struct FavoriteButton {
@State private liked: boolean = false
build() {
Button() {
Row({ space: 8 }) {
Text(this.liked ? '♥' : '♡')
.fontSize(22)
Text(this.liked ? '已收藏' : '收藏')
}
}
.fontColor(this.liked ? Color.White : '#1F2329')
.backgroundColor(this.liked ? '#E5484D' : '#F2F3F5')
.scale({ x: this.liked ? 1.04 : 1, y: this.liked ? 1.04 : 1 })
.animation({ duration: 220, curve: Curve.EaseOut })
.onClick(() => {
this.liked = !this.liked
})
}
}
这里的颜色和缩放由同一个 liked 状态驱动,因此会一起过渡。需要注意修饰符顺序:animation() 只作用于它之前可动画的属性。若把某个属性写在 animation() 后面,它不会自动获得同样的动画配置。
属性动画适合局部、简单、直接由状态映射出的变化。不要在一个组件链上堆叠多个作用范围不清晰的 animation(),否则后续维护者很难判断某个属性使用哪套时长和曲线。
三、用 animateTo 统一编排状态变化
当多个组件、多个属性需要在一次交互中同步变化时,animateTo() 更清晰。它把闭包中的状态修改作为一个动画事务处理。
ts
@Component
struct FilterPanel {
@State private expanded: boolean = false
build() {
Column({ space: 12 }) {
Row() {
Text('筛选条件')
.fontSize(18)
.fontWeight(FontWeight.Medium)
Blank()
Text(this.expanded ? '收起' : '展开')
}
.width('100%')
.onClick(() => {
animateTo({
duration: 260,
curve: Curve.EaseOut
}, () => {
this.expanded = !this.expanded
})
})
Column({ space: 10 }) {
TextInput({ placeholder: '负责人' })
TextInput({ placeholder: '标签' })
}
.width('100%')
.height(this.expanded ? 104 : 0)
.opacity(this.expanded ? 1 : 0)
.translate({ y: this.expanded ? 0 : -8 })
.clip(true)
}
.padding(16)
}
}
高度、透明度和位移同时变化,用户会感知到面板从标题下方展开。这里使用 clip(true),避免收起过程中子组件绘制到容器外。
业务代码中不建议把异步请求放进 animateTo() 闭包。更稳妥的顺序是:先完成状态校验或网络请求,再在动画事务中提交视图状态。这样动画不会依赖不可预测的异步时长。
ts
private async submit(): Promise<void> {
if (this.submitting) {
return
}
this.submitting = true
try {
await this.repository.save(this.formData)
animateTo({ duration: 180, curve: Curve.EaseOut }, () => {
this.saved = true
})
} finally {
this.submitting = false
}
}
四、为组件创建和销毁添加 transition
条件渲染会创建或销毁组件。只给组件设置 opacity 并不能保证退出过程可见,因为状态改变后组件可能立即离开组件树。transition() 专门描述组件出现和消失时的效果。
ts
@Component
struct ToastDemo {
@State private showMessage: boolean = false
build() {
Stack({ alignContent: Alignment.Bottom }) {
Button('保存任务')
.onClick(() => {
animateTo({ duration: 220 }, () => {
this.showMessage = true
})
})
if (this.showMessage) {
Row({ space: 8 }) {
Text('✓')
Text('保存成功')
}
.padding({ left: 16, right: 16, top: 10, bottom: 10 })
.backgroundColor('#202124')
.fontColor(Color.White)
.borderRadius(6)
.margin({ bottom: 24 })
.transition(
TransitionEffect.OPACITY
.combine(TransitionEffect.translate({ y: 16 }))
.animation({ duration: 220, curve: Curve.EaseOut })
)
.onClick(() => {
animateTo({ duration: 160 }, () => {
this.showMessage = false
})
})
}
}
.width('100%')
.height(240)
}
}
进入和退出可以使用不同配置。进入通常强调"内容到达",适合稍慢的减速曲线;退出强调"操作完成",通常更短。若当前 SDK 支持非对称转场,可分别配置 appear 和 disappear 效果。
还要避免用动画替代完整交互。提示条仍应具备自动关闭、手动关闭、重复消息合并等机制,转场只负责表现,不负责业务生命周期。
五、让列表增删保持稳定
列表动画最常见的问题不是"不够顺滑",而是身份不稳定。ForEach 或 LazyForEach 必须使用稳定且唯一的业务键,不能用数组下标充当长期键。
ts
interface TaskItem {
id: string
title: string
completed: boolean
}
@Component
struct TaskList {
@State private tasks: TaskItem[] = []
build() {
List({ space: 8 }) {
ForEach(this.tasks, (task: TaskItem) => {
ListItem() {
Row() {
Text(task.title)
Blank()
Button('删除')
.onClick(() => this.removeTask(task.id))
}
.width('100%')
.padding(12)
}
.transition(
TransitionEffect.OPACITY
.combine(TransitionEffect.translate({ x: 24 }))
)
}, (task: TaskItem) => task.id)
}
}
private removeTask(id: string): void {
animateTo({ duration: 200, curve: Curve.EaseInOut }, () => {
this.tasks = this.tasks.filter((item: TaskItem) => item.id !== id)
})
}
}
稳定键让框架知道"哪个元素消失、哪些元素只是换了位置"。若使用下标,删除中间项后,后续所有元素的身份都会变化,容易造成内容跳动、状态串位和不必要的重建。
数据量较大时应继续使用懒加载容器,并限制同时播放的复杂效果。列表滚动过程中给每个子项添加阴影、模糊和多层缩放,通常会放大渲染开销。
六、用 geometryTransition 建立共享元素连续性
当用户点击卡片进入详情时,让封面或标题从原位置自然过渡到目标位置,可以明确表达"详情页就是这张卡片的展开"。geometryTransition() 通过相同 ID 关联前后两个几何形态。
ts
@Entry
@Component
struct BoardPage {
@State private selected: boolean = false
private readonly heroId: string = 'task-cover-42'
build() {
Stack() {
if (!this.selected) {
Column() {
Image($r('app.media.task_cover'))
.width(120)
.height(80)
.objectFit(ImageFit.Cover)
.geometryTransition(this.heroId)
Text('版本发布检查')
}
.onClick(() => {
animateTo({ duration: 320, curve: Curve.EaseInOut }, () => {
this.selected = true
})
})
} else {
Column() {
Image($r('app.media.task_cover'))
.width('100%')
.height(240)
.objectFit(ImageFit.Cover)
.geometryTransition(this.heroId)
Text('版本发布检查')
.fontSize(26)
Text('确认签名、版本号、隐私声明与灰度策略。')
}
.onClick(() => {
animateTo({ duration: 280, curve: Curve.EaseInOut }, () => {
this.selected = false
})
})
}
}
.width('100%')
.height('100%')
}
}
共享元素 ID 必须在当前动画范围内唯一且稳定。列表场景应由业务 ID 派生,例如 task-cover-${task.id}。不要给多个同时可见元素复用同一个 ID。
源元素与目标元素的内容、裁剪方式和宽高比越接近,转场越自然。若封面在列表中是正方形、详情中突然变成长横图,需要明确设置 objectFit 和裁剪策略,否则中间帧可能出现拉伸。
七、为 Navigation 设计页面转场
真实应用中的页面切换通常由 Navigation 和 NavPathStack 管理。页面转场应服从导航状态,而不是用两个布尔变量模拟页面栈。
ts
@Entry
@Component
struct TaskApp {
private pathStack: NavPathStack = new NavPathStack()
@Builder
pageMap(name: string, param: object) {
if (name === 'TaskDetail') {
TaskDetailPage({
taskId: (param as Record<string, string>).taskId,
pathStack: this.pathStack
})
}
}
build() {
Navigation(this.pathStack) {
TaskBoard({
onOpen: (taskId: string) => {
this.pathStack.pushPath({
name: 'TaskDetail',
param: { taskId }
})
}
})
}
.title('任务看板')
.navDestination(this.pageMap)
.mode(NavigationMode.Stack)
}
}
系统默认转场已经覆盖大多数页面。自定义 Navigation 转场时,应重点处理以下边界:
- 入栈、出栈与返回手势方向一致。
- 页面被快速连续打开时,不重复压入相同业务页面。
- 转场期间仍以
NavPathStack为真实导航状态。 - 返回手势取消后,页面状态和元素透明度能够恢复。
- 弹窗、半模态和完整页面使用不同空间语义。
不要为了统一视觉而给所有页面套同一种缩放效果。层级页面适合水平或共享元素衔接,临时任务适合从底部出现,跨模块跳转则可以使用更克制的淡入淡出。
八、曲线、时长与节奏如何选择
动画参数应该形成产品级规范,而不是散落在代码中的魔法数字。
| 动作 | 建议时长 | 曲线倾向 |
|---|---|---|
| 按压、开关反馈 | 100~180 ms | 快速减速 |
| 小组件进退场 | 180~260 ms | 进入减速,退出加速 |
| 面板展开收起 | 220~320 ms | 平滑缓入缓出 |
| 页面或共享元素衔接 | 280~420 ms | 强调空间连续性 |
可以集中定义动效令牌:
ts
export class MotionTokens {
static readonly quick: number = 140
static readonly normal: number = 220
static readonly page: number = 320
static readonly standardCurve: Curve = Curve.EaseInOut
static readonly enterCurve: Curve = Curve.EaseOut
}
统一令牌能减少体验漂移,也方便针对低性能设备或减少动态效果偏好进行整体调整。时长不是越短越好:太短看不清空间关系,太长则阻碍高频操作。
九、正确处理连续点击与动画中断
动画期间完全禁止交互通常不是最佳方案。用户可能想立即反向收起面板,系统应从当前视觉状态平滑过渡到新目标,而不是等待旧动画结束。
对于提交、支付等不可重复业务操作,应由业务状态防重:
ts
@State private submitting: boolean = false
Button(this.submitting ? '提交中' : '提交')
.enabled(!this.submitting)
.opacity(this.submitting ? 0.6 : 1)
.onClick(() => this.submit())
对于展开、切换、返回等可逆视觉操作,则可以响应新状态,让框架处理从当前插值位置到新目标的过渡。不要用固定延时把状态"锁死",这会在掉帧、应用切后台或系统动画倍率变化时失去同步。
动画完成回调只适合触发表现层收尾,不应成为保存关键业务数据的唯一时机。应用被中断时,完成回调未必有机会执行。
十、性能优化:优先改变合成属性
动画掉帧常见原因是每一帧都触发昂贵布局或绘制。优化时遵循以下顺序:
- 优先使用
opacity、translate、scale、rotate等合成友好的属性。 - 谨慎持续改变宽高,尤其是复杂布局树中的百分比尺寸。
- 减少大面积模糊、阴影、透明叠层和超大图片参与动画。
- 列表中只为可见且需要反馈的元素播放动画。
- 避免在状态 getter 或
build()相关路径执行重计算。 - 在真机和接近目标配置的设备上观察,而不只依赖预览器。
例如侧边面板可以固定最终宽度,再通过位移显示和隐藏;相比每帧把宽度从零计算到目标值,这通常更稳定:
ts
Column() {
// 面板内容
}
.width(280)
.translate({ x: this.open ? 0 : -280 })
.opacity(this.open ? 1 : 0)
如果动画仍不流畅,应使用 DevEco Studio 的性能分析工具确认瓶颈属于布局、绘制、图片解码还是业务线程阻塞,再针对证据优化。
十一、无障碍与减少动态效果
动画不能成为理解内容的唯一方式。颜色、位移和缩放之外,还应提供文本、图标状态或辅助说明。例如保存成功不能只靠绿色闪烁,还应有"保存成功"的可读文本。
工程中可以把"减少动态效果"抽象为统一配置:
ts
export function motionDuration(normalDuration: number, reduceMotion: boolean): number {
return reduceMotion ? 0 : normalDuration
}
当用户偏好减少动态效果时,应关闭大幅位移、旋转和视差,保留必要的短淡入淡出,或者直接完成状态切换。具体系统偏好的读取方式会随 API 版本变化,应通过当前 SDK 提供的无障碍或系统设置能力接入。
同时检查焦点行为:弹层出现后焦点应进入弹层,关闭后回到触发控件;页面转场不能让屏幕阅读器继续访问已经不可见的旧页面。
十二、测试与排障清单
动画测试不能只验证"最终页面存在"。至少覆盖以下内容:
- 动画前后的业务状态和组件树正确。
- 快速连续点击不会产生重复页面或重复提交。
- 返回、手势取消和应用切后台后状态可恢复。
- 列表增删使用稳定键,内容不会串位。
- 深色模式、横竖屏和字体放大下没有裁剪。
- 低性能设备上没有明显长帧和输入延迟。
- 减少动态效果开启后仍能完成全部操作。
常见排障方向如下:
| 现象 | 优先检查 |
|---|---|
| 属性突然跳到最终值 | animation() 的修饰符顺序、状态是否真的变化 |
| 退出动画不显示 | 是否使用条件渲染配合 transition() 和动画事务 |
| 列表项内容串位 | 键生成函数是否使用了数组下标或可变字段 |
| 共享元素闪烁 | ID 是否唯一、源与目标是否同时存在、裁剪和图片尺寸是否一致 |
| 页面返回后状态异常 | 导航栈是否为唯一事实来源、完成回调是否承担了业务状态 |
| 真机明显掉帧 | 是否动画化布局属性、图片是否过大、主线程是否执行重任务 |
总结
ArkUI 动画的核心仍然是状态管理。animation() 适合局部属性变化,animateTo() 适合统一提交一组视图状态,transition() 负责组件进入和退出,geometryTransition() 与 Navigation 转场负责保持页面之间的空间连续性。
生产级动效还需要稳定的业务键、可中断交互、统一时长与曲线、性能分析和无障碍降级。先把状态、身份和导航关系建模正确,再增加有意义的过渡,界面才能既流畅又可靠。