搞 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 个独立的状态变量: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 (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 在正常负载下的误差通常也就个位数毫秒。

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

  1. 大量动画同时排队:如果一次排了 20+ 个 setTimeout,主线程可能在密集触发时出现掉帧
  2. 动画执行期间有重计算:比如动画过程中在 doing 大量数据处理,会抢占主线程
  3. 需要严格同步的多设备动画:这个场景 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. 控制总时长:不管多少元素,串联动画的总时长别超过 1.5-2 秒。用户不愿意等。
  2. 分组入场:把元素分成几个组,组内并联,组间串联。比如"标题+搜索框"先进 → "卡片网格"再进 → "底部操作栏"最后进。
  3. 可视区域优先:只给当前可见区域的元素做入场动画,滚动后才出现的元素可以用另一组延迟更短的动画。
  4. 提供跳过机制:如果是非首次进入页面,考虑直接跳过入场动画或大幅缩短间隔。

小结

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

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

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

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

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐