前言

闹钟页面是一个非常典型的“小功能看起来简单,真正做起来全是细节”的案例。时间显示要直观,开关切换要即时,重复规则要让人一眼看懂,新增面板还得保证录入过程不别扭。只要其中一个环节处理得粗糙,用户就会觉得这个工具“不顺手”。

所以这篇文章不打算泛泛介绍组件,而是专门讲清楚:HarmonyOS7 里一个闹钟管理页,到底该怎么组织状态、怎么表达重复周期、怎么把列表展示和新增交互接起来。它虽然只是一个示例,但里面已经包含了工具类应用最常见的交互骨架。

适用场景:时钟工具、提醒事项、习惯打卡、用药提醒
本篇重点:时间格式化、重复策略表达、表单式新增交互
阅读建议:边看边想“如果我要每天都用这个页面,会不会顺手”

这个页面的难点,其实不在 UI,而在规则表达

闹钟不同于普通待办,它带着很强的时间规则属性。

你会发现页面里至少有四层业务含义:

  • 一个闹钟有没有开启。
  • 它几点几分触发。
  • 它是一次性提醒,还是工作日、周末、每天重复。
  • 用户新增后,列表能不能马上以正确顺序呈现。

也就是说,这不是单纯的“列表增删改查”,而是一个带规则解释能力的列表页。真正值得学的部分,恰好都藏在这些规则处理函数里。

先从用户视角,跑一遍这页的完整流程

你可以按下面顺序自己体验一下:

  1. 打开页面,先看顶部“下一个闹钟”和“已开启数量”,确认首页是不是先给了最关键的信息。
  2. 看每条闹钟卡片,注意时间、标题、重复规则、开关、删除入口这些信息是不是一眼能扫清。
  3. 切换某一条闹钟开关,看列表样式、次要文案颜色和顶部统计是否同步变化。
  4. 点击右上角加号,进入新增面板,分别调整小时、分钟、名称和重复日期。
  5. 保存后回到列表,观察新闹钟是否自动插入到正确时间位置。

你做完这五步,会发现这个案例最有价值的地方并不是布局,而是它把“看列表”“改状态”“新增规则”三种操作放进了一个连续的使用链路里。

状态设计决定了这页能不能越写越稳

先看状态入口:

@State alarms: AlarmItem[] = [
  { id: 1, hour: 6, minute: 30, label: '起床', enabled: true, repeatDays: [1, 2, 3, 4, 5], sound: '默认铃声', vibrate: true },
  { id: 2, hour: 8, minute: 0, label: '上班提醒', enabled: true, repeatDays: [1, 2, 3, 4, 5], sound: '清晨', vibrate: false },
  { id: 3, hour: 12, minute: 0, label: '午饭时间', enabled: false, repeatDays: [1, 2, 3, 4, 5, 6, 0], sound: '叮咚', vibrate: true },
  { id: 4, hour: 18, minute: 30, label: '下班提醒', enabled: true, repeatDays: [1, 2, 3, 4, 5], sound: '默认铃声', vibrate: false },
  { id: 5, hour: 22, minute: 30, label: '睡觉提醒', enabled: true, repeatDays: [0, 1, 2, 3, 4, 5, 6], sound: '轻音乐', vibrate: true },
  { id: 6, hour: 7, minute: 45, label: '周末起床', enabled: false, repeatDays: [6, 0], sound: '默认铃声', vibrate: true },
]
@State showAddPanel: boolean = false

![配图](/Users/chengruo/Desktop/HarmonyOS/formCardWeight/demoList/entry/src/main/ets/第三个账号100/01-50/images/img_001_A_hand-drawn_doodle_illustrati.png)

@State newHour: number = 7
@State newMinute: number = 0
@State newLabel: string = ''
@State newRepeatDays: number[] = []
@State editAlarmId: number = -1

private dayLabels: string[] = ['日', '一', '二', '三', '四', '五', '六']

这里最值得借鉴的是,作者把状态按“列表态”和“编辑态”分开了。

状态类别 字段 用途
持久业务数据 alarms 存放所有闹钟记录,决定页面主要内容
弹层开关 showAddPanel 控制新增面板是否出现
新增表单值 newHournewMinutenewLabelnewRepeatDays 服务于当前录入过程
辅助映射数据 dayLabels 把数字星期转换成可读文案

这类拆法的好处很直接:你以后要加编辑闹钟、铃声选择、振动模式,都知道该往哪一层加,不会把所有数据混在一锅里。

formatTime() 虽然短,却是页面可读性的起点

闹钟页面最先被用户看到的,永远是时间。只要时间显示不统一,整个页面就会显得很廉价。

formatTime(hour: number, minute: number): string {
  return `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`
}

这一段的意义不是技术难度,而是格式标准化

  • 6:5 这种裸值不适合直接展示。
  • 06:05 才符合用户对闹钟时间的预期。
  • 统一格式以后,顶部卡片和列表项都可以直接复用同一套输出结果。

这就是工具函数在 UI 页面里的价值。它不负责复杂逻辑,但负责把所有显示细节压到一个稳定格式里。

formatRepeat() 才是这个案例最值得细讲的函数

很多文章讲闹钟页时,只会说“把重复日期显示出来”。这太浅了。真正的问题是:用户不关心数组长什么样,他关心自己看到的是不是符合日常语言习惯。

formatRepeat(days: number[]): string {
  if (days.length === 0) return '仅一次'
  if (days.length === 7) return '每天'
  const weekdays = [1, 2, 3, 4, 5]
  const weekend = [0, 6]
  if (weekdays.every(d => days.includes(d)) && days.length === 5) return '工作日'
  if (weekend.every(d => days.includes(d)) && days.length === 2) return '周末'
  return days.sort((a, b) => a - b).map(d => `${this.dayLabels[d]}`).join(' ')
}

A hand-drawn doodle illustration on pure white pap

这段代码为什么好?因为它在做“数据语言”到“用户语言”的转换。

  • 空数组,不显示成 [],而是“仅一次”。
  • 七天全选,不逐个列出“周日 周一 …”,而是直接说“每天”。
  • 工作日和周末这两种高频场景,也做了单独压缩。
  • 只有当规则不属于常见模式时,才退回到“周一 周三 周五”这种展开写法。

这才是一个真实工具页应该有的表达能力。你写的不是程序员内部结构,而是用户每天都会扫一眼的界面文案。

顶部“下一个闹钟”区域,体现了信息优先级意识

打开闹钟应用时,用户最想知道的通常不是“总共有几条”,而是“下一次什么时候响”。

源码里顶部卡片就是围绕这个目标做的:

Text(this.alarms.filter(a => a.enabled).length > 0
  ? this.formatTime(
      this.alarms.filter(a => a.enabled)
        .sort((a, b) => a.hour * 60 + a.minute - (b.hour * 60 + b.minute))[0].hour,
      this.alarms.filter(a => a.enabled)
        .sort((a, b) => a.hour * 60 + a.minute - (b.hour * 60 + b.minute))[0].minute
    )
  : '无闹钟')

这段写法虽然略重复,但产品意图很明确:

A hand-drawn doodle illustration on pure white pap

  • 先过滤出已开启闹钟。
  • 再按时间排序。
  • 最后拿最早的一条作为“下一个闹钟”。

这其实是在做一个首页摘要。它让用户不用进列表逐条找,就能在第一屏看到最关键的信息。

如果你把这个案例继续工程化,我会建议把这段提成独立计算函数,例如 getNextEnabledAlarm(),这样更利于复用和测试。不过从教学角度看,当前写法足够直观,读者一眼就能明白它在干什么。

开关切换为什么会让人觉得“顺手”

toggleAlarm()

toggleAlarm(id: number) {
  this.alarms = this.alarms.map((a) => { if (a.id === id) { a.enabled = !a.enabled } return a })
}

这个函数本身不复杂,但它触发的是整页联动。

  • 列表项时间颜色会变。
  • 重复规则文案颜色会变。
  • 卡片透明度会变。
  • 顶部“已开启数量”会变。
  • “下一个闹钟”也可能跟着变。

也就是说,用户手上切的不是一个孤立开关,而是整个页面里的一个关键状态节点。之所以会觉得“有反馈”,是因为这次状态变化被多个区域共同消费了。

addAlarm() 写清楚了新增后应该发生什么

新增闹钟最容易漏掉的,是保存之后的收尾动作。看这段:

addAlarm() {
  this.alarms = this.alarms.concat([{
    id: Date.now(),
    hour: this.newHour,
    minute: this.newMinute,
    label: this.newLabel || '闹钟',
    enabled: true,
    repeatDays: this.newRepeatDays,
    sound: '默认铃声',
    vibrate: true,
  }])
  this.alarms = this.alarms.sort((a: AlarmItem, b: AlarmItem) => {
    return (a.hour * 60 + a.minute) - (b.hour * 60 + b.minute)
  })
  this.showAddPanel = false
  this.newLabel = ''
  this.newRepeatDays = []
}

这段逻辑至少做对了四件事:

  1. 新建数据时给了默认名称和默认开关状态。
  2. 新增完成后立即按分钟数重新排序,保证列表顺序稳定。
  3. 自动关闭新增面板,结束当前交互。
  4. 清理表单值,避免下次打开时残留旧输入。

尤其是“新增后排序”这一点,特别像真实产品逻辑。如果没有这一步,用户刚添加一个早上 6 点的闹钟,却发现它跑到列表底部,体验会非常割裂。

新增面板的交互设计,体现了工具类页面的实用主义

这一页的新增弹层没有追求花哨,而是围绕录入效率来设计:

  • 小时和分钟用加减按钮调整,适合快速试值。
  • 闹钟名称作为可选字段,不强迫用户每次都填。
  • 星期选择做成圆形标签,点击即可切换选中状态。
  • 保存和取消按钮放在底部成对出现,路径很清楚。

这一套设计对教程读者的启发是:工具页交互不需要炫技,重点是低认知负担和明确反馈。

如果这是正式项目,我会继续补哪些能力

当前示例已经把主链路串起来了,但如果要进入可上线状态,我会优先做这些增强:

  • 把“下一个闹钟”提炼成独立计算函数,避免重复过滤和排序。
  • 增加编辑已有闹钟的入口,而不只是新增和删除。
  • repeatDays 做拷贝处理,避免排序时直接修改原数组带来的副作用。
  • 补充真正的日期计算逻辑,让“今日 08:00”能处理跨天情况。
  • 接系统提醒能力时,把 UI 状态和系统调度结果做一次对齐。

读完这篇,最该学会哪件事

不是某个按钮怎么摆,而是这个思路:

时间类工具页面的核心,不只是把数据展示出来,而是把规则翻译成用户能立刻理解的结果。

这就是为什么 formatRepeat()、排序逻辑、顶部摘要区会比普通 UI 代码更有价值。它们决定了这个页面有没有真正承担“管理闹钟”这件事,而不只是长得像闹钟页。

完整代码

下面保留案例完整代码,方便你直接对照学习、复制和重构。

// 闹钟管理页面: 闹钟管理 - 多闹钟列表、Toggle开关、重复周期设置

interface AlarmItem {
  id: number
  hour: number
  minute: number
  label: string
  enabled: boolean
  repeatDays: number[]  // 0=周日 1=周一 ... 6=周六
  sound: string
  vibrate: boolean
}

@Entry
@Component
struct AlarmScheduleManager {
  @State alarms: AlarmItem[] = [
    { id: 1, hour: 6, minute: 30, label: '起床', enabled: true, repeatDays: [1, 2, 3, 4, 5], sound: '默认铃声', vibrate: true },
    { id: 2, hour: 8, minute: 0, label: '上班提醒', enabled: true, repeatDays: [1, 2, 3, 4, 5], sound: '清晨', vibrate: false },
    { id: 3, hour: 12, minute: 0, label: '午饭时间', enabled: false, repeatDays: [1, 2, 3, 4, 5, 6, 0], sound: '叮咚', vibrate: true },
    { id: 4, hour: 18, minute: 30, label: '下班提醒', enabled: true, repeatDays: [1, 2, 3, 4, 5], sound: '默认铃声', vibrate: false },
    { id: 5, hour: 22, minute: 30, label: '睡觉提醒', enabled: true, repeatDays: [0, 1, 2, 3, 4, 5, 6], sound: '轻音乐', vibrate: true },
    { id: 6, hour: 7, minute: 45, label: '周末起床', enabled: false, repeatDays: [6, 0], sound: '默认铃声', vibrate: true },
  ]
  @State showAddPanel: boolean = false
  @State newHour: number = 7
  @State newMinute: number = 0
  @State newLabel: string = ''
  @State newRepeatDays: number[] = []
  @State editAlarmId: number = -1

  private dayLabels: string[] = ['日', '一', '二', '三', '四', '五', '六']

  formatTime(hour: number, minute: number): string {
    return `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`
  }

  formatRepeat(days: number[]): string {
    if (days.length === 0) return '仅一次'
    if (days.length === 7) return '每天'
    const weekdays = [1, 2, 3, 4, 5]
    const weekend = [0, 6]
    if (weekdays.every(d => days.includes(d)) && days.length === 5) return '工作日'
    if (weekend.every(d => days.includes(d)) && days.length === 2) return '周末'
    return days.sort((a, b) => a - b).map(d => `${this.dayLabels[d]}`).join(' ')
  }

  getNextAlarmTime(alarm: AlarmItem): string {
    if (!alarm.enabled) return '已关闭'
    return `今日 ${this.formatTime(alarm.hour, alarm.minute)}`
  }

  toggleAlarm(id: number) {
    this.alarms = this.alarms.map((a) => { if (a.id === id) { a.enabled = !a.enabled } return a })
  }

  deleteAlarm(id: number) {
    this.alarms = this.alarms.filter((a: AlarmItem) => a.id !== id)
  }

  addAlarm() {
    this.alarms = this.alarms.concat([{
      id: Date.now(),
      hour: this.newHour,
      minute: this.newMinute,
      label: this.newLabel || '闹钟',
      enabled: true,
      repeatDays: this.newRepeatDays,
      sound: '默认铃声',
      vibrate: true,
    }])
    this.alarms = this.alarms.sort((a: AlarmItem, b: AlarmItem) => {
      return (a.hour * 60 + a.minute) - (b.hour * 60 + b.minute)
    })
    this.showAddPanel = false
    this.newLabel = ''
    this.newRepeatDays = []
  }

  build() {
    Column() {
      // 顶部
      Row() {
        Text('闹钟').fontSize(24).fontWeight(FontWeight.Bold).fontColor('#1A1A1A')
        Blank()
        Button('+').fontSize(22).fontColor('#FF9500')
          .backgroundColor('transparent')
          .width(40).height(40)
          .onClick(() => { this.showAddPanel = true })
      }
      .width('100%')
      .padding({ left: 20, right: 20, top: 20, bottom: 8 })

      // 最近一个闹钟提示
      Row() {
        Text('⏰').fontSize(20)
        Column() {
          Text('下一个闹钟').fontSize(12).fontColor('#888888')
          Text(this.alarms.filter(a => a.enabled).length > 0
            ? this.formatTime(
                this.alarms.filter(a => a.enabled)
                  .sort((a, b) => a.hour * 60 + a.minute - (b.hour * 60 + b.minute))[0].hour,
                this.alarms.filter(a => a.enabled)
                  .sort((a, b) => a.hour * 60 + a.minute - (b.hour * 60 + b.minute))[0].minute
              )
            : '无闹钟')
            .fontSize(28).fontWeight(FontWeight.Bold).fontColor('#FF9500')
        }
        .alignItems(HorizontalAlign.Start)
        .margin({ left: 12 })
        Blank()
        Text(`${this.alarms.filter(a => a.enabled).length} 个开启`)
          .fontSize(12).fontColor('#888888')
      }
      .width('100%')
      .padding(16)
      .backgroundColor('#FFF8EE')
      .borderRadius(14)
      .margin({ left: 20, right: 20, bottom: 16 })

      // 闹钟列表
      List({ space: 10 }) {
        ForEach(this.alarms, (alarm: AlarmItem) => {
          ListItem() {
            Column() {
              Row() {
                Column() {
                  Text(this.formatTime(alarm.hour, alarm.minute))
                    .fontSize(36)
                    .fontWeight(FontWeight.Lighter)
                    .fontColor(alarm.enabled ? '#1A1A1A' : '#BBBBBB')
                  Row({ space: 6 }) {
                    Text(alarm.label)
                      .fontSize(13)
                      .fontColor(alarm.enabled ? '#555555' : '#CCCCCC')
                    if (alarm.vibrate) {
                      Text('📳').fontSize(12)
                    }
                  }
                  .margin({ top: 2 })
                }
                .alignItems(HorizontalAlign.Start)
                .layoutWeight(1)

                Column() {
                  Toggle({ type: ToggleType.Switch, isOn: alarm.enabled })
                    .selectedColor('#FF9500')
                    .onChange((isOn: boolean) => {
                      this.toggleAlarm(alarm.id)
                    })
                }
              }
              .width('100%')

              Row() {
                Text(this.formatRepeat(alarm.repeatDays))
                  .fontSize(11)
                  .fontColor(alarm.enabled ? '#FF9500' : '#CCCCCC')
                Blank()
                Text(this.getNextAlarmTime(alarm))
                  .fontSize(11)
                  .fontColor('#AAAAAA')
                Text(' 删除')
                  .fontSize(11)
                  .fontColor('#FF3B30')
                  .margin({ left: 12 })
                  .onClick(() => this.deleteAlarm(alarm.id))
              }
              .width('100%')
              .margin({ top: 8 })
            }
            .width('100%')
            .padding({ left: 16, right: 16, top: 14, bottom: 14 })
            .backgroundColor('#FFFFFF')
            .borderRadius(14)
            .opacity(alarm.enabled ? 1 : 0.6)
          }
        })
      }
      .layoutWeight(1)
      .padding({ left: 20, right: 20 })

      // 新增闹钟面板
      if (this.showAddPanel) {
        Stack() {
          Column()
            .width('100%').height('100%')
            .backgroundColor('#00000050')
            .onClick(() => { this.showAddPanel = false })

          Column({ space: 16 }) {
            Text('新增闹钟').fontSize(17).fontWeight(FontWeight.Bold)

            // 时间选择
            Row({ space: 20 }) {
              Column() {
                Text('小时').fontSize(12).fontColor('#888888')
                Row() {
                  Button('-').width(36).height(36).fontSize(18)
                    .backgroundColor('#F5F5F5').fontColor('#333333').borderRadius(8)
                    .onClick(() => { this.newHour = (this.newHour - 1 + 24) % 24 })
                  Text(String(this.newHour).padStart(2, '0'))
                    .fontSize(28).fontWeight(FontWeight.Bold).width(50).textAlign(TextAlign.Center)
                  Button('+').width(36).height(36).fontSize(18)
                    .backgroundColor('#F5F5F5').fontColor('#333333').borderRadius(8)
                    .onClick(() => { this.newHour = (this.newHour + 1) % 24 })
                }
              }

              Text(':').fontSize(28).fontWeight(FontWeight.Bold).margin({ top: 16 })

              Column() {
                Text('分钟').fontSize(12).fontColor('#888888')
                Row() {
                  Button('-').width(36).height(36).fontSize(18)
                    .backgroundColor('#F5F5F5').fontColor('#333333').borderRadius(8)
                    .onClick(() => { this.newMinute = (this.newMinute - 5 + 60) % 60 })
                  Text(String(this.newMinute).padStart(2, '0'))
                    .fontSize(28).fontWeight(FontWeight.Bold).width(50).textAlign(TextAlign.Center)
                  Button('+').width(36).height(36).fontSize(18)
                    .backgroundColor('#F5F5F5').fontColor('#333333').borderRadius(8)
                    .onClick(() => { this.newMinute = (this.newMinute + 5) % 60 })
                }
              }
            }
            .justifyContent(FlexAlign.Center)

            TextInput({ placeholder: '闹钟名称(可选)', text: this.newLabel })
              .height(44).borderRadius(10).backgroundColor('#F5F5F5')
              .onChange((v: string) => { this.newLabel = v })

            // 重复日期
            Column({ space: 8 }) {
              Text('重复').fontSize(13).fontColor('#555555').alignSelf(ItemAlign.Start)
              Row({ space: 8 }) {
                ForEach([0, 1, 2, 3, 4, 5, 6], (day: number) => {
                  Text(this.dayLabels[day])
                    .fontSize(13)
                    .fontWeight(FontWeight.Medium)
                    .fontColor(this.newRepeatDays.includes(day) ? '#FFFFFF' : '#666666')
                    .backgroundColor(this.newRepeatDays.includes(day) ? '#FF9500' : '#F5F5F5')
                    .borderRadius(18)
                    .width(36).height(36)
                    .textAlign(TextAlign.Center)
                    .lineHeight(36)
                    .onClick(() => {
                      if (this.newRepeatDays.includes(day)) {
                        this.newRepeatDays = this.newRepeatDays.filter(d => d !== day)
                      } else {
                        this.newRepeatDays = this.newRepeatDays.concat([day])
                      }
                    })
                })
              }
            }

            Row({ space: 12 }) {
              Button('取消').layoutWeight(1).height(44)
                .fontColor('#007AFF').backgroundColor('#F5F5F5').borderRadius(12)
                .onClick(() => { this.showAddPanel = false })
              Button('保存').layoutWeight(1).height(44)
                .fontColor('#FFFFFF').backgroundColor('#FF9500').borderRadius(12)
                .onClick(() => this.addAlarm())
            }
          }
          .width('90%')
          .padding(20)
          .backgroundColor('#FFFFFF')
          .borderRadius(20)
        }
        .width('100%').height('100%')
        .position({ x: 0, y: 0 })
      }
    }
    .width('100%').height('100%')
    .backgroundColor('#F8F8F8')
  }
}

最后总结

这个案例最有价值的地方,是它把“时间”“规则”“开关”“新增”四类信息揉进了一条很顺的使用路径里。你以后再做任何提醒类页面,只要记住规则表达要说人话、列表顺序要稳定、状态切换要有联动反馈,这个页面的核心就算真正学到了。

Logo

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

更多推荐