鸿蒙实战:呼吸脉冲动画实现

前言(必读)

在这里插入图片描述

图:鸿蒙实战:呼吸脉冲动画实现(第 62 篇) 运行效果截图(HarmonyOS NEXT)

「呼吸」是最自然的生命节律。UX 设计中借用这一概念,通过有节奏的循环缩放让静态 UI 元素「活」起来——引导用户的视线、暗示可交互性、或为等待状态增添趣味。本文以项目中的三个典型案例(拍照按钮脉冲、取景框引导脉冲、墨水动画)深入讲解递归 animateTo 循环动画的实现。

呼吸脉冲动画效果示意图

图:呼吸脉冲动画——递归 animateTo 实现无限循环缩放+透明度渐变效果

onFinish 回调

onFinish 回调

true

false

startBreathing 启动

animateTo 放大阶段
scale: 1.0→1.12
opacity: 1.0→0.6
800ms EaseInOut

animateTo 缩小阶段
scale: 1.12→1.0
opacity: 0.6→1.0
800ms EaseInOut

isAnimating?

停止循环

stopBreathing 停止

isAnimating = false


一、呼吸动画的核心机制

1.1 递归 animateTo 模式

所有呼吸动画都基于同一个核心模式——onFinish 回调中递归调用自身

function pulseAnimation() {
  // Phase A:放大
  animateTo({
    duration: 1500,
    onFinish: () => {
      // Phase B:缩小(onFinish 中触发反方向)
      animateTo({
        duration: 1500,
        onFinish: () => pulseAnimation()  // 递归:循环回到 Phase A
      }, () => {
        // Phase B 的状态变化
      })
    }
  }, () => {
    // Phase A 的状态变化
  })
}

1.2 呼吸动画的四要素

要素 说明 项目典型值
频率 一次完整呼吸的周期 3000ms(1500+1500)
幅度 缩放或透明度的变化范围 scale: 0.9→1.25, opacity: 0.1→0.3
曲线 变化速率 Curve.Smooth(平滑起停)
起始状态 动画循环的初始值 缩小状态(更符合「吸气」)

二、拍照按钮呼吸脉冲

CapturePage 中有两套独立的呼吸脉冲动画:

2.1 中间拍照按钮的脉冲环

// 文件:pages/CapturePage.ets
@State pulseScale: number = 1.0    // 脉冲环缩放
@State pulseOpacity: number = 0.3  // 脉冲环透明度

private startPulse() {
  const pulse = () => {
    // Phase A:缩小变亮 → 吸引拍照注意
    animateTo({
      duration: 1500,
      curve: Curve.Smooth,
      onFinish: () => {
        // Phase B:放大变淡 → 模拟退开
        animateTo({
          duration: 1500,
          curve: Curve.Smooth,
          onFinish: () => pulse()  // 递归循环
        }, () => {
          this.pulseScale = 1.25    // 放大到 125%
          this.pulseOpacity = 0.1   // 变淡到 0.1
        })
      }
    }, () => {
      this.pulseScale = 0.9         // 缩小到 90%
      this.pulseOpacity = 0.3       // 变亮到 0.3
    })
  }
  pulse()  // 启动循环
}

UI 结构

// 拍照按钮的 UI 层叠
Stack() {
  // 外层:脉冲环(呼吸动画载体)
  Circle()
    .width(76).height(76)
    .fill('rgba(255,255,255,0.2)')
    .scale({ x: this.pulseScale, y: this.pulseScale })
    .opacity(this.pulseOpacity)

  // 中层:白色外环(68px)
  Circle().width(68).height(68).fill(Color.White)

  // 内层:WARM 拍照按钮(56px)
  Circle().width(56).height(56).fill(AppColors.WARM)
    .onClick(() => this.takePhoto())
}

视觉层级示意

          外层脉冲环(76px · 呼吸动画)
      ┌─────────────────────────────┐
     ╱                               ╲
    │   中层白环(68px · 静态)        │
    │  ┌─────────────────────────┐   │
    │  │                         │   │
    │  │  内层 WARM 环 (56px)    │   │
    │  │    (拍照按钮)           │   │
    │  │                         │   │
    │  └─────────────────────────┘   │
     ╲                               ╱
      └─────────────────────────────┘

2.2 四角引导框脉冲

// 文件:pages/CapturePage.ets
@State cornerOpacity: number = 0.6   // 四角引导框透明度

private startCornerPulse() {
  const pulse = () => {
    animateTo({
      duration: 1200,
      curve: Curve.Smooth,
      onFinish: () => {
        animateTo({
          duration: 1200,
          curve: Curve.Smooth,
          onFinish: () => pulse()
        }, () => { this.cornerOpacity = 1.0 })    // 变亮
      }
    }, () => { this.cornerOpacity = 0.45 })       // 变暗
  }
  pulse()
}

2.3 两类呼吸动画的参数对比

参数 按钮脉冲环 四角引导框
周期 3000ms(1500+1500) 2400ms(1200+1200)
幅度 scale 0.9↔1.25, opacity 0.3↔0.1 opacity 0.6↔1.0
曲线 Curve.Smooth Curve.Smooth
变化属性 scale + opacity 组合 opacity 单属性
视觉节奏 慢速(3s 一周期) 中速(2.4s 一周期)

三、AnalyzingPage 的三阶段墨水动画

墨水动画比脉冲更复杂——它由三个连续阶段构成一个完整周期:

// 文件:pages/AnalyzingPage.ets(墨水动画)
@State dropOffset: number = -70      // 墨滴垂直位置
@State dropOpacity: number = 0       // 墨滴透明度
@State spreadOpacity: number = 0     // 墨迹透明度
@State spreadScale: number = 0.5     // 墨迹缩放

private startInkAnimation() {
  const drop = () => {
    // 重置到初始状态
    this.dropOffset = -70
    this.dropOpacity = 0
    this.spreadOpacity = 0
    this.spreadScale = 0.5

    // Phase 1:墨滴下落(1000ms)
    // 墨滴从上方落到底部,缓慢加速
    animateTo({
      duration: 1000,
      curve: Curve.EaseIn,
      onFinish: () => {
        // Phase 2:墨迹扩散(600ms)
        // 墨滴触底后向四周扩散
        animateTo({
          duration: 600,
          curve: Curve.EaseOut,
          onFinish: () => {
            // Phase 3:渐隐消散(400ms)
            // 墨迹淡出后开始下一轮
            animateTo({
              duration: 400,
              curve: Curve.EaseOut,
              onFinish: () => drop()  // 递归循环
            }, () => {
              this.spreadOpacity = 0
              this.dropOpacity = 0
            })
          }
        }, () => {
          this.spreadOpacity = 0.25
          this.spreadScale = 2.0
        })
      }
    }, () => {
      this.dropOffset = 0       // 落到底部
      this.dropOpacity = 1      // 完全可见
    })
  }
  setTimeout(() => drop(), 300)  // 延迟 300ms 启动
}

三阶段动画时序图

Phase 1: 下落(1000ms)            Phase 2: 扩散(600ms)     Phase 3: 渐隐(400ms)
████████████████████████████████████████████████████████████████████████████
                                                                          
墨水状态:
┌────┐                            ╭────╮                      ······
│ ●  │ 下落中                      │    │ 扩散                  .    .
└────┘                            ╰────╯                      ······
dropOffset: -70→0                 spreadScale: 0.5→2.0        opacity: 0.25→0
dropOpacity: 0→1                  spreadOpacity: 0→0.25       全部归零
                                                                          
等待 300ms 后开始                   完成后递归回到 Phase 1 → 无限循环

三阶段 vs 两阶段呼吸的对比

维度 两阶段呼吸(CapturePage) 三阶段墨水动画(AnalyzingPage)
阶段数 Phase A → Phase B → 递归 Phase 1 → Phase 2 → Phase 3 → 递归
复杂度 低(2 次嵌套) 中(3 次嵌套)
视觉效果 单属性节奏变化 完整的事件序列(下落→扩散→消散)
动画曲线 全场 Smooth 分阶段 EaseIn / EaseOut / EaseOut

四、TapSharePage 的三层脉冲涟漪

TapSharePage 实现了 NFC 碰一碰的脉冲涟漪效果——三个圆圈依次向外扩散:

// 文件:pages/TapSharePage.ets(三圈涟漪动画)
@State ripple1Scale: number = 1.0
@State ripple1Opacity: number = 0.3
@State ripple2Scale: number = 1.0
@State ripple2Opacity: number = 0.3
@State ripple3Scale: number = 1.0
@State ripple3Opacity: number = 0.3

private startRippleAnimation() {
  const animateRing = (
    scaleState: number[],
    opacityState: number[],
    index: number
  ) => {
    // 重置当前环
    scaleState[index] = 1.0
    opacityState[index] = 0.3

    animateTo({
      duration: 1500,
      curve: Curve.Smooth,
      onFinish: () => {
        scaleState[index] = 1.0
        opacityState[index] = 0
        // 循环
        animateRing(scaleState, opacityState, index)
      }
    }, () => {
      scaleState[index] = 1.3   // 放大到 130%
      opacityState[index] = 0   // 完全透明
    })
  }

  // 三个环相位偏移 500ms 启动
  setTimeout(() => animateRing(this.rippleScales, this.rippleOpacities, 0), 0)
  setTimeout(() => animateRing(this.rippleScales, this.rippleOpacities, 1), 500)
  setTimeout(() => animateRing(this.rippleScales, this.rippleOpacities, 2), 1000)
}

五、呼吸动画的应用场景总结

5.1 项目中的呼吸动画全景

页面 动画元素 周期 作用
CapturePage 拍照按钮脉冲环 3s 引导用户按拍照按钮
CapturePage 四角引导框脉冲 2.4s 提示用户将字迹放入取景框
AnalyzingPage 墨水下落扩散 2s(1+0.6+0.4) 增加分析等待的趣味性
TapSharePage NFC 三圈涟漪 1.5s × 3 模拟 NFC 信号辐射

5.2 呼吸动画 vs 入场动画

呼吸动画:永久循环
  ▁▂▃▅▆▇▆▅▃▂▁▂▃▅▆▇▆▅▃▂▁▂▃▅▆...
  持续吸引注意力直到用户操作

入场动画:一次性
  ▁▂▃▅▆▇━━━━━━━━━━━━━━━━━
  完成即止,不干扰后续交互
对比维度 呼吸动画 入场动画
播放次数 无限循环(递归 onFinish) 一次性
用途 引导注意力 / 暗示可交互 内容呈现
持续时间 页面可见的全时段 500-1000ms
性能影响 持续占用 GPU 一次性开销
停止条件 用户交互后移除 动画自然结束

六、性能考量

6.1 递归动画的潜在问题

// 潜在问题:页面已消失,动画仍在运行
private startPulse() {
  const pulse = () => {
    animateTo({ duration: 1500, onFinish: () => {
      animateTo({ duration: 1500, onFinish: () => {
        pulse()  // 页面 pop 后可能还在运行
      }})
    }})
  }
  pulse()
}

6.2 解决方案

// 方案 1:使用标志位控制
private isActive: boolean = false

aboutToAppear() {
  this.isActive = true
  this.startPulse()
}

aboutToDisappear() {
  this.isActive = false  // 页面消失时停止
}

private startPulse() {
  if (!this.isActive) return  // 检查标志位
  animateTo({
    duration: 1500,
    onFinish: () => {
      if (!this.isActive) return  // 再次检查
      animateTo({
        duration: 1500,
        onFinish: () => {
          if (!this.isActive) return
          this.startPulse()
        }
      })
    }
  })
}

// 方案 2:简单场景使用 setTimeout 配合 clearTimeout
private pulseTimer: number = -1

aboutToDisappear() {
  clearTimeout(this.pulseTimer)
}

private startPulse() {
  // 使用 setTimeout 替代 onFinish 递归
  animateTo({ duration: 1500 }, () => { this.scale = 0.9 })
  this.pulseTimer = setTimeout(() => {
    animateTo({ duration: 1500 }, () => { this.scale = 1.25 })
    this.pulseTimer = setTimeout(() => this.startPulse(), 1500)
  }, 1500)
}

6.3 渲染性能

动画类型 重绘区域 GPU 负荷 帧率
scale 变换 仅元素自身区域 低(GPU 合成层) 60fps
opacity 变化 无重绘 60fps
translate 位移 无重绘 60fps
同时多环动画 多区域 50-60fps

scaleopacity 是 GPU 合成层属性,不会触发布局重排,性能最优。


七、设计指南

7.1 什么时候用呼吸动画?

应该用 不应该用
引导用户注意核心 CTA 多个元素同时呼吸(用争议)
暗示可交互的静态元素 已经足够突出的按钮
等待/加载状态的装饰 用户正在阅读的主要文字区域
品牌/氛围氛围渲染 频繁更新的数据区域

7.2 节奏和幅度准则

呼吸频率对照表:
  慢速(3-4s):优雅、沉稳、高端     → 拍照按钮
  中速(2-3s):舒适、自然、呼吸感   → 引导框
  快速(1-2s):活力、急切、动态     → 通知徽标
应用场景 推荐周期 推荐幅度
主 CTA 引导 2.5-3.5s scale ±15%
辅助引导 2-3s opacity ±25%
加载装饰 1.5-2.5s 多属性组合
品牌氛围 4-6s 极轻微 ±5%

八、最佳实践模板

/**
 * 标准呼吸动画工具函数
 * 用法:在 aboutToAppear 中调用,aboutToDisappear 中停止
 */
class BreathingAnimation {
  private isActive: boolean = false
  private scale: number = 1.0
  private opacity: number = 0.3

  /**
   * @param scaleRange  缩放范围 [小, 大] 如 [0.9, 1.25]
   * @param opacityRange 透明度范围 [大, 小] 如 [0.3, 0.1]
   * @param cycleMs 单期时长(总周期 = cycleMs × 2)
   */
  start(
    scaleRange: [number, number],
    opacityRange: [number, number],
    cycleMs: number
  ) {
    this.isActive = true
    const breath = () => {
      if (!this.isActive) return
      
      // 收缩
      animateTo({
        duration: cycleMs,
        curve: Curve.Smooth,
        onFinish: () => {
          if (!this.isActive) return
          // 扩张
          animateTo({
            duration: cycleMs,
            curve: Curve.Smooth,
            onFinish: () => breath()
          }, () => {
            this.scale = scaleRange[1]
            this.opacity = opacityRange[0]
          })
        }
      }, () => {
        this.scale = scaleRange[0]
        this.opacity = opacityRange[1]
      })
    }
    breath()
  }

  stop() {
    this.isActive = false
  }
}

九、注意事项与常见问题

9.1 开发注意事项

说明: 以下注意事项基于 HarmonyOS NEXT 实际项目开发经验整理,建议在动手开发前仔细阅读,可有效避免常见坑点。

在正式开发前,建议按以下步骤完成环境准备与前置检查:

  1. 版本确认:检查 DevEco Studio 与 SDK 版本,确保满足目标 API Level 要求
  2. 权限声明:在 module.json5requestPermissions 字段中提前声明所有需要的系统权限
  3. 设备能力检查:调用前验证设备是否支持目标能力(相机、NFC、传感器等)
  4. 异步封装:所有耗时操作(数据库、文件 I/O、网络请求)统一使用 async/await 处理
  5. 资源释放:在组件 aboutToDisappear() 生命周期钩子中及时释放系统资源,防止内存泄漏

9.2 常见错误与解决方案

常见问题快速排查表:

问题类型 排查方向 参考方法
应用崩溃 查看 hilog 错误日志 hilog.error(TAG, "...", e.message)
状态丢失 检查 AppStorage 键名拼写 统一使用常量管理键名
动画不流畅 避免在 animateTo 回调中执行 I/O 动画与数据操作分离

提示: 建议在 DevEco Studio 中开启 ArkTS Lint 静态检查,大部分编译期问题可在开发阶段发现。

总结

本文深入讲解了呼吸脉冲动画的设计与实现:

  • 递归 animateTo 模式:在 onFinish 中自调用实现无限循环
  • CapturePage 拍照按钮:scale + opacity 组合双层呼吸引导
  • CapturePage 四角引导框:opacity 单属性中速脉冲
  • AnalyzingPage 墨水动画:三阶段复合动画(下落→扩散→消散)
  • TapSharePage 三圈涟漪:相位偏移的多环扩散效果
  • 性能控制:标志位停止、GPU 合成层、帧率优化
  • 设计指南:使用场景评判 + 节奏/幅度对照表

最后一篇将讲解全局动画常量管理,分析 AppAnimations 常量系统的设计。


📌 收藏提示:如果您觉得本系列对您有帮助,欢迎点赞 + 关注,也欢迎在评论区交流鸿蒙动画开发的问题!


相关资源:

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐