用 setTimeout 编排动画序列:HarmonyOS6 PC 串联动画的正确姿势
搞 HarmonyOS6 PC 端开发的过程中,有一个动画需求几乎每个项目都会遇到,但文档里几乎找不到完整方案——让几个动画按顺序依次执行。
举个例子:页面上有 4 个卡片,要它们一个一个飞进来,而不是一窝蜂同时出现。这种效果在引导页、数据面板加载、成就展示等场景里特别常见。
问题来了:animateTo() 是个异步执行的函数,它不会等你动画做完再往下走代码。你连着写 4 个 animateTo(),它们会同时触发,根本不会排队。
这篇文章就来聊怎么用 setTimeout 把动画串起来,以及这个方案的一些坑和更优雅的替代方案。

先看效果:4 个方块依次进场
直接上代码,这个 Demo 实现了两种串联效果——“依次进场"和"波浪效果”。
@Entry
@Component
struct SerialAnimationDemo {
@State step1: number = 0
@State step2: number = 0
@State step3: number = 0
@State step4: number = 0
build() {
Column() {
Text('串联动画')
.fontSize(18).fontWeight(FontWeight.Bold).margin({ bottom: 8 })
Column() {
Row({ space: 12 }) {
ForEach([0, 1, 2, 3], (idx: number) => {
Column()
.width(60).height(60)
.backgroundColor(this.getColor(idx))
.borderRadius(12)
.opacity(([this.step1, this.step2, this.step3, this.step4])[idx])
.scale({
x: ([this.step1, this.step2, this.step3, this.step4])[idx],
y: ([this.step1, this.step2, this.step3, this.step4])[idx]
})
.animation({ duration: 400, curve: Curve.EaseOut })
})
}
.width('100%').justifyContent(FlexAlign.Center)
Row({ space: 10 }) {
Button('依次进场')
.onClick(() => {
this.step1 = 0; this.step2 = 0; this.step3 = 0; this.step4 = 0
setTimeout(() => {
animateTo({ duration: 400 }, () => { this.step1 = 1 })
}, 0)
setTimeout(() => {
animateTo({ duration: 400 }, () => { this.step2 = 1 })
}, 200)
setTimeout(() => {
animateTo({ duration: 400 }, () => { this.step3 = 1 })
}, 400)
setTimeout(() => {
animateTo({ duration: 400 }, () => { this.step4 = 1 })
}, 600)
})
Button('波浪效果')
.onClick(() => {
for (let i = 0; i < 4; i++) {
setTimeout(() => {
animateTo({ duration: 300, curve: Curve.EaseOut }, () => {
if (i === 0) this.step1 = 1
if (i === 1) this.step2 = 1
if (i === 2) this.step3 = 1
if (i === 3) this.step4 = 1
})
setTimeout(() => {
animateTo({ duration: 300 }, () => {
if (i === 0) this.step1 = 0
if (i === 1) this.step2 = 0
if (i === 2) this.step3 = 0
if (i === 3) this.step4 = 0
})
}, 300)
}, i * 150)
}
})
Button('重置')
.onClick(() => {
this.step1 = 0; this.step2 = 0; this.step3 = 0; this.step4 = 0
})
}
.width('100%').justifyContent(FlexAlign.SpaceEvenly).margin({ top: 16 })
}
.width('100%').backgroundColor('#FFFFFF').borderRadius(12).padding(16).margin({ top: 12 })
}
.width('100%').height('100%').backgroundColor('#F5F6FA').padding(16)
}
getColor(index: number): string {
const colors = ['#FF6B6B', '#FFA500', '#FFD93D', '#6BCB77']
return colors[index]
}
}

代码拆解:为什么这么写?
状态设计的思路
4 个方块,每个方块需要控制 opacity(透明度)和 scale(缩放),所以我定义了 4 个独立的状态变量:step1 到 step4。
每个方块同时绑定了 opacity 和 scale 两个属性。当 step 值从 0 变为 1 时,方块从"完全透明 + 缩放为零"变为"完全不透明 + 正常大小",视觉上就是一个"从小到大弹出来"的效果。
说实话,用数组来做会更优雅,但 ArkUI 的 @State 对数组索引赋值的响应式追踪在某些场景下不够灵敏,用独立变量是最稳的方案。
.animation() 修饰器的作用
每个方块上都挂了 .animation({ duration: 400, curve: Curve.EaseOut })。这意味着当 step 值通过 animateTo() 改变时,opacity 和 scale 的变化会自动做 400ms 的 EaseOut 过渡。
这里没有用 .animation() 的 curve 来串联——真正控制时间轴的是 animateTo() 和 setTimeout 的配合。
依次进场:核心逻辑
setTimeout(() => { animateTo({ duration: 400 }, () => { this.step1 = 1 }) }, 0)
setTimeout(() => { animateTo({ duration: 400 }, () => { this.step2 = 1 }) }, 200)
setTimeout(() => { animateTo({ duration: 400 }, () => { this.step3 = 1 }) }, 400)
setTimeout(() => { animateTo({ duration: 400 }, () => { this.step4 = 1 }) }, 600)
时间轴是这样的:
时间线 (ms): 0 ----200 ----400 ----600 ----800 ----1000
方块1: |==动画==|
方块2: |==动画==|
方块3: |==动画==|
方块4: |==动画==|
每个方块间隔 200ms 启动,但每个动画本身要 400ms。所以方块 1 的动画还没结束,方块 2 就已经开始了。这种部分重叠的效果比"一个做完再开始下一个"更流畅,视觉上不会有明显的"等待感"。
如果你想要严格的前后衔接(前一个做完再开始下一个),把间隔改成 400ms 就行了:
setTimeout(() => { animateTo({ duration: 400 }, () => { this.step1 = 1 }) }, 0)
setTimeout(() => { animateTo({ duration: 400 }, () => { this.step2 = 1 }) }, 400)
setTimeout(() => { animateTo({ duration: 400 }, () => { this.step3 = 1 }) }, 800)
setTimeout(() => { animateTo({ duration: 400 }, () => { this.step4 = 1 }) }, 1200)
波浪效果:嵌套 setTimeout
波浪效果的逻辑更复杂一些——每个方块先放大再缩回去,而且彼此之间有重叠:
for (let i = 0; i < 4; i++) {
setTimeout(() => {
// 第一步:放大(step 变 1)
animateTo({ duration: 300, curve: Curve.EaseOut }, () => {
if (i === 0) this.step1 = 1
if (i === 1) this.step2 = 1
if (i === 2) this.step3 = 1
if (i === 3) this.step4 = 1
})
// 第二步:300ms 后缩回去(step 变 0)
setTimeout(() => {
animateTo({ duration: 300 }, () => {
if (i === 0) this.step1 = 0
if (i === 1) this.step2 = 0
if (i === 2) this.step3 = 0
if (i === 3) this.step4 = 0
})
}, 300)
}, i * 150)
}
时间轴变成了这样:
方块1: 放大|缩小|
方块2: 放大|缩小|
方块3: 放大|缩小|
方块4: 放大|缩小|
每个方块在放大 300ms 后自动缩回去,而下一个方块在 150ms 后就开始放大。效果就是波浪从左传到右。
这里有个小技巧——嵌套的 setTimeout。外层 setTimeout 控制每个方块的启动时机,内层 setTimeout 控制"放大后多久缩回去"。这种嵌套写法虽然可读性不太好,但在 ArkUI 目前的动画 API 下是最直接的方案。
setTimeout 精度问题:真的靠谱吗?
坦白讲,setTimeout 的精度在 JavaScript/ArkTS 运行时里是个老问题。
规范保证的是"至少延迟这么久",而不是"精确延迟这么久"。也就是说 setTimeout(fn, 200) 的实际执行时间可能是 202ms、205ms,极端情况下甚至可能 210ms+。
对于 UI 动画来说,这个精度其实够用了。人眼对 10-20ms 的时间差基本无感,而 setTimeout 在正常负载下的误差通常也就个位数毫秒。
但如果你遇到了这些情况,就要小心了:
- 大量动画同时排队:如果一次排了 20+ 个 setTimeout,主线程可能在密集触发时出现掉帧
- 动画执行期间有重计算:比如动画过程中在 doing 大量数据处理,会抢占主线程
- 需要严格同步的多设备动画:这个场景 setTimeout 确实不够精确
一个实际踩过的坑
我曾经在一个 HarmonyOS6 PC 项目里遇到过这样的问题:页面有 15 个列表项要依次入场,每项间隔 80ms。在开发机上跑得挺好,到了低配 PC 上,前几个动画正常,后面的明显卡顿和堆积。
原因是每个 animateTo() 触发后,ArkUI 框架要在渲染线程做插值计算,15 个动画密集创建对渲染管线有一定压力。
解决方案:把间隔从 80ms 加大到 120ms,同时把动画时长从 400ms 缩短到 250ms。总体时间差不多,但每个动画的重叠更少,渲染压力小了很多。
更优雅的方案:Promise 封装

如果你觉得一堆 setTimeout 嵌套着太丑,可以用 Promise 包装一下:
// 封装一个返回 Promise 的延迟函数
function delay(ms: number): Promise<void> {
return new Promise((resolve) => {
setTimeout(() => resolve(), ms)
})
}
// 封装一个带动画的延迟函数
function animateWithDelay(
delayMs: number,
duration: number,
curve: Curve,
action: () => void
): Promise<void> {
return new Promise((resolve) => {
setTimeout(() => {
animateTo({ duration: duration, curve: curve, onFinish: () => resolve() }, action)
}, delayMs)
})
}
有了这个工具函数,你就可以用 async/await 来写动画序列了:
async function playEntryAnimation() {
// 方块1 先进场,做完等它
await animateWithDelay(0, 400, Curve.EaseOut, () => { this.step1 = 1 })
// 方块1 做完后,方块2 进场
await animateWithDelay(0, 400, Curve.EaseOut, () => { this.step2 = 1 })
// 然后方块3
await animateWithDelay(0, 400, Curve.EaseOut, () => { this.step3 = 1 })
// 最后方块4
await animateWithDelay(0, 400, Curve.EaseOut, () => { this.step4 = 1 })
}
这种写法的好处是完全串行——前一个动画的 onFinish 触发后才开始下一个。时间控制最精确,不会出现 setTimeout 的累积误差。
但缺点是代码不够灵活。如果你想让动画有重叠(像上面 Demo 里的效果),就得组合使用 Promise.all:
async function playOverlapAnimation() {
// 同时启动所有动画,但各自有不同的延迟
await Promise.all([
animateWithDelay(0, 400, Curve.EaseOut, () => { this.step1 = 1 }),
animateWithDelay(200, 400, Curve.EaseOut, () => { this.step2 = 1 }),
animateWithDelay(400, 400, Curve.EaseOut, () => { this.step3 = 1 }),
animateWithDelay(600, 400, Curve.EaseOut, () => { this.step4 = 1 }),
])
// 所有动画都完成后才会走到这里
console.log('全部动画完成!')
}
串联 vs 并联:什么时候用哪种?
这个问题我觉得值得单独说一下,因为很多开发者其实没有主动思考过。
串联动画(依次执行)适合这些场景:
- 引导页的步骤说明——第一步讲完再讲第二步
- 数据加载完成后的逐项展示——先出标题、再出图表、再出按钮
- 成就/奖励的逐一揭晓——制造悬念感
- 表单的分步填写——引导用户注意力
并联/重叠动画(同时或有重叠地执行)适合这些场景:
- 页面整体入场——所有元素协调地出现
- 列表项的交错入场——虽然每个都有延迟,但彼此有重叠
- 复杂的状态切换——颜色、大小、位置同时变化
说实话,实际项目里用得最多的其实是有延迟的重叠动画——就是上面 Demo 里"依次进场"那种写法。它既有串联的节奏感,又不会因为完全串行而显得拖沓。
关于动态列表项的串联动画
Demo 里只有 4 个方块,但如果你的列表项数量不固定呢?比如从后端拿回来的数据可能有 3 条也可能有 20 条。
这时候就不能硬编码 step1, step2, step3, step4 了,得用数组:
@State itemOpacities: number[] = []
aboutToAppear() {
// 根据数据量初始化状态数组
this.itemOpacities = new Array(this.dataList.length).fill(0)
}
playSequentialAnimation() {
const interval = 100 // 每项间隔 100ms
for (let i = 0; i < this.dataList.length; i++) {
setTimeout(() => {
animateTo({ duration: 350, curve: Curve.EaseOut }, () => {
this.itemOpacities[i] = 1
})
}, i * interval)
}
}
但这里有个坑:ArkUI 的 @State 装饰器对数组内部元素的修改,在某些版本里可能不会触发响应式更新。解决方案是用一个新的数组替换旧数组:
setTimeout(() => {
animateTo({ duration: 350, curve: Curve.EaseOut }, () => {
const newState = [...this.itemOpacities]
newState[i] = 1
this.itemOpacities = newState
})
}, i * interval)
或者更保险的方式——使用 @ObjectLink 或 @Observed 装饰数据模型类,让每个列表项自己管理自己的动画状态。这种方式在大型列表里性能更好,因为不需要每次都替换整个数组。
HarmonyOS6 PC 端的特别考虑
PC 端和手机端在串联动画上有个关键区别:PC 端屏幕大,元素多。
手机上做 4 个卡片的依次进场,用户一眼就能看全。但在 HarmonyOS6 PC 端,你可能面对的是 20 个列表项、3 列卡片网格、侧边栏 + 主内容区同时入场。
几个经验:
- 控制总时长:不管多少元素,串联动画的总时长别超过 1.5-2 秒。用户不愿意等。
- 分组入场:把元素分成几个组,组内并联,组间串联。比如"标题+搜索框"先进 → "卡片网格"再进 → "底部操作栏"最后进。
- 可视区域优先:只给当前可见区域的元素做入场动画,滚动后才出现的元素可以用另一组延迟更短的动画。
- 提供跳过机制:如果是非首次进入页面,考虑直接跳过入场动画或大幅缩短间隔。
小结
用 setTimeout 串联 animateTo 不是最优雅的方案,但它是目前 HarmonyOS ArkUI 里最实用的方案。核心就三句话:
- setTimeout 控制"什么时候开始"
- animateTo 控制"怎么变"
- 两者嵌套实现"先变这个,再变那个"
Promise 封装可以让代码更干净,但在需要动画重叠的场景下,setTimeout 的时间轴编排反而更直观。
做 HarmonyOS6 PC 开发,动画编排能力是绕不过去的坎。把这个 Demo 里的两种效果都跑一遍,改改参数,试试 8 个方块、12 个方块的效果,你对时间轴的感觉就出来了。
更多推荐
所有评论(0)