HarmonyOS时光胶囊——MoodPicker与CountdownBadge组件设计
时光胶囊 App 有两个高频使用的小组件——心情选择器和倒计时徽章。它们分别在封印 Tab 和时间线列表里反复出现。一个负责让用户快速选择情绪,另一个实时显示距离解锁还有多久。看似简单,但要做到交互流畅、视觉统一、状态同步,每个组件都有值得拆解的设计细节。
完整效果
一、MoodPicker 的调用方式
从首页封印 Tab 的代码可以还原 MoodPicker 的完整用法:
MoodPicker({
selectedMood: this.selectedMood, // 当前选中的心情
colors: this.getColors(), // 主题颜色对象
onMoodChange: (mood: string) => { // 选中回调
this.selectedMood = mood
}
})

三个参数的设计理由
| 参数 | 类型 | 作用 | 为什么这样传 |
|---|---|---|---|
| selectedMood | string | 当前选中的 emoji | 组件不管理状态,由父组件控制 |
| colors | ThemeColors | 主题颜色 | 保证切换主题后心情选择器同步变色 |
| onMoodChange | 回调函数 | 通知父组件选中变化 | 单向数据流——组件只通知,不修改状态 |
这是典型的"受控组件"模式——组件本身不持有状态,所有状态由父组件管理。 心情选择器只负责"展示当前选中的是什么"和"用户点了哪个",至于"当前选中的是什么"这个数据存在 Index 的 @State selectedMood 里。
二、MoodPicker 的内部实现推断
MOOD_OPTIONS 常量
从 opt.emoji 和 opt.label 推断,MoodPicker 依赖一个固定的心情选项列表:
// 从 model/Capsule.ets 导入
export const MOOD_OPTIONS: MoodOption[] = [
{ emoji: '😊', label: '开心' },
{ emoji: '😢', label: '难过' },
{ emoji: '🤔', label: '思考' },
{ emoji: '😤', label: '生气' },
{ emoji: '🥳', label: '庆祝' }
]

五个选项是固定值——不从数据库读取,不支持自定义。 原因:心情选项需要和统计 Tab 的柱状图对齐。如果用户自定义心情,统计图表的维度会不可控。
渲染结构推断
基于选中/未选中的视觉效果,MoodPicker 内部大概率是这样的结构:
@Component
struct MoodPicker {
selectedMood: string = ''
colors: ThemeColors = getThemeColors(ThemeType.CAPSULE)
onMoodChange: (mood: string) => void = () => {}
build() {
Flex({ wrap: FlexWrap.Wrap }) {
ForEach(MOOD_OPTIONS, (opt: MoodOption) => {
Column() {
Text(opt.emoji).fontSize(28)
Text(opt.label).fontSize(10)
}
.width(56).padding({ top: 8, bottom: 8 })
.borderRadius(BorderRadius.md)
.backgroundColor(this.selectedMood === opt.emoji
? this.colors.primary + '20' : 'transparent')
.border({ width: 1.5,
color: this.selectedMood === opt.emoji
? this.colors.primary : this.colors.border })
.onClick(() => this.onMoodChange(opt.emoji))
})
}
.width('100%')
}
}
选中状态的视觉编码
| 状态 | 背景色 | 边框色 | 文字 |
|---|---|---|---|
| 选中 | primary + ‘20’(20% 透明度) | primary | emoji + label |
| 未选中 | transparent | border | emoji + label |
选中状态用 20% 透明度的主色背景——比笔记本卡片的 8% 更深,因为心情选择器的点击目标更小,需要更强的视觉反馈。 边框从 1px 加粗到 1.5px,进一步强化选中感。
为什么用 Flex 而不是 Row
Flex({ wrap: FlexWrap.Wrap }) // 自动换行
// vs
Row({ space: 8 }) // 不换行,可能溢出
五个心情选项在窄屏手机上一行放不下——Row 会溢出,Flex 会自动换行。 和首页统计 Tab 的 2×2 网格用同样的 FlexWrap.Wrap 策略。
三、CountdownBadge 的调用方式
// 从 CapsuleCard 调用推断
CountdownBadge({ capsule: capsule })
只有一个参数——整个 Capsule 对象。 组件自己从胶囊上读取 unlockAt 和 isOpened 来计算状态,不需要父组件预处理。
三种显示状态
根据胶囊的生命周期,CountdownBadge 有三种显示模式:
| 状态 | 判断条件 | 显示内容 | 颜色 |
|---|---|---|---|
| 等待中 | !isOpened && unlockAt > now | “还剩 X天X小时” | 主色 |
| 可开启 | !isOpened && unlockAt <= now | “✨ 可开启” | 绿色 |
| 已开启 | isOpened === true | “已开启” | 灰色 |
倒计时的计算逻辑
private getTimeLeft(unlockAt: number): string {
const diff = unlockAt - Date.now()
if (diff <= 0) return '可开启'
const days = Math.floor(diff / 86400000)
const hours = Math.floor((diff % 86400000) / 3600000)
if (days > 0) return `还剩 ${days}天${hours}小时`
return `还剩 ${hours}小时`
}

时间单位只显示"天"和"小时"——不显示分钟和秒。 原因:胶囊的解锁周期通常是天级别(1天/7天/30天/1年),精确到分钟没有实际意义,反而增加视觉噪音。
渲染结构推断
@Component
struct CountdownBadge {
capsule: Capsule = {} as Capsule
build() {
Text(this.badgeText())
.fontSize(10)
.fontColor(this.badgeColor())
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
.borderRadius(BorderRadius.full)
.backgroundColor(this.badgeBgColor())
}
}
Badge 样式——小字号 + 圆角背景 + 文字颜色区分状态。 这是移动端常见的"标签徽章"模式,用于在有限空间内传递状态信息。
四、CapsuleCard 的调用方式
CapsuleCard({
capsule: capsule,
colors: this.getColors(),
onCardClick: (c: Capsule) => {
this.handleCardClick(c)
}
})
三个参数
| 参数 | 类型 | 作用 |
|---|---|---|
| capsule | Capsule | 胶囊数据对象 |
| colors | ThemeColors | 主题颜色 |
| onCardClick | 回调函数 | 点击卡片时触发,传递整个胶囊对象 |
CapsuleCard 同样是受控组件——不管理自己的状态,数据和交互都由父组件控制。
卡片需要展示的信息
| 信息 | 来源 | 布局位置 |
|---|---|---|
| 内容预览 | capsule.content(截取前50字) | 卡片主体 |
| 心情 | capsule.mood | 右上角 |
| 标签 | capsule.tags | 内容下方 |
| 创建时间 | capsule.createdAt | 卡片底部 |
| 状态标记 | isOpened + unlockAt | CountdownBadge |
卡片的视觉结构推断
Row(卡片)
├→ Column(左侧:状态/时间)
│ ├→ CountdownBadge 状态徽章
│ └→ Text(createdAt) 创建时间
└→ Column(右侧:内容)
├→ Row(标题行)
│ ├→ Text(content) 内容预览(截取50字)
│ └→ Text(mood) 心情 emoji
└→ Row(标签行)
└→ ForEach(tags) 标签列表
五、组件间的颜色传递
为什么传 colors 而不是让组件自己获取
// 方案 A(当前):父组件传颜色
MoodPicker({ colors: this.getColors(), ... })
// 方案 B:组件自己获取颜色
MoodPicker({ ... })
// 组件内部:getThemeColors(currentTheme)
方案 A 更好——因为颜色来自父组件的 currentTheme 状态。 如果组件自己获取颜色,需要访问父组件的 currentTheme,增加了组件间的耦合。传 colors 参数让组件完全独立——不关心当前是什么主题,只用传进来的颜色渲染。
主题切换时的刷新链路:
用户点击主题标签
→ saveTheme() 更新 currentTheme
→ build 重新执行
→ getColors() 返回新主题颜色
→ 传给 MoodPicker/CapsuleCard 的 colors 更新
→ 组件重新渲染
透明度后缀的统一规则
| 组件 | 透明度 | 用途 |
|---|---|---|
| 笔记本图标背景 | ‘15’(15%) | 轻度背景 |
| 心情选中背景 | ‘20’(20%) | 中度背景 |
| 快捷操作图标 | ‘12’(12%) | 轻度背景 |
| 标签输入背景 | ‘15’(15%) | 轻度背景 |
所有透明度都在 10%~20% 之间——太浅看不见,太深抢内容的视觉权重。 MoodPicker 的 20% 最深,因为它是核心交互元素,需要最明显的选中反馈。
六、受控组件模式的统一
整个 App 的组件都遵循同一个模式:
父组件:管理状态 + 传递数据 + 接收回调
子组件:接收数据 + 渲染 UI + 触发回调
| 组件 | 状态归属 | 数据输入 | 交互输出 |
|---|---|---|---|
| MoodPicker | Index @State | selectedMood, colors | onMoodChange |
| CountdownBadge | 无状态 | capsule | 无 |
| CapsuleCard | 无状态 | capsule, colors | onCardClick |
CountdownBadge 是最简单的——纯展示,无交互。 它不需要回调,因为用户不会和倒计时徽章交互(点击是整个卡片的事件)。
MoodPicker 需要回调——因为用户会和它交互。 每次点击都触发 onMoodChange,通知父组件更新状态。
七、组件文件的组织方式
ets/
components/
CapsuleCard.ets 卡片组件
MoodPicker.ets 心情选择器
CountdownBadge.ets 倒计时徽章
pages/
Index.ets 首页(调用上述组件)
CapsuleDetail.ets 详情页
MemoryShuffle.ets 记忆洗牌
model/
Capsule.ets 数据模型
Database.ets 数据库
utils/
Theme.ets 主题系统
components 目录放可复用组件,pages 目录放页面,model 放数据层,utils 放工具函数。 四层分离——每个目录的职责清晰。
组件复用的可能性
| 组件 | 当前使用位置 | 可复用位置 |
|---|---|---|
| MoodPicker | 封印 Tab | CapsuleDetail(编辑时可改心情) |
| CountdownBadge | CapsuleCard | CapsuleDetail(详情页显示倒计时) |
| CapsuleCard | 时间线列表 | MemoryShuffle(洗牌展示卡片) |
MoodPicker 在详情页可能需要——如果允许用户修改已封印胶囊的心情。 CountdownBadge 在详情页也合理——显示当前胶囊的等待状态。CapsuleCard 在洗牌页可以复用——每张"洗出来的牌"就是一张胶囊卡片。
八、组件与页面的边界
什么逻辑放在组件里,什么放在页面里
| 逻辑 | 位置 | 原因 |
|---|---|---|
| 心情选择 UI | MoodPicker 组件 | 纯 UI 渲染,可复用 |
| 选中状态管理 | Index 页面 | 状态属于封印表单的一部分 |
| 倒计时计算 | CountdownBadge 组件 | 计算逻辑和展示绑定 |
| 卡片点击跳转 | Index 页面 | 路由逻辑属于页面层 |
| 卡片内容渲染 | CapsuleCard 组件 | 纯 UI 渲染,可复用 |
组件只管"怎么显示",页面管"显示什么"和"点了之后做什么"。 这是组件化开发的核心原则——组件是 UI 的封装,不是业务逻辑的封装。
九、组件的类型安全
回调函数的类型定义
// MoodPicker 的回调类型
onMoodChange: (mood: string) => void
// CapsuleCard 的回调类型
onCardClick: (c: Capsule) => void
回调参数有明确的类型——不是 (data: any) => void。 ArkTS 的类型系统在编译期检查参数类型,防止运行时错误。mood 是 string,c 是 Capsule——类型不匹配时编译器直接报错。
组件属性的类型定义
// CountdownBadge 的 capsule 属性
capsule: Capsule = {} as Capsule
默认值用 {} as Capsule——空对象类型断言为 Capsule。 这是 ArkTS 处理可选属性的常见模式。组件被调用时一定会传 capsule,但类型系统需要默认值。
十、组件设计的三个原则
1. 单一职责
每个组件只做一件事:
| 组件 | 职责 |
|---|---|
| MoodPicker | 展示心情选项 + 接收选择 |
| CountdownBadge | 计算并展示剩余时间 |
| CapsuleCard | 渲染一张胶囊卡片 |
不做超出职责的事。 MoodPicker 不负责保存数据,CountdownBadge 不负责点击跳转,CapsuleCard 不负责筛选逻辑。
2. 受控优先
组件不管理自己的 @State——所有状态由父组件控制。这让数据流单向:父组件 → 组件 → 用户交互 → 回调 → 父组件。调试时只需要追踪父组件的 @State 变化。
3. 主题可配置
所有组件通过 colors 参数接收主题颜色——不在组件内部硬编码颜色值。主题切换时,父组件传入新的 colors,组件自动刷新。这保证了整个 App 的视觉一致性。
更多推荐



所有评论(0)