完整源码:SportTrackDemo-CountdownOverlay.ets

上一篇文章我们完成了运动健康类控制交互按钮组件,本篇内容接上一篇点击「开始」按钮后页面呈现倒计时组件,并且增加了视觉感。

在运动健康类应用中,倒计时是用户点击「开始运动」后的第一个交互。3、2、1、开始,这短短几秒的动画既给用户准备时间,也营造仪式感。本文将从设计思路出发,分享一套流畅的倒计时数字动画组件,并支持数字变化回调,方便配合语音播报等拓展功能。关于语音播报,下一篇文章详细讲解设计思路以及功能封装。

一、设计思路

1.1 为什么需要倒计时?

倒计时的核心作用是给用户准备时间。用户点击「开始运动」后,不会立即开始记录,而是有3秒时间收起手机、调整姿势。同时,倒计时动画也营造了仪式感,让开始运动更有“出发”的感觉。

1.2 动画设计原则

原则 说明 实现方式
醒目 数字要足够大,吸引注意力 字体大小 120,绿色 #4CAF50
节奏感 每个数字停留时间一致 每个数字约1秒
衔接感 倒计时结束与运动开始无缝衔接 「开始」飞入底部按钮位置
不遮挡 倒计时浮在地图上方,背景透明 背景色透明,只显示数字
可扩展 支持回调通知父组件当前数字 提供 onNumberChange 回调

1.3 动画时序设计

数字3: [放大300ms] → [停留400ms] → [淡出300ms]  总计1000ms
数字2: [放大300ms] → [停留400ms] → [淡出300ms]  总计1000ms
数字1: [放大300ms] → [停留400ms] → [淡出300ms]  总计1000ms
开始: [放大300ms] → [停留400ms] → [飞入300ms]   总计1000ms
────────────────────────────────────────────────
总时长: 约 4 秒

1.4 为什么最后加个「开始」?

一开始我也只想做 3、2、1,但总感觉缺点什么,就像人说话卡壳了说了一半。虽然只是显示文字,但如果加入语音播报呢?把运动看成一场正式的比赛,裁判员那句「开始」才是真正的命令。3、2、1 只是准备,「开始」才是出发的信号。

1.5 动画曲线选择

动画阶段 使用曲线 原因
数字放大 Curve.FastOutSlowIn 先快后慢,有弹性感,数字弹出有力
数字淡出 Curve.EaseOut 缓慢消失,自然过渡
飞入 Curve.EaseIn 先慢后快,模拟被吸入的效果

二、效果预览

地图运动控制交互组件.gif

三、核心代码实现

3.1 组件属性定义

@Component
export struct CountdownOverlay {
   // 是否激活倒计时(由父组件控制显示/隐藏)
  @Prop isActive: boolean = false;
  // 背景颜色,默认透明,不遮挡下层内容
  @Prop bgColor: ResourceStr = 'rgba(0, 0, 0, 0)';
  // 倒计时结束回调(动画全部执行完毕后触发)
  onFinish?: () => void;
  // 数字/文字变化回调(每个数字/文字显示时触发)
  onNumberChange?: (text: string) => void;

  // 当前显示的倒计时文字(3、2、1、开始)
  @State private countdownText: string = '';
  // 文字缩放比例(用于放大/缩小动画)
  @State private countdownScale: number = 1.0;
  // 文字透明度(用于淡入/淡出动画)
  @State private countdownOpacity: number = 1.0;
  // 文字垂直偏移量(用于开始飞入动画)
  @State private countdownOffsetY: number = 0;
  // 动画是否正在执行(防止重复触发)
  @State private isAnimating: boolean = false;

  // 倒计时步骤数组
  private readonly steps: string[] = ['3', '2', '1', '开始'];
  // 当前执行到第几步
  private currentStepIndex: number = 0;
  // 存储所有定时器ID,用于组件销毁时清理
  private timeouts: number[] = [];
  
}

3.2 构建方法

  build() {
    if (this.isActive) {
      Stack() {
        Column()
          .width('100%')
          .height('100%')
          .backgroundColor(this.bgColor)

        Text(this.countdownText)
          .fontSize(this.countdownText === '开始' ? 90 : 120)
          .fontWeight(FontWeight.Bold)
          .fontColor('#4CAF50')
          .scale({ x: this.countdownScale, y: this.countdownScale })
          .opacity(this.countdownOpacity)
          .offset({ y: this.countdownOffsetY })
      }
      .width('100%')
      .height('100%')
      .onAppear(() => {
        this.resetToDefault();
        this.startCountdown();
      })
      .onDisAppear(() => {
        this.clearAllTimeouts();
        this.resetToDefault();
      })
      .hitTestBehavior(HitTestMode.Block)
    }
  }

3.3 核心动画逻辑


  private resetToDefault(): void {
    this.countdownText = '';
    this.countdownScale = 1.0;
    this.countdownOpacity = 1.0;
    this.countdownOffsetY = 0;
    this.isAnimating = false;
    this.currentStepIndex = 0;
  }

  private startCountdown(): void {
    if (this.isAnimating) return;
    this.isAnimating = true;
    this.playStepAnimation();
  }

  private playStepAnimation(): void {
    if (this.currentStepIndex >= this.steps.length) {
      this.isAnimating = false;
      this.onFinish?.();
      return;
    }

    const text = this.steps[this.currentStepIndex];
    const isLastStep = (text === '开始');

    // 更新显示文字
    this.countdownText = text;

    // 回调出去,播放语音
    this.onNumberChange?.(text);

    this.countdownScale = 0.3;
    this.countdownOpacity = 1;
    this.countdownOffsetY = 0;

    this.getUIContext().animateTo({
      duration: 300,
      curve: Curve.FastOutSlowIn,
      onFinish: () => {
        const timeoutId = setTimeout(() => {
          if (isLastStep) {
            this.getUIContext().animateTo({
              duration: 300,
              curve: Curve.EaseIn,
              onFinish: () => {
                this.currentStepIndex++;
                this.playStepAnimation();
              }
            }, () => {
              this.countdownScale = 0.1;
              this.countdownOffsetY = 200;
              this.countdownOpacity = 0;
            });
          } else {
            this.getUIContext().animateTo({
              duration: 300,
              curve: Curve.EaseOut,
              onFinish: () => {
                this.currentStepIndex++;
                this.playStepAnimation();
              }
            }, () => {
              this.countdownScale = 0.5;
              this.countdownOpacity = 0;
            });
          }
        }, 400);
        this.timeouts.push(timeoutId);
      }
    }, () => {
      this.countdownScale = 1.2;
    });
  }

  private clearAllTimeouts(): void {
    for (const id of this.timeouts) {
      clearTimeout(id);
    }
    this.timeouts = [];
  }

设计说明

  • countdownText 初始为空,动画从 3 开始自然执行,无需特殊处理索引
  • 使用 animateTo 实现动画,onFinish 回调串联步骤
  • setTimeout 控制停留时间,让用户看清每个数字
  • onNumberChange 回调:每个数字/文字显示时立即通知父组件,方便同步语音播报
  • onDisAppear 清理所有定时器并重置状态,避免内存泄漏

四、父组件使用示例

@State isCountdownActive: boolean = false;
private speechManager: SpeechManager = SpeechManager.getInstance();

private startCountdown(): void {
  this.isCountdownActive = true;
}

// 在 build 中
CountdownOverlay({
  isActive: this.isCountdownActive,
  onNumberChange: (text: string) => {
    // 动画显示什么数字/文字,就播报什么
    this.speechManager.speakCountdownText(text);
  },
  onFinish: () => {
    this.isCountdownActive = false;
    this.startTracking();
  }
})

五、踩坑经验

问题 原因 解决方案
背景不透明遮挡地图 背景色设置了不透明 使用 rgba(0,0,0,0) 透明背景
点击穿透到下层按钮 倒计时层未阻止触摸 添加 .hitTestBehavior(HitTestMode.Block)
「开始」字体太大 与数字使用相同字号 条件判断设置不同字号(开始90,数字120)
动画与组件销毁冲突 组件销毁时动画仍在执行 onDisAppear 中清理定时器并重置状态
数字重复出现 初始值与动画第一帧重复 countdownText 初始设为空字符串

七、总结

该组件可直接集成到跑步、骑行、步行等运动场景中使用,也适用于任何需要倒计时功能的应用。通过 onNumberChange 回调,父组件可以实时感知倒计时的每一个数字/文字,轻松扩展语音播报、日志记录等功能。

本文通过完整的代码示例,演示了如何设计一个流畅的倒计时组件,我分享的不仅仅是如何实现这个动画,而是动画的本质,是在特定的时间做什么样的“事情”通过一连串的时间-动作完成一套动画。动画也分很多种类而这个动画很简单,还有很多复杂的动画通过一系列计算完成某一个动作。

如果觉得本文对你有帮助,请点赞、收藏、转发,谢谢!

Logo

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

更多推荐