你有没有注意过,很多PC端应用里的"录音中"按钮会一直跳动?或者"正在连接"的图标会持续闪烁放大缩小?这种效果有个通用名字叫脉冲动画(Pulse Animation)。

脉冲动画的核心目的就一个——吸引注意力。它告诉用户:“嘿,这里有东西在进行中,别忘了我。”

在HarmonyOS6 PC端开发中,脉冲动画的实现其实挺有意思的。它不像展开折叠或者Tab切换那样是一次性触发的,而是一个持续循环的过程。怎么让动画一直跑?怎么优雅地停下来?这里面有不少门道。

效果描述

我们要做的是一个圆形按钮的脉冲效果:

  • 正常情况下,圆形按钮保持静止
  • 点击"开始脉冲",按钮开始持续地放大+变透明,然后缩回来恢复原样,循环往复
  • 点击"停止脉冲",动画优雅地停下来,按钮回到初始状态
  • 还有一个"快速脉冲"按钮,做一次快速的放大缩小就停

三种模式对应了脉冲动画在实际应用中的三种典型场景:持续提示、手动控制、一次性反馈。

状态变量与定时器

先来看状态设计:

@Entry
@Component
struct PulseDemo {
  @State pulseScale: number = 1
  @State pulseOpacity: number = 1
  @State isPulsing: boolean = false
  private pulseTimer: number = -1
  // ...
}

这里有两个动画属性——pulseScale 控制缩放,pulseOpacity 控制透明度。加上一个 isPulsing 布尔值作为"开关"。

pulseTimer 是个定时器ID,虽然在这个实现里我们用了递归 setTimeout 而不是 setInterval,但保留一个定时器引用是个好习惯,方便后续扩展。

脉冲的核心:递归 setTimeout 驱动循环

这是整个脉冲动画最关键的部分。我们来看"开始脉冲"按钮的点击逻辑:

Button('开始脉冲')
  .onClick(() => {
    if (this.isPulsing) return
    this.isPulsing = true
    const pulse = () => {
      if (!this.isPulsing) {
        this.pulseScale = 1
        this.pulseOpacity = 1
        return
      }
      // 第一阶段:放大 + 变透明
      animateTo({ duration: 600, curve: Curve.EaseOut }, () => {
        this.pulseScale = 1.3
        this.pulseOpacity = 0.5
      })
      // 第二阶段:600ms后缩回 + 恢复透明度
      setTimeout(() => {
        animateTo({ duration: 600, curve: Curve.EaseIn }, () => {
          this.pulseScale = 1
          this.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 有几个优势:

  1. 更精确的时序控制setInterval 的回调是"固定间隔"触发的,如果某次回调执行时间较长,可能导致回调堆积。递归 setTimeout 是上一次执行完才安排下一次,不会出现这个问题。

  2. 方便做条件判断。每次循环结束前都可以检查 isPulsing,优雅地决定是否继续。如果用 setInterval,你需要额外管理 clearInterval 的时机。

  3. 动画阶段更灵活。一个脉冲周期里有"放大"和"缩回"两个阶段,用 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 = 1
  this.pulseOpacity = 1
  return
}

isPulsing 变为 false 后,当前正在执行的动画会自然跑完(因为 animateTo 已经启动了),然后在下一轮 pulse 调用时,检测到 isPulsingfalse,把属性恢复到初始值,退出循环。

这个设计的好处是停止过程是"优雅"的——不会突然跳到初始状态,而是让当前的缩放/透明度变化自然完成,再在下一个周期退出。用户不会感到突兀。

快速脉冲:一次性反馈效果

"快速脉冲"按钮实现的是另一种模式——不循环,只做一次快速的放大缩小:

Button('快速脉冲')
  .onClick(() => {
    this.isPulsing = false  // 先停止可能正在进行的持续脉冲
    animateTo({ duration: 200, curve: Curve.EaseOut }, () => {
      this.pulseScale = 1.4
      this.pulseOpacity = 0.4
    })
    setTimeout(() => {
      animateTo({ duration: 200 }, () => {
        this.pulseScale = 1
        this.pulseOpacity = 1
      })
    }, 200)
  })

跟持续脉冲相比,快速脉冲有几个区别:

  • 时长更短:200ms vs 600ms,节奏快得多
  • 幅度更大:放大到1.4倍 vs 1.3倍,透明度降到0.4 vs 0.5
  • 不循环:做完一次就停

这种效果在PC端的实际应用场景很多:

  • 按钮点击后的反馈动效
  • 下载完成时的提示
  • 发送消息成功后的确认
  • 表单提交后的处理状态提示

它比单纯的静态反馈更有"确认感",但又不会像持续脉冲那样一直抢注意力。

完整代码

@Entry
@Component
struct PulseDemo {
  @State pulseScale: number = 1
  @State pulseOpacity: number = 1
  @State isPulsing: boolean = false
  private pulseTimer: number = -1

  build() {
    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) return
                  this.isPulsing = true
                  const pulse = () => {
                    if (!this.isPulsing) {
                      this.pulseScale = 1
                      this.pulseOpacity = 1
                      return
                    }
                    animateTo({ duration: 600, curve: Curve.EaseOut }, () => {
                      this.pulseScale = 1.3
                      this.pulseOpacity = 0.5
                    })
                    setTimeout(() => {
                      animateTo({ duration: 600, curve: Curve.EaseIn }, () => {
                        this.pulseScale = 1
                        this.pulseOpacity = 1
                      })
                      if (this.isPulsing) setTimeout(pulse, 200)
                    }, 600)
                  }
                  pulse()
                })
              Button('停止脉冲')
                .onClick(() => {
                  this.isPulsing = false
                })
              Button('快速脉冲')
                .onClick(() => {
                  this.isPulsing = false
                  animateTo({ duration: 200, curve: Curve.EaseOut }, () => {
                    this.pulseScale = 1.4
                    this.pulseOpacity = 0.4
                  })
                  setTimeout(() => {
                    animateTo({ duration: 200 }, () => {
                      this.pulseScale = 1
                      this.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 回调,可以在动画结束后执行后续逻辑:

const doPulse = () => {
  if (!this.isPulsing) return
  animateTo({
    duration: 600,
    curve: Curve.EaseOut,
    onFinish: () => {
      animateTo({
        duration: 600,
        curve: Curve.EaseIn,
        onFinish: () => {
          if (this.isPulsing) {
            setTimeout(doPulse, 200)
          }
        }
      }, () => {
        this.pulseScale = 1
        this.pulseOpacity = 1
      })
    }
  }, () => {
    this.pulseScale = 1.3
    this.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端通常不是问题,但还是要注意几点:

  1. 不要同时跑太多脉冲动画。如果一个页面上有五六个元素都在脉冲跳动,CPU和GPU的负载会明显上升。建议同一时间最多两三个。

  2. 页面不可见时停止脉冲。如果用户切换到了别的页面,看不到的脉冲动画就是在白白消耗性能。可以在 aboutToDisappear 生命周期里停止脉冲。

  3. 脉冲参数不要太激进。放大到1.3倍已经足够显眼了,别放到2倍3倍。过大的缩放幅度在动画过程中会导致大量的重绘计算。

小结

脉冲动画是让UI元素"活"起来的有效手段。在HarmonyOS6 PC端开发中,它的实现核心是:

  • animateTo 驱动属性变化
  • 用递归 setTimeoutonFinish 回调实现循环
  • 用布尔状态变量控制启停

三种脉冲模式——持续脉冲、可停脉冲、快速脉冲——覆盖了绝大部分实际应用场景。选择哪种取决于你的具体需求:是需要持续提醒,还是需要用户主动控制,还是只需要一次性反馈。

记住一个设计原则:脉冲动画是配角,不是主角。它的作用是辅助引导注意力,而不是喧宾夺主。克制使用,效果最好。

Logo

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

更多推荐