HarmonyOS7闹钟管理页面实战:从时间选择到重复规则的完整交互设计
文章目录
前言
闹钟页面是一个非常典型的“小功能看起来简单,真正做起来全是细节”的案例。时间显示要直观,开关切换要即时,重复规则要让人一眼看懂,新增面板还得保证录入过程不别扭。只要其中一个环节处理得粗糙,用户就会觉得这个工具“不顺手”。
所以这篇文章不打算泛泛介绍组件,而是专门讲清楚:HarmonyOS7 里一个闹钟管理页,到底该怎么组织状态、怎么表达重复周期、怎么把列表展示和新增交互接起来。它虽然只是一个示例,但里面已经包含了工具类应用最常见的交互骨架。
适用场景:时钟工具、提醒事项、习惯打卡、用药提醒
本篇重点:时间格式化、重复策略表达、表单式新增交互
阅读建议:边看边想“如果我要每天都用这个页面,会不会顺手”
这个页面的难点,其实不在 UI,而在规则表达
闹钟不同于普通待办,它带着很强的时间规则属性。
你会发现页面里至少有四层业务含义:
- 一个闹钟有没有开启。
- 它几点几分触发。
- 它是一次性提醒,还是工作日、周末、每天重复。
- 用户新增后,列表能不能马上以正确顺序呈现。
也就是说,这不是单纯的“列表增删改查”,而是一个带规则解释能力的列表页。真正值得学的部分,恰好都藏在这些规则处理函数里。
先从用户视角,跑一遍这页的完整流程
你可以按下面顺序自己体验一下:
- 打开页面,先看顶部“下一个闹钟”和“已开启数量”,确认首页是不是先给了最关键的信息。
- 看每条闹钟卡片,注意时间、标题、重复规则、开关、删除入口这些信息是不是一眼能扫清。
- 切换某一条闹钟开关,看列表样式、次要文案颜色和顶部统计是否同步变化。
- 点击右上角加号,进入新增面板,分别调整小时、分钟、名称和重复日期。
- 保存后回到列表,观察新闹钟是否自动插入到正确时间位置。
你做完这五步,会发现这个案例最有价值的地方并不是布局,而是它把“看列表”“改状态”“新增规则”三种操作放进了一个连续的使用链路里。
状态设计决定了这页能不能越写越稳
先看状态入口:
@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[] = ['日', '一', '二', '三', '四', '五', '六']
这里最值得借鉴的是,作者把状态按“列表态”和“编辑态”分开了。
| 状态类别 | 字段 | 用途 |
|---|---|---|
| 持久业务数据 | alarms |
存放所有闹钟记录,决定页面主要内容 |
| 弹层开关 | showAddPanel |
控制新增面板是否出现 |
| 新增表单值 | newHour、newMinute、newLabel、newRepeatDays |
服务于当前录入过程 |
| 辅助映射数据 | 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(' ')
}

这段代码为什么好?因为它在做“数据语言”到“用户语言”的转换。
- 空数组,不显示成
[],而是“仅一次”。 - 七天全选,不逐个列出“周日 周一 …”,而是直接说“每天”。
- 工作日和周末这两种高频场景,也做了单独压缩。
- 只有当规则不属于常见模式时,才退回到“周一 周三 周五”这种展开写法。
这才是一个真实工具页应该有的表达能力。你写的不是程序员内部结构,而是用户每天都会扫一眼的界面文案。
顶部“下一个闹钟”区域,体现了信息优先级意识
打开闹钟应用时,用户最想知道的通常不是“总共有几条”,而是“下一次什么时候响”。
源码里顶部卡片就是围绕这个目标做的:
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
)
: '无闹钟')
这段写法虽然略重复,但产品意图很明确:

- 先过滤出已开启闹钟。
- 再按时间排序。
- 最后拿最早的一条作为“下一个闹钟”。
这其实是在做一个首页摘要。它让用户不用进列表逐条找,就能在第一屏看到最关键的信息。
如果你把这个案例继续工程化,我会建议把这段提成独立计算函数,例如 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 = []
}
这段逻辑至少做对了四件事:
- 新建数据时给了默认名称和默认开关状态。
- 新增完成后立即按分钟数重新排序,保证列表顺序稳定。
- 自动关闭新增面板,结束当前交互。
- 清理表单值,避免下次打开时残留旧输入。
尤其是“新增后排序”这一点,特别像真实产品逻辑。如果没有这一步,用户刚添加一个早上 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')
}
}
最后总结
这个案例最有价值的地方,是它把“时间”“规则”“开关”“新增”四类信息揉进了一条很顺的使用路径里。你以后再做任何提醒类页面,只要记住规则表达要说人话、列表顺序要稳定、状态切换要有联动反馈,这个页面的核心就算真正学到了。
更多推荐

所有评论(0)