文章目录
- 前言
- 效果预览
- 布局思路
- 逐步实现
- 定义独立的动画状态
- 方块渲染与动画绑定
- 核心:setTimeout 串联动画
- 波浪效果的嵌套 setTimeout
- 关键代码
- 踩坑记录
- 坑1:延迟时间太短会导致动画"吞掉"
- 坑2:快速连续点击会出bug
- 坑3:animation 和 animateTo 的区别
- 坑4:临时数组写法的注意事项
- 写在最后
前言
做 App 动效的时候,经常碰到一个需求——几个动画不能同时跑,得一个接一个地依次执行。比如四个方块依次弹入画面,或者一组卡片像多米诺骨牌一样波浪式出现。
这种"串联动画"在 iOS 上有UIView.animate的 completion 回调,在 Web 上有 CSS animation-delay。那在 HarmonyOS7 的 ArkUI 里怎么搞?答案可能比你想的要简单:setTimeout+animateTo就能搞定。
这篇文章就来拆解串联动画的实现思路,顺便把踩过的坑也捋一遍。
效果预览
最终实现的效果是这样的:页面上有四个彩色方块,点击"依次进场"按钮后,它们按照从左到右的顺序逐个从透明缩小状态变为正常显示。点击"波浪效果"则会出现更花哨的效果——每个方块先出现再消失,形成一个传递的波浪。
| 按钮 | 效果描述 | 动画时长 |
|---|---|---|
| 依次进场 | 四个方块依次从 opacity=0 变为 opacity=1 | 约 1s 全部完成 |
| 波浪效果 | 逐个出现再消失,形成波浪传递 | 约 1.2s 全部完成 |
| 重置 | 所有方块瞬间回到初始状态 | 无动画 |
布局思路
整个页面结构不复杂,一个纵向滚动容器里面套一个卡片:
- 标题文字
- 四个方块的横向排列(
Row+ForEach) - 底部按钮组
关键是每个方块需要绑定独立的动画状态值。这一点很重要——如果四个方块共享一个状态变量,那就只能同时动,没法分别控制。
逐步实现
定义独立的动画状态
给每个方块准备一个独立的状态变量:
@Entry@Componentstruct SequentialAnimationChain{@Statestep1:number=0@Statestep2:number=0@Statestep3:number=0@Statestep4:number=0build(){Column(){// ...}}}为什么不用数组?说实话,ArkUI 的@State对数组内元素的变更检测有时候不够灵敏,直接改数组的某个索引值不一定能触发 UI 刷新。用独立的变量虽然写起来有点笨,但最可靠。
方块渲染与动画绑定
四个方块通过ForEach渲染,每个方块的opacity和scale绑定到对应的状态变量上:
Row({space:12}){ForEach([0,1,2,3],(idx:number)=>{Column().width(60).height(60).backgroundColor(PRESET_COLORS[idx].value).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)这里用了个小技巧——把四个变量组成一个临时数组[this.step1, this.step2, this.step3, this.step4],然后通过索引idx取值。这样ForEach里面就不需要写if-else判断了。
核心:setTimeout 串联动画
"依次进场"的实现逻辑是最关键的部分:
Button('依次进场').onClick(()=>{// 先全部重置为 0this.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)})核心原理:每个
setTimeout的延迟时间递增 200ms,让四个animateTo调用在时间轴上错开,形成依次执行的视觉效果。
画个时间轴就清楚了:
时间轴: 0ms → step1 开始动画 (0→1, 持续400ms) 200ms → step2 开始动画 (0→1, 持续400ms) 400ms → step3 开始动画 (0→1, 持续400ms) 600ms → step4 开始动画 (0→1, 持续400ms)波浪效果的嵌套 setTimeout
波浪效果更有意思——每个方块先出现再消失,而且是逐个传递的:
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})// 第二阶段:延迟300ms后消失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)}})这段代码嵌套了两层setTimeout:
- 外层控制每个方块的启动时间,间隔 150ms
- 内层控制方块出现后的消失时机,延迟 300ms
关键代码
把完整的核心逻辑整理一遍。先看重置操作:
Button('重置').onClick(()=>{this.step1=0;this.step2=0;this.step3=0;this.step4=0})重置不需要animateTo,直接赋值就会瞬间跳回初始状态。如果你希望重置也有动画过渡,把赋值放进animateTo里就行。
完整的组件结构:
@Entry@Componentstruct SequentialAnimationChain{@Statestep1:number=0@Statestep2:number=0@Statestep3:number=0@Statestep4:number=0build(){Column(){Scroll(){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(PRESET_COLORS[idx].value).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)// 按钮组...}.width('100%').backgroundColor('#FFFFFF').borderRadius(12).padding(16)}.width('100%')}.layoutWeight(1)}.width('100%').height('100%').backgroundColor('#F5F6FA').padding(16)}}踩坑记录
坑1:延迟时间太短会导致动画"吞掉"
如果两个setTimeout的延迟间隔太短(比如只有 50ms),前一个动画还没展开,后一个就开始了,视觉上看起来就像同时出现。建议间隔至少在150-200ms以上。
坑2:快速连续点击会出bug
用户疯狂点击"依次进场"按钮,会导致多组setTimeout叠加,方块的状态会乱。解决办法很简单,加个节流标记:
@StateisAnimating:boolean=falseButton('依次进场').onClick(()=>{if(this.isAnimating)returnthis.isAnimating=true// 动画逻辑...setTimeout(()=>{this.isAnimating=false},1200)})坑3:animation 和 animateTo 的区别
.animation()是声明式动画,绑定在组件属性上,当属性变化时自动执行。而animateTo()是命令式调用,在闭包里改变状态变量来触发动画。串联动画场景下,两者配合使用效果最好——用.animation()定义动画参数,用animateTo()控制触发时机。
坑4:临时数组写法的注意事项
.opacity(([this.step1,this.step2,this.step3,this.step4])[idx])这行代码每次build()都会创建一个新的临时数组。在ForEach场景下这是可以接受的,但如果列表很长(比如几十个元素),频繁创建数组会有性能问题,建议改用 Map 或者独立组件。
写在最后
串联动画的核心就一句话:用setTimeout控制时间轴,用animateTo控制动画触发。
这种写法虽然不像 iOS 的UIViewPropertyAnimator那么优雅,但胜在简单直观,不需要学习额外的 API。实际项目中,把延迟时间和动画参数抽成常量,维护起来也不会太痛苦。