HarmonyOS ArkTS 实战:实现一个简约倒计时器

项目效果

本文实现的是一个基于 HarmonyOS 和 ArkTS 的简约倒计时器应用。项目中使用 ArkUI 组件完成页面布局,通过 @State 管理状态数据,实现时间设置、开始/暂停/重置、倒计时结束提示、快捷时间选择和进度环显示等功能。

最终运行效果如下:
在这里插入图片描述

页面主要包含以下内容:

  • 顶部应用标题;
  • 圆形进度环显示剩余时间;
  • 大字体时间显示;
  • 快捷时间选择按钮(1/3/5/10/15/30分钟);
  • 自定义时间设置;
  • 开始/暂停/重置按钮;
  • 倒计时结束状态提示;
  • 页面整体采用 ArkUI 声明式布局。

本文重点是演示如何在 HarmonyOS 项目中使用 ArkTS 和 ArkUI 实现一个倒计时类单页面应用。项目代码主要写在 entry/src/main/ets/pages/Index.ets 文件中,适合作为 HarmonyOS ArkTS 入门到进阶之间的练习案例。

前言

倒计时器是生活中常用的工具,无论是煮蛋、健身、冥想还是工作专注,都需要一个简单好用的倒计时。手机自带的计时器往往功能复杂,而我们需要一个界面简洁、操作方便的倒计时器。

从应用开发角度来看,这个项目不依赖后端接口,也不需要数据库,但能练习 ArkTS 中的状态管理、定时器、进度动画、按钮交互和时间计算等内容。

本文基于 HarmonyOS 和 ArkTS 实现一个简约倒计时器。用户可以选择快捷时间或自定义时间,开始倒计时,随时暂停或重置,倒计时结束时有明显的视觉提示。

这个项目的核心不是简单的数字递减,而是流畅的倒计时体验和直观的进度展示。每一次开始、暂停和重置,本质上都是对计时器状态的管理。状态变化后,页面会自动刷新,这正是 ArkUI 声明式开发的基本思想。

一、项目目标

本次实践主要实现以下目标:

  • 创建 HarmonyOS ArkTS 页面;
  • 使用 @Entry@Component 定义页面组件;
  • 使用 @State 管理页面状态;
  • 使用定时器实现倒计时功能;
  • 圆形进度环显示倒计时进度;
  • 提供快捷时间选择按钮;
  • 支持自定义分钟和秒数;
  • 实现开始/暂停/重置功能;
  • 倒计时结束时的状态提示;
  • 时间格式化显示;
  • 使用 @Builder 封装快捷按钮和控制按钮;
  • 完成一个可以运行的倒计时器页面。

这个项目虽然是单页面应用,但它有完整的时间设置、倒计时控制和进度显示功能,比普通静态页面更适合练习 ArkTS。

二、技术栈

类型 内容
开发方向 HarmonyOS 应用开发
开发语言 ArkTS
UI 框架 ArkUI
SDK 版本 HarmonyOS API 23 及以上
工程模型 Stage 模型
核心组件 Text / Button / Column / Row / Circle / Stack / TextInput
状态管理 @State
数据处理 时间计算 / 定时器 / 进度动画
项目入口 entry/src/main/ets/pages/Index.ets
运行平台 模拟器或真机

本项目是 HarmonyOS 原生 ArkTS 项目。页面主体由 ArkUI 组件构建,核心逻辑写在 Index.ets 文件中,不依赖后端接口,也不需要额外配置数据库。

三、为什么选择倒计时器项目

简约倒计时器适合作为 ArkTS 练习项目,主要有以下几个原因。

第一,实用性强。倒计时是非常常用的功能,在很多场景下都会用到。

第二,业务场景真实。烹饪、运动、专注、休息等场景都需要倒计时,是手机必备工具之一。

第三,适合练习状态管理。运行状态、剩余时间、总时间、定时器ID都属于页面状态。状态变化后,页面会自动刷新。

第四,适合练习定时器和动画。倒计时需要每秒更新,进度环需要平滑动画。

第五,适合练习时间格式化。将秒数格式化为易读的分秒格式。

第六,扩展空间比较大。基础功能完成后,可以继续增加多组倒计时、提示音、震动提醒、后台运行和预设模板。

在本项目中,剩余时间和运行状态是核心数据。页面中的时间显示和进度环都由它们计算或渲染得到。

四、功能规则说明

快捷时间选项:1分钟、3分钟、5分钟、10分钟、15分钟、30分钟。

自定义时间范围:0-99分钟,0-59秒。

按钮状态规则:

当前状态 主按钮 副按钮
初始/重置 开始 重置(禁用)
运行中 暂停 重置
暂停中 继续 重置
倒计时结束 重新开始 重置

时间显示格式:MM:SS,例如05:00表示5分钟。

倒计时结束时,时间显示为00:00,进度环填满,颜色变为绿色提示完成。

五、项目结构

本项目主要修改首页文件:

entry
└── src
    └── main
        └── ets
            └── pages
                └── Index.ets

其中:

文件 作用
Index.ets 编写页面结构、状态数据和倒计时逻辑

本文不涉及复杂路由,也不需要额外创建多个页面。对于练习项目来说,把主要逻辑集中在一个 Index.ets 文件中更方便理解。

六、核心实现思路

本项目的核心流程如下:

  1. 使用 @State 保存总时间、剩余时间、运行状态和定时器ID;
  2. 用户点击快捷按钮设置时间;
  3. 用户也可以自定义分钟和秒数;
  4. 点击开始按钮启动定时器;
  5. 每秒减少1秒剩余时间;
  6. 圆形进度环显示倒计时进度;
  7. 点击暂停按钮停止定时器;
  8. 点击重置按钮回到初始状态;
  9. 剩余时间为0时倒计时结束;
  10. 格式化时间显示为分秒格式。

项目中最重要的状态变量如下:

@State isRunning: boolean = false
@State isFinished: boolean = false
@State totalSeconds: number = 300
@State remainingSeconds: number = 300
@State timerId: number = -1
@State customMinutes: string = '5'
@State customSeconds: string = '0'

其中:

状态变量 作用
isRunning 倒计时是否运行中
isFinished 倒计时是否结束
totalSeconds 总时间(秒)
remainingSeconds 剩余时间(秒)
timerId 定时器ID
customMinutes 自定义分钟输入
customSeconds 自定义秒数输入

remainingSeconds 是本项目中最重要的数据。时间显示和进度环都依赖这个状态。

七、Index.ets 完整代码

打开文件:

entry/src/main/ets/pages/Index.ets

将其中内容替换为下面代码:

@Entry
@Component
struct Index {
  @State isRunning: boolean = false
  @State isFinished: boolean = false
  @State totalSeconds: number = 300
  @State remainingSeconds: number = 300
  @State timerId: number = -1
  @State customMinutes: string = '5'
  @State customSeconds: string = '0'

  private quickTimes: number[] = [60, 180, 300, 600, 900, 1800]

  aboutToDisappear() {
    this.stopTimer()
  }

  private setTime(seconds: number): void {
    if (this.isRunning) return
    this.totalSeconds = seconds
    this.remainingSeconds = seconds
    this.isFinished = false
    this.customMinutes = Math.floor(seconds / 60).toString()
    this.customSeconds = (seconds % 60).toString()
  }

  private applyCustomTime(): void {
    if (this.isRunning) return
    let mins = parseInt(this.customMinutes) || 0
    let secs = parseInt(this.customSeconds) || 0
    mins = Math.min(Math.max(mins, 0), 99)
    secs = Math.min(Math.max(secs, 0), 59)
    let total = mins * 60 + secs
    if (total > 0) {
      this.setTime(total)
    }
  }

  private startTimer(): void {
    if (this.isRunning || this.remainingSeconds <= 0) return
    
    this.isRunning = true
    this.isFinished = false
    
    this.timerId = setInterval(() => {
      if (this.remainingSeconds > 0) {
        this.remainingSeconds--
      } else {
        this.finishTimer()
      }
    }, 1000)
  }

  private pauseTimer(): void {
    if (!this.isRunning) return
    this.isRunning = false
    this.stopTimer()
  }

  private stopTimer(): void {
    if (this.timerId !== -1) {
      clearInterval(this.timerId)
      this.timerId = -1
    }
  }

  private resetTimer(): void {
    this.pauseTimer()
    this.remainingSeconds = this.totalSeconds
    this.isFinished = false
  }

  private finishTimer(): void {
    this.pauseTimer()
    this.isFinished = true
    this.remainingSeconds = 0
  }

  private getProgress(): number {
    if (this.totalSeconds === 0) return 0
    return 1 - (this.remainingSeconds / this.totalSeconds)
  }

  private formatTime(seconds: number): string {
    let mins = Math.floor(seconds / 60)
    let secs = seconds % 60
    return `${this.formatTwo(mins)}:${this.formatTwo(secs)}`
  }

  private formatTwo(value: number): string {
    return value < 10 ? '0' + value.toString() : value.toString()
  }

  private getRingColor(): string {
    if (this.isFinished) return '#10B981'
    if (this.isRunning) return '#0A59F7'
    return '#9CA3AF'
  }

  @Builder
  QuickTimeButton(seconds: number) {
    let mins = Math.floor(seconds / 60)
    Button(`${mins}分钟`)
      .height(40)
      .padding({ left: 16, right: 16 })
      .fontSize(14)
      .fontColor(this.totalSeconds === seconds && !this.isRunning ? Color.White : '#4B5563')
      .backgroundColor(this.totalSeconds === seconds && !this.isRunning ? '#0A59F7' : '#F3F4F6')
      .borderRadius(20)
      .onClick(() => {
        this.setTime(seconds)
      })
  }

  build() {
    Column() {
      Text('倒计时器')
        .fontSize(28)
        .fontWeight(FontWeight.Bold)
        .fontColor('#182431')
        .margin({ top: 22 })
      Text('基于 HarmonyOS ArkTS 的简约计时工具')
        .fontSize(14)
        .fontColor('#6B7280')
        .margin({ top: 8, bottom: 30 })

      Stack() {
        Circle()
          .width(260)
          .height(260)
          .fill('#F5F7FA')
          .stroke('#E5E7EB')
          .strokeWidth(12)
        
        Circle()
          .width(260)
          .height(260)
          .fill(Color.Transparent)
          .stroke(this.getRingColor())
          .strokeWidth(12)
          .strokeDashArray([2 * Math.PI * 124 * this.getProgress(), 2 * Math.PI * 124])
          .strokeDashOffset(2 * Math.PI * 124 * 0.25)
          .animation({ duration: 1000, curve: Curve.Linear })

        Column() {
          if (this.isFinished) {
            Text('⏰')
              .fontSize(40)
              .margin({ bottom: 8 })
            Text('时间到!')
              .fontSize(20)
              .fontWeight(FontWeight.Bold)
              .fontColor('#10B981')
          }
          Text(this.formatTime(this.remainingSeconds))
            .fontSize(56)
            .fontWeight(FontWeight.Bold)
            .fontColor(this.isFinished ? '#10B981' : '#182431')
            .fontFamily('monospace')
          Text(this.isRunning ? '倒计时中' : this.isFinished ? '已完成' : '准备开始')
            .fontSize(14)
            .fontColor('#6B7280')
            .margin({ top: 8 })
        }
      }
      .width(280)
      .height(280)
      .margin({ bottom: 30 })
      .justifyContent(FlexAlign.Center)
      .alignItems(Alignment.Center)

      Text('快捷时间')
        .fontSize(16)
        .fontWeight(FontWeight.Medium)
        .fontColor('#182431')
        .width('100%')
        .margin({ bottom: 12 })

      Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.Start }) {
        ForEach(this.quickTimes, (time: number) => {
          this.QuickTimeButton(time)
        }, (t: number) => t.toString())
      }
      .width('100%')
      .margin({ bottom: 20 })

      Text('自定义时间')
        .fontSize(16)
        .fontWeight(FontWeight.Medium)
        .fontColor('#182431')
        .width('100%')
        .margin({ bottom: 12 })

      Row() {
        TextInput({
          text: this.customMinutes,
          placeholder: '分'
        })
          .layoutWeight(1)
          .height(48)
          .fontSize(18)
          .textAlign(TextAlign.Center)
          .backgroundColor('#F5F7FA')
          .borderRadius(12)
          .type(InputType.Number)
          .onChange((value: string) => {
            this.customMinutes = value
          })
        Text('分')
          .fontSize(16)
          .margin({ left: 8, right: 16 })
        TextInput({
          text: this.customSeconds,
          placeholder: '秒'
        })
          .layoutWeight(1)
          .height(48)
          .fontSize(18)
          .textAlign(TextAlign.Center)
          .backgroundColor('#F5F7FA')
          .borderRadius(12)
          .type(InputType.Number)
          .onChange((value: string) => {
            this.customSeconds = value
          })
        Text('秒')
          .fontSize(16)
          .margin({ left: 8 })
        Button('应用')
          .height(48)
          .margin({ left: 16 })
          .padding({ left: 16, right: 16 })
          .fontSize(15)
          .fontColor(Color.White)
          .backgroundColor('#0A59F7')
          .borderRadius(12)
          .onClick(() => {
            this.applyCustomTime()
          })
      }
      .width('100%')
      .margin({ bottom: 30 })

      Row() {
        if (this.isRunning) {
          Button('暂停')
            .height(56)
            .layoutWeight(2)
            .fontSize(18)
            .fontColor(Color.White)
            .backgroundColor('#F59E0B')
            .borderRadius(28)
            .onClick(() => {
              this.pauseTimer()
            })
        } else {
          Button(this.isFinished ? '重新开始' : '开始')
            .height(56)
            .layoutWeight(2)
            .fontSize(18)
            .fontColor(Color.White)
            .backgroundColor('#10B981')
            .borderRadius(28)
            .onClick(() => {
              if (this.isFinished) {
                this.resetTimer()
              }
              this.startTimer()
            })
        }
        Blank().width(16)
        Button('重置')
          .height(56)
          .layoutWeight(1)
          .fontSize(16)
          .fontColor(this.remainingSeconds < this.totalSeconds ? '#EF4444' : '#9CA3AF')
          .backgroundColor(this.remainingSeconds < this.totalSeconds ? '#FEF2F2' : '#F3F4F6')
          .borderRadius(28)
          .onClick(() => {
            this.resetTimer()
          })
      }
      .width('100%')
    }
    .width('100%')
    .height('100%')
    .padding({ left: 18, right: 18 })
    .backgroundColor('#F5F7FA')
  }
}

八、代码实现说明

1. 倒计时核心逻辑

使用setInterval每秒减少1秒剩余时间,时间到0时调用finishTimer:

this.timerId = setInterval(() => {
  if (this.remainingSeconds > 0) {
    this.remainingSeconds--
  } else {
    this.finishTimer()
  }
}, 1000)
2. 圆形进度环

和番茄钟类似,使用两个Circle实现进度环,进度随时间增加,动画平滑过渡。倒计时结束时进度环变为绿色。

3. 快捷时间设置

提供常用时间的快捷按钮,点击即可快速设置,无需手动输入。

4. 自定义时间

支持输入分钟和秒数,点击应用设置自定义时间,有范围限制防止非法值。

5. 按钮状态管理

根据运行状态和结束状态动态改变按钮文字和颜色:

  • 运行中显示暂停按钮(橙色)
  • 未运行显示开始按钮(绿色)
  • 结束后显示重新开始
  • 重置按钮在有进度时才是红色可点击状态
6. 结束状态

倒计时结束时显示闹钟emoji和"时间到!"提示,进度环变绿,给用户明确的反馈。

九、运行项目

代码编写完成后,在DevEco Studio中运行项目。测试快捷时间选择、自定义时间、开始/暂停/重置功能,检查倒计时是否准确,进度环是否正常动画,结束时是否有正确提示。

十、开发中遇到的问题

  • 定时器清理:页面销毁时清除定时器防止内存泄漏
  • 运行中禁止修改时间:倒计时进行时不能修改时间设置
  • 自定义时间范围验证:防止输入超过范围的数值
  • 结束状态处理:时间到后正确显示完成状态

十一、总结

本文基于HarmonyOS和ArkTS实现了一个简约倒计时器。项目通过@State管理计时状态,使用定时器实现倒计时功能,实现了快捷时间选择、自定义时间、开始/暂停/重置和进度显示等功能。这个项目展示了ArkTS中定时器使用、动画效果和状态管理的基本方法,适合作为入门练习项目。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐