鸿蒙实战:运动健康类应用核心组件——倒计时组件设计与实现
完整源码: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 | 先慢后快,模拟被吸入的效果 |
二、效果预览
三、核心代码实现
3.1 组件属性定义
@Componentexportstruct CountdownOverlay{// 是否激活倒计时(由父组件控制显示/隐藏)@PropisActive:boolean=false;// 背景颜色,默认透明,不遮挡下层内容@PropbgColor:ResourceStr='rgba(0, 0, 0, 0)';// 倒计时结束回调(动画全部执行完毕后触发)onFinish?:()=>void;// 数字/文字变化回调(每个数字/文字显示时触发)onNumberChange?:(text:string)=>void;// 当前显示的倒计时文字(3、2、1、开始)@StateprivatecountdownText:string='';// 文字缩放比例(用于放大/缩小动画)@StateprivatecountdownScale:number=1.0;// 文字透明度(用于淡入/淡出动画)@StateprivatecountdownOpacity:number=1.0;// 文字垂直偏移量(用于开始飞入动画)@StateprivatecountdownOffsetY:number=0;// 动画是否正在执行(防止重复触发)@StateprivateisAnimating:boolean=false;// 倒计时步骤数组privatereadonlysteps:string[]=['3','2','1','开始'];// 当前执行到第几步privatecurrentStepIndex:number=0;// 存储所有定时器ID,用于组件销毁时清理privatetimeouts: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 核心动画逻辑
privateresetToDefault():void{this.countdownText='';this.countdownScale=1.0;this.countdownOpacity=1.0;this.countdownOffsetY=0;this.isAnimating=false;this.currentStepIndex=0;}privatestartCountdown():void{if(this.isAnimating)return;this.isAnimating=true;this.playStepAnimation();}privateplayStepAnimation():void{if(this.currentStepIndex>=this.steps.length){this.isAnimating=false;this.onFinish?.();return;}consttext=this.steps[this.currentStepIndex];constisLastStep=(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:()=>{consttimeoutId=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;});}privateclearAllTimeouts():void{for(constidofthis.timeouts){clearTimeout(id);}this.timeouts=[];}设计说明:
countdownText初始为空,动画从 3 开始自然执行,无需特殊处理索引- 使用
animateTo实现动画,onFinish回调串联步骤 setTimeout控制停留时间,让用户看清每个数字onNumberChange回调:每个数字/文字显示时立即通知父组件,方便同步语音播报onDisAppear清理所有定时器并重置状态,避免内存泄漏
四、父组件使用示例
@StateisCountdownActive:boolean=false;privatespeechManager:SpeechManager=SpeechManager.getInstance();privatestartCountdown():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回调,父组件可以实时感知倒计时的每一个数字/文字,轻松扩展语音播报、日志记录等功能。
本文通过完整的代码示例,演示了如何设计一个流畅的倒计时组件,我分享的不仅仅是如何实现这个动画,而是动画的本质,是在特定的时间做什么样的“事情”通过一连串的时间-动作完成一套动画。动画也分很多种类而这个动画很简单,还有很多复杂的动画通过一系列计算完成某一个动作。
如果觉得本文对你有帮助,请点赞、收藏、转发,谢谢!
