鸿蒙实战:运动健康类应用核心组件——倒计时组件设计与实现
本文分享了一个运动健康类应用中的倒计时组件实现方案。该组件在用户点击"开始运动"后显示3、2、1、开始的倒计时动画,为用户提供准备时间并增强仪式感。设计上遵循醒目、节奏感、衔接感等原则,采用120px大字号和绿色显示数字。动画时序为每个数字300ms放大、400ms停留、300ms淡出,总时长4秒。组件支持数字变化回调,便于扩展语音播报等功能。核心代码包括属性定义、构建方法和动
上一篇文章我们完成了运动健康类控制交互按钮组件,本篇内容接上一篇点击「开始」按钮后页面呈现倒计时组件,并且增加了视觉感。
在运动健康类应用中,倒计时是用户点击「开始运动」后的第一个交互。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 |
先慢后快,模拟被吸入的效果 |
二、效果预览

三、核心代码实现
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 回调,父组件可以实时感知倒计时的每一个数字/文字,轻松扩展语音播报、日志记录等功能。
本文通过完整的代码示例,演示了如何设计一个流畅的倒计时组件,我分享的不仅仅是如何实现这个动画,而是动画的本质,是在特定的时间做什么样的“事情”通过一连串的时间-动作完成一套动画。动画也分很多种类而这个动画很简单,还有很多复杂的动画通过一系列计算完成某一个动作。
如果觉得本文对你有帮助,请点赞、收藏、转发,谢谢!
更多推荐




所有评论(0)