用 setTimeout 编排动画序列:HarmonyOS6 PC 串联动画的正确姿势
2026/6/10 9:44:37 网站建设 项目流程

搞 HarmonyOS6 PC 端开发的过程中,有一个动画需求几乎每个项目都会遇到,但文档里几乎找不到完整方案——让几个动画按顺序依次执行

举个例子:页面上有 4 个卡片,要它们一个一个飞进来,而不是一窝蜂同时出现。这种效果在引导页、数据面板加载、成就展示等场景里特别常见。

问题来了:animateTo()是个异步执行的函数,它不会等你动画做完再往下走代码。你连着写 4 个animateTo(),它们会同时触发,根本不会排队。

这篇文章就来聊怎么用setTimeout把动画串起来,以及这个方案的一些坑和更优雅的替代方案。

先看效果:4 个方块依次进场

直接上代码,这个 Demo 实现了两种串联效果——“依次进场"和"波浪效果”。

@Entry@Componentstruct SerialAnimationDemo{@Statestep1:number=0@Statestep2:number=0@Statestep3:number=0@Statestep4:number=0build(){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=0setTimeout(()=>{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(leti=0;i<4;i++){setTimeout(()=>{animateTo({duration:300,curve:Curve.EaseOut},()=>{if(i===0)this.step1=1if(i===1)this.step2=1if(i===2)this.step3=1if(i===3)this.step4=1})setTimeout(()=>{animateTo({duration:300},()=>{if(i===0)this.step1=0if(i===1)this.step2=0if(i===2)this.step3=0if(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{constcolors=['#FF6B6B','#FFA500','#FFD93D','#6BCB77']returncolors[index]}}

代码拆解:为什么这么写?

状态设计的思路

4 个方块,每个方块需要控制 opacity(透明度)和 scale(缩放),所以我定义了 4 个独立的状态变量:step1step4

每个方块同时绑定了 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(leti=0;i<4;i++){setTimeout(()=>{// 第一步:放大(step 变 1)animateTo({duration:300,curve:Curve.EaseOut},()=>{if(i===0)this.step1=1if(i===1)this.step2=1if(i===2)this.step3=1if(i===3)this.step4=1})// 第二步:300ms 后缩回去(step 变 0)setTimeout(()=>{animateTo({duration:300},()=>{if(i===0)this.step1=0if(i===1)this.step2=0if(i===2)this.step3=0if(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 在正常负载下的误差通常也就个位数毫秒。

但如果你遇到了这些情况,就要小心了:

  1. 大量动画同时排队:如果一次排了 20+ 个 setTimeout,主线程可能在密集触发时出现掉帧
  2. 动画执行期间有重计算:比如动画过程中在 doing 大量数据处理,会抢占主线程
  3. 需要严格同步的多设备动画:这个场景 setTimeout 确实不够精确

一个实际踩过的坑

我曾经在一个 HarmonyOS6 PC 项目里遇到过这样的问题:页面有 15 个列表项要依次入场,每项间隔 80ms。在开发机上跑得挺好,到了低配 PC 上,前几个动画正常,后面的明显卡顿和堆积。

原因是每个animateTo()触发后,ArkUI 框架要在渲染线程做插值计算,15 个动画密集创建对渲染管线有一定压力。

解决方案:把间隔从 80ms 加大到 120ms,同时把动画时长从 400ms 缩短到 250ms。总体时间差不多,但每个动画的重叠更少,渲染压力小了很多。

更优雅的方案:Promise 封装

如果你觉得一堆 setTimeout 嵌套着太丑,可以用 Promise 包装一下:

// 封装一个返回 Promise 的延迟函数functiondelay(ms:number):Promise<void>{returnnewPromise((resolve)=>{setTimeout(()=>resolve(),ms)})}// 封装一个带动画的延迟函数functionanimateWithDelay(delayMs:number,duration:number,curve:Curve,action:()=>void):Promise<void>{returnnewPromise((resolve)=>{setTimeout(()=>{animateTo({duration:duration,curve:curve,onFinish:()=>resolve()},action)},delayMs)})}

有了这个工具函数,你就可以用 async/await 来写动画序列了:

asyncfunctionplayEntryAnimation(){// 方块1 先进场,做完等它awaitanimateWithDelay(0,400,Curve.EaseOut,()=>{this.step1=1})// 方块1 做完后,方块2 进场awaitanimateWithDelay(0,400,Curve.EaseOut,()=>{this.step2=1})// 然后方块3awaitanimateWithDelay(0,400,Curve.EaseOut,()=>{this.step3=1})// 最后方块4awaitanimateWithDelay(0,400,Curve.EaseOut,()=>{this.step4=1})}

这种写法的好处是完全串行——前一个动画的 onFinish 触发后才开始下一个。时间控制最精确,不会出现 setTimeout 的累积误差。

但缺点是代码不够灵活。如果你想让动画有重叠(像上面 Demo 里的效果),就得组合使用 Promise.all:

asyncfunctionplayOverlapAnimation(){// 同时启动所有动画,但各自有不同的延迟awaitPromise.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了,得用数组:

@StateitemOpacities:number[]=[]aboutToAppear(){// 根据数据量初始化状态数组this.itemOpacities=newArray(this.dataList.length).fill(0)}playSequentialAnimation(){constinterval=100// 每项间隔 100msfor(leti=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},()=>{constnewState=[...this.itemOpacities]newState[i]=1this.itemOpacities=newState})},i*interval)

或者更保险的方式——使用@ObjectLink@Observed装饰数据模型类,让每个列表项自己管理自己的动画状态。这种方式在大型列表里性能更好,因为不需要每次都替换整个数组。

HarmonyOS6 PC 端的特别考虑

PC 端和手机端在串联动画上有个关键区别:PC 端屏幕大,元素多

手机上做 4 个卡片的依次进场,用户一眼就能看全。但在 HarmonyOS6 PC 端,你可能面对的是 20 个列表项、3 列卡片网格、侧边栏 + 主内容区同时入场。

几个经验:

  1. 控制总时长:不管多少元素,串联动画的总时长别超过 1.5-2 秒。用户不愿意等。
  2. 分组入场:把元素分成几个组,组内并联,组间串联。比如"标题+搜索框"先进 → "卡片网格"再进 → "底部操作栏"最后进。
  3. 可视区域优先:只给当前可见区域的元素做入场动画,滚动后才出现的元素可以用另一组延迟更短的动画。
  4. 提供跳过机制:如果是非首次进入页面,考虑直接跳过入场动画或大幅缩短间隔。

小结

用 setTimeout 串联 animateTo 不是最优雅的方案,但它是目前 HarmonyOS ArkUI 里最实用的方案。核心就三句话:

  1. setTimeout 控制"什么时候开始"
  2. animateTo 控制"怎么变"
  3. 两者嵌套实现"先变这个,再变那个"

Promise 封装可以让代码更干净,但在需要动画重叠的场景下,setTimeout 的时间轴编排反而更直观。

做 HarmonyOS6 PC 开发,动画编排能力是绕不过去的坎。把这个 Demo 里的两种效果都跑一遍,改改参数,试试 8 个方块、12 个方块的效果,你对时间轴的感觉就出来了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询