你有没有注意过,很多PC端应用里的"录音中"按钮会一直跳动?或者"正在连接"的图标会持续闪烁放大缩小?这种效果有个通用名字叫脉冲动画(Pulse Animation)。
脉冲动画的核心目的就一个——吸引注意力。它告诉用户:“嘿,这里有东西在进行中,别忘了我。”
在HarmonyOS6 PC端开发中,脉冲动画的实现其实挺有意思的。它不像展开折叠或者Tab切换那样是一次性触发的,而是一个持续循环的过程。怎么让动画一直跑?怎么优雅地停下来?这里面有不少门道。
效果描述
我们要做的是一个圆形按钮的脉冲效果:
- 正常情况下,圆形按钮保持静止
- 点击"开始脉冲",按钮开始持续地放大+变透明,然后缩回来恢复原样,循环往复
- 点击"停止脉冲",动画优雅地停下来,按钮回到初始状态
- 还有一个"快速脉冲"按钮,做一次快速的放大缩小就停
三种模式对应了脉冲动画在实际应用中的三种典型场景:持续提示、手动控制、一次性反馈。
状态变量与定时器
先来看状态设计:
@Entry@Componentstruct PulseDemo{@StatepulseScale:number=1@StatepulseOpacity:number=1@StateisPulsing:boolean=falseprivatepulseTimer:number=-1// ...}这里有两个动画属性——pulseScale控制缩放,pulseOpacity控制透明度。加上一个isPulsing布尔值作为"开关"。
pulseTimer是个定时器ID,虽然在这个实现里我们用了递归setTimeout而不是setInterval,但保留一个定时器引用是个好习惯,方便后续扩展。
脉冲的核心:递归 setTimeout 驱动循环
这是整个脉冲动画最关键的部分。我们来看"开始脉冲"按钮的点击逻辑:
Button('开始脉冲').onClick(()=>{if(this.isPulsing)returnthis.isPulsing=trueconstpulse=()=>{if(!this.isPulsing){this.pulseScale=1this.pulseOpacity=1return}// 第一阶段:放大 + 变透明animateTo({duration:600,curve:Curve.EaseOut},()=>{this.pulseScale=1.3this.pulseOpacity=0.5})// 第二阶段:600ms后缩回 + 恢复透明度setTimeout(()=>{animateTo({duration:600,curve:Curve.EaseIn},()=>{this.pulseScale=1this.pulseOpacity=1})// 第三阶段:如果还在脉冲状态,继续下一轮if(this.isPulsing)setTimeout(pulse,200)},600)}pulse()})这段代码用了递归调用来实现循环。说实话,很多人第一眼看到会觉得有点绕。我来画一下执行流程。
执行流程拆解
pulse() 被调用 │ ├─ 检查 isPulsing,如果为 false,恢复初始状态并退出 │ ├─ animateTo:scale 1→1.3,opacity 1→0.5(600ms,EaseOut) │ ↓ 600ms 后 │ ├─ setTimeout 触发: │ ├─ animateTo:scale 1.3→1,opacity 0.5→1(600ms,EaseIn) │ │ ↓ 600ms 后 │ │ │ └─ 检查 isPulsing,如果还是 true,等 200ms 后再次调用 pulse() │ ↓ 200ms 后 │ pulse() 被调用(新的一轮) │ └─ 循环继续...每一轮脉冲的总时长是 600ms(放大)+ 600ms(缩回)+ 200ms(间隔)= 1400ms。中间那200ms的间隔是为了让两轮脉冲之间有一个短暂的"停顿",不至于连得太紧让用户眼睛不舒服。
为什么用递归 setTimeout 而不是 setInterval?
这是个很常见的问题。坦白讲,两种方式都能实现循环,但递归setTimeout有几个优势:
更精确的时序控制。
setInterval的回调是"固定间隔"触发的,如果某次回调执行时间较长,可能导致回调堆积。递归setTimeout是上一次执行完才安排下一次,不会出现这个问题。方便做条件判断。每次循环结束前都可以检查
isPulsing,优雅地决定是否继续。如果用setInterval,你需要额外管理clearInterval的时机。动画阶段更灵活。一个脉冲周期里有"放大"和"缩回"两个阶段,用
setTimeout嵌套来编排这两个阶段比用setInterval清晰得多。
缓动曲线的选择
你可能注意到了,放大阶段用的是Curve.EaseOut,缩回阶段用的是Curve.EaseIn。这不是随便选的。
- EaseOut(快起慢收):放大过程一开始比较快,然后减速。视觉上给人一种"弹出去"的感觉,很有冲击力。
- EaseIn(慢起快收):缩回过程一开始比较慢,然后加速回去。视觉上给人一种"被吸回去"的感觉。
这个组合模拟了物理世界中弹性体的运动规律——弹出去的时候有初速度所以快,但被阻力减速;弹回来的时候被"拉力"加速。虽然是数字动画,但这种物理感会让效果看起来自然很多。
如果你两个阶段都用EaseInOut,脉冲效果会过于"均匀",缺少那种弹性的活力感。
动画属性的绑定
脉冲效果的视觉呈现是通过.scale()和.opacity()绑定状态变量,再用.animation()修饰器驱动的:
Stack(){Column().width(60).height(60).backgroundColor('#007DFF').borderRadius(30).scale({x:this.pulseScale,y:this.pulseScale}).opacity(this.pulseOpacity).animation({duration:600,curve:Curve.EaseInOut})Text('P').fontSize(22).fontColor('#FFFFFF').fontWeight(FontWeight.Bold)}.width(80).height(80)这里用了Stack把圆形背景和文字"P"叠加在一起。注意.scale()、.opacity()和.animation()都加在了背景的Column上,而Text不受影响——文字"P"始终保持静止。
为什么要这样设计?因为脉冲动画的目的是让背景圆跳动来吸引注意力,而文字如果也跟着放大缩小,会变得难以阅读。在PC端这种信息密度高的场景下,可读性永远排在第一位。
停止脉冲:优雅退出
停止逻辑非常简单:
Button('停止脉冲').onClick(()=>{this.isPulsing=false})就是把isPulsing设为false。
然后在pulse函数开头有这个检查:
if(!this.isPulsing){this.pulseScale=1this.pulseOpacity=1return}当isPulsing变为false后,当前正在执行的动画会自然跑完(因为animateTo已经启动了),然后在下一轮pulse调用时,检测到isPulsing为false,把属性恢复到初始值,退出循环。
这个设计的好处是停止过程是"优雅"的——不会突然跳到初始状态,而是让当前的缩放/透明度变化自然完成,再在下一个周期退出。用户不会感到突兀。
快速脉冲:一次性反馈效果
"快速脉冲"按钮实现的是另一种模式——不循环,只做一次快速的放大缩小:
Button('快速脉冲').onClick(()=>{this.isPulsing=false// 先停止可能正在进行的持续脉冲animateTo({duration:200,curve:Curve.EaseOut},()=>{this.pulseScale=1.4this.pulseOpacity=0.4})setTimeout(()=>{animateTo({duration:200},()=>{this.pulseScale=1this.pulseOpacity=1})},200)})跟持续脉冲相比,快速脉冲有几个区别:
- 时长更短:200ms vs 600ms,节奏快得多
- 幅度更大:放大到1.4倍 vs 1.3倍,透明度降到0.4 vs 0.5
- 不循环:做完一次就停
这种效果在PC端的实际应用场景很多:
- 按钮点击后的反馈动效
- 下载完成时的提示
- 发送消息成功后的确认
- 表单提交后的处理状态提示
它比单纯的静态反馈更有"确认感",但又不会像持续脉冲那样一直抢注意力。
完整代码
@Entry@Componentstruct PulseDemo{@StatepulseScale:number=1@StatepulseOpacity:number=1@StateisPulsing:boolean=falseprivatepulseTimer:number=-1build(){Column(){Scroll(){Column(){Text('脉冲动画').fontSize(18).fontWeight(FontWeight.Bold).margin({bottom:8})Column(){// 脉冲目标Row(){Stack(){Column().width(60).height(60).backgroundColor('#007DFF').borderRadius(30).scale({x:this.pulseScale,y:this.pulseScale}).opacity(this.pulseOpacity).animation({duration:600,curve:Curve.EaseInOut})Text('P').fontSize(22).fontColor('#FFFFFF').fontWeight(FontWeight.Bold)}.width(80).height(80)}.width('100%').height(120).justifyContent(FlexAlign.Center)// 控制按钮Row({space:10}){Button('开始脉冲').onClick(()=>{if(this.isPulsing)returnthis.isPulsing=trueconstpulse=()=>{if(!this.isPulsing){this.pulseScale=1this.pulseOpacity=1return}animateTo({duration:600,curve:Curve.EaseOut},()=>{this.pulseScale=1.3this.pulseOpacity=0.5})setTimeout(()=>{animateTo({duration:600,curve:Curve.EaseIn},()=>{this.pulseScale=1this.pulseOpacity=1})if(this.isPulsing)setTimeout(pulse,200)},600)}pulse()})Button('停止脉冲').onClick(()=>{this.isPulsing=false})Button('快速脉冲').onClick(()=>{this.isPulsing=falseanimateTo({duration:200,curve:Curve.EaseOut},()=>{this.pulseScale=1.4this.pulseOpacity=0.4})setTimeout(()=>{animateTo({duration:200},()=>{this.pulseScale=1this.pulseOpacity=1})},200)})}.width('100%').justifyContent(FlexAlign.SpaceEvenly).margin({top:16})}.width('100%').backgroundColor('#FFFFFF').borderRadius(12).padding(16)}.width('100%')}.layoutWeight(1)}.width('100%').height('100%').backgroundColor('#F5F6FA').padding(16)}}循环动画的通用模式
脉冲动画本质上是一种"循环动画"。在ArkUI中实现循环动画,有几种常见的模式。
模式一:递归 setTimeout(本例使用的方式)
最灵活的方式。可以精确控制每个阶段的时长、缓动曲线和结束条件。适合阶段数量少、每个阶段逻辑不同的循环动画。
模式二:animateTo + onComplete 回调
animateTo其实支持onComplete回调,可以在动画结束后执行后续逻辑:
constdoPulse=()=>{if(!this.isPulsing)returnanimateTo({duration:600,curve:Curve.EaseOut,onFinish:()=>{animateTo({duration:600,curve:Curve.EaseIn,onFinish:()=>{if(this.isPulsing){setTimeout(doPulse,200)}}},()=>{this.pulseScale=1this.pulseOpacity=1})}},()=>{this.pulseScale=1.3this.pulseOpacity=0.5})}用onFinish回调替代setTimeout,时序上更精确——动画确确实实跑完了才触发下一步。代码可读性稍差一些,但在对时序精度要求高的场景下更可靠。
模式三:.repeat() 属性动画
如果你只需要简单的"来回循环"效果,.animation()修饰器其实支持.repeat()配置:
Column().scale({x:this.pulseScale,y:this.pulseScale}).animation({duration:600,curve:Curve.EaseInOut,iterations:-1,// -1 表示无限循环playMode:PlayMode.AlternateReverse// 来回交替})这种方式最简洁,但灵活性最低。它适合那些"从A到B来回切换"的简单循环动画,不适合需要分阶段控制的复杂循环。
脉冲动画在PC端的实际应用场景
PC端的脉冲动画用得比手机端多,原因很简单——PC屏幕大,用户注意力更容易分散,需要用动画把注意力引导到正确的位置。
录音/录屏状态指示。HarmonyOS6 PC端如果有录音或录屏功能,录制过程中按钮持续脉冲是最常见的做法。用户一眼就知道"正在录"。
下载/上传进度指示。文件传输过程中,图标做脉冲动画,暗示"正在处理"。比单纯的进度条更有存在感。
实时通讯状态。“正在输入…”、"正在连接…"这些状态配合脉冲动画,能让用户感受到"对面有人"的实时感。
紧急通知提醒。高优先级的通知图标做脉冲跳动,确保用户不会忽略。在PC端多任务环境下特别有用。
性能注意事项
脉冲动画是持续运行的,对性能有一定消耗。在PC端通常不是问题,但还是要注意几点:
不要同时跑太多脉冲动画。如果一个页面上有五六个元素都在脉冲跳动,CPU和GPU的负载会明显上升。建议同一时间最多两三个。
页面不可见时停止脉冲。如果用户切换到了别的页面,看不到的脉冲动画就是在白白消耗性能。可以在
aboutToDisappear生命周期里停止脉冲。脉冲参数不要太激进。放大到1.3倍已经足够显眼了,别放到2倍3倍。过大的缩放幅度在动画过程中会导致大量的重绘计算。
小结
脉冲动画是让UI元素"活"起来的有效手段。在HarmonyOS6 PC端开发中,它的实现核心是:
- 用
animateTo驱动属性变化 - 用递归
setTimeout或onFinish回调实现循环 - 用布尔状态变量控制启停
三种脉冲模式——持续脉冲、可停脉冲、快速脉冲——覆盖了绝大部分实际应用场景。选择哪种取决于你的具体需求:是需要持续提醒,还是需要用户主动控制,还是只需要一次性反馈。
记住一个设计原则:脉冲动画是配角,不是主角。它的作用是辅助引导注意力,而不是喧宾夺主。克制使用,效果最好。