文章目录
- 前言
- 真实页面会关心什么
- 列表、状态和反馈
- 关键片段
- 片段 1:`formatTime(seconds: number): string {`
- 片段 2:`getProgress(timer: TimerItem): number {`
- 使用方式
- 完整代码
- 可扩展点
前言
如果刚开始写 HarmonyOS7 页面,我建议多拆这种案例。代码量不夸张,但能把声明式 UI 的味道摸清楚。
它的主线是倒计时应用,细节落在多计时器管理与节日倒计时。这些细节刚好能把页面状态、用户操作和组件刷新串起来。
真实页面会关心什么
CountdownTimerPage不是一个只展示静态内容的页面。它会根据用户点击、输入、切换或计时来改变界面,所以阅读代码时可以先抓三条线:
| 观察点 | 在这个案例里的表现 |
|---|---|
| 页面主题 | 倒计时应用 |
| 主要文案 | 番茄时钟,休息时间,会议提醒,元旦,春节,五一劳动节 |
| 常用组件 | Button,Column,Divider,ForEach,Progress,Row,Scroll,Stack,Text |
| 适合练习 | 状态驱动 UI、条件样式、列表渲染、事件回调 |
列表、状态和反馈
ArkUI 的页面刷新依赖状态变化。下面这些@State字段就是页面的“开关”和“数据源”,不用手动找 DOM,也不用自己通知某个控件刷新。
| 状态字段 | 类型 | 我会怎么理解它 |
|---|---|---|
activeTab | number | 当前选中项,决定高亮和内容切换 |
timers | TimerItem[] | 记录页面当前选择、输入或展示状态 |
countdowns | CountdownItem[] | 记录页面当前选择、输入或展示状态 |
一个经验:先把
@State看完,再看build(),页面逻辑会清楚很多。否则很容易被一长串布局代码带偏。
关键片段
片段 1:formatTime(seconds: number): string {
这段代码承担的是页面里的“判断”或“动作”,把它从布局里抽出来后,build()会清爽很多。后面要加新条件,也不会到处翻 UI 代码。
formatTime(seconds:number):string{consth=Math.floor(seconds/3600)constm=Math.floor((seconds%3600)/60)consts=seconds%60if(h>0){return`${h.toString().padStart(2,'0')}:${m.toString().padStart(2,'0')}:${s.toString().padStart(2,'0')}`}return`${m.toString().padStart(2,'0')}:${s.toString().padStart(2,'0')}`}片段 2:getProgress(timer: TimerItem): number {
这段代码承担的是页面里的“判断”或“动作”,把它从布局里抽出来后,build()会清爽很多。后面要加新条件,也不会到处翻 UI 代码。
getProgress(timer:TimerItem):number{return(timer.totalSeconds-timer.remaining)/timer.totalSeconds}使用方式
把下面代码放进 ArkTS 页面文件中即可运行。用于正式项目时,我会继续把数据模型、卡片 Builder 和页面事件拆出去,页面文件只负责组合。
建议练习顺序:先改状态字段 -> 再改交互事件 -> 最后调整 UI 样式 检查重点:点击是否刷新、列表 key 是否稳定、条件样式是否集中管理这份代码可以继续扩展的方向- 把模拟数据替换成接口数据
- 抽出复用卡片组件
- 增加空状态和异常状态
- 补充深色模式或主题色适配
- 对输入类场景增加校验提示
完整代码
// CountdownTimerPage - 倒计时应用 - 多计时器管理与节日倒计时interfaceCountdownItem{id:numbertitle:stringtargetDate:stringemoji:stringcolor:stringdaysLeft:number}interfaceTimerItem{id:numberlabel:stringtotalSeconds:numberremaining:numberisRunning:booleanisFinished:boolean}@Entry@Componentstruct CountdownTimerPage{@StateactiveTab:number=0@Statetimers:TimerItem[]=[{id:1,label:'番茄时钟',totalSeconds:1500,remaining:1500,isRunning:false,isFinished:false},{id:2,label:'休息时间',totalSeconds:300,remaining:300,isRunning:false,isFinished:false},{id:3,label:'会议提醒',totalSeconds:3600,remaining:3600,isRunning:false,isFinished:false},]@Statecountdowns:CountdownItem[]=[{id:1,title:'元旦',targetDate:'2026-01-01',emoji:'🎆',color:'#ff4d4f',daysLeft:209},{id:2,title:'春节',targetDate:'2026-02-17',emoji:'🧨',color:'#fa8c16',daysLeft:256},{id:3,title:'五一劳动节',targetDate:'2026-05-01',emoji:'🌸',color:'#52c41a',daysLeft:329},{id:4,title:'中秋节',targetDate:'2026-09-25',emoji:'🌕',color:'#1890ff',daysLeft:476},{id:5,title:'国庆节',targetDate:'2026-10-01',emoji:'🇨🇳',color:'#eb2f96',daysLeft:482},]privatetimerHandles:Map<number,number>=newMap()formatTime(seconds:number):string{consth=Math.floor(seconds/3600)constm=Math.floor((seconds%3600)/60)consts=seconds%60if(h>0){return`${h.toString().padStart(2,'0')}:${m.toString().padStart(2,'0')}:${s.toString().padStart(2,'0')}`}return`${m.toString().padStart(2,'0')}:${s.toString().padStart(2,'0')}`}getProgress(timer:TimerItem):number{return(timer.totalSeconds-timer.remaining)/timer.totalSeconds}toggleTimer(id:number){this.timers=this.timers.map((t:TimerItem)=>{if(t.id===id){t.isRunning=!t.isRunningif(t.isRunning){consthandle=setInterval(()=>{this.timers=this.timers.map((item:TimerItem)=>{if(item.id===id){if(item.remaining>0){item.remaining=item.remaining-1returnitem}else{clearInterval(this.timerHandles.get(id)asnumber)this.timerHandles.delete(id)item.isRunning=falseitem.isFinished=truereturnitem}}returnitem})},1000)this.timerHandles.set(id,handle)}else{consth=this.timerHandles.get(id)if(h!==undefined){clearInterval(h)this.timerHandles.delete(id)}}returnt}returnt})}resetTimer(id:number){consth=this.timerHandles.get(id)if(h!==undefined){clearInterval(h)this.timerHandles.delete(id)}this.timers=this.timers.map((t:TimerItem)=>{if(t.id===id){t.remaining=t.totalSeconds t.isRunning=falset.isFinished=falsereturnt}returnt})}aboutToDisappear(){this.timerHandles.forEach((handle:number)=>clearInterval(handle))}@BuilderTimerTab(){Column({space:16}){ForEach(this.timers,(item:TimerItem)=>{Column({space:16}){Row(){Text(item.label).fontSize(16).fontWeight(FontWeight.Bold).fontColor(item.isFinished?'#52c41a':'#1a1a1a')Blank()if(item.isFinished){Text('✅ 完成!').fontSize(13).fontColor('#52c41a').fontWeight(FontWeight.Medium)}}.width('100%')// 圆形进度Stack(){// 背景圆Circle().width(120).height(120).fill('none').stroke('#f0f0f0').strokeWidth(8)Text(this.formatTime(item.remaining)).fontSize(22).fontWeight(FontWeight.Bold).fontColor(item.isFinished?'#52c41a':'#1a1a1a')}.width(120).height(120)// 线性进度条Column({space:4}){Progress({value:this.getProgress(item)*100,total:100,type:ProgressType.Linear}).width('100%').height(6).color(item.isFinished?'#52c41a':'#1890ff').backgroundColor('#f0f0f0')Text(`${Math.floor(this.getProgress(item)*100)}%`).fontSize(12).fontColor('#999999').width('100%').textAlign(TextAlign.End)}// 控制按钮Row({space:12}){Button(item.isRunning?'暂停':(item.isFinished?'已完成':'开始')).layoutWeight(1).height(40).fontSize(14).backgroundColor(item.isFinished?'#f0f0f0':(item.isRunning?'#fa8c16':'#1890ff')).fontColor(item.isFinished?'#999999':'#ffffff').enabled(!item.isFinished).onClick(()=>this.toggleTimer(item.id))Button('重置').width(80).height(40).fontSize(14).backgroundColor('#f5f5f5').fontColor('#444444').onClick(()=>this.resetTimer(item.id))}.width('100%')}.width('100%').padding(20).backgroundColor('#ffffff').borderRadius(16).shadow({radius:6,color:'#00000010',offsetX:0,offsetY:2}).alignItems(HorizontalAlign.Center)})}}@BuilderCountdownTab(){Column({space:12}){ForEach(this.countdowns,(item:CountdownItem)=>{Row({space:16}){Text(item.emoji).fontSize(36)Column({space:4}){Text(item.title).fontSize(16).fontWeight(FontWeight.Bold).fontColor('#1a1a1a')Text(item.targetDate).fontSize(12).fontColor('#999999')}.layoutWeight(1).alignItems(HorizontalAlign.Start)Column({space:2}){Text(`${item.daysLeft}`).fontSize(28).fontWeight(FontWeight.Bold).fontColor(item.color)Text('天后').fontSize(12).fontColor('#999999')}.alignItems(HorizontalAlign.Center)}.width('100%').padding({left:20,right:20,top:16,bottom:16}).backgroundColor('#ffffff').borderRadius(16).border({width:2,color:item.color+'30'})})}}build(){Column({space:0}){// 标题栏Row(){Text('倒计时').fontSize(20).fontWeight(FontWeight.Bold).fontColor('#1a1a1a')Blank()Text('+ 新建').fontSize(14).fontColor('#1890ff')}.width('100%').padding({left:20,right:20,top:16,bottom:16}).backgroundColor('#ffffff')// Tab 切换Row(){ForEach(['计时器','节日倒计时'],(tab:string,index:number)=>{Column(){Text(tab).fontSize(15).fontWeight(this.activeTab===index?FontWeight.Bold:FontWeight.Normal).fontColor(this.activeTab===index?'#1890ff':'#999999').padding({top:10,bottom:10})Divider().strokeWidth(2).color(this.activeTab===index?'#1890ff':'transparent')}.layoutWeight(1).alignItems(HorizontalAlign.Center).onClick(()=>{this.activeTab=index})})}.width('100%').backgroundColor('#ffffff').margin({bottom:2})Scroll(){Column({space:0}){if(this.activeTab===0){this.TimerTab()}else{this.CountdownTab()}}.padding({left:16,right:16,top:16,bottom:32})}.layoutWeight(1).backgroundColor('#f5f7fa')}.width('100%').height('100%').backgroundColor('#f5f7fa')}}可扩展点
这个案例可以当成一个小模板:先把页面会变的东西放进状态,再用函数或 Builder 处理重复逻辑,最后让布局只关心展示。写 HarmonyOS7 页面时,这个顺序通常比一上来堆 UI 更稳。