时光胶囊 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.emojiopt.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 对象。 组件自己从胶囊上读取 unlockAtisOpened 来计算状态,不需要父组件预处理。

三种显示状态

根据胶囊的生命周期,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 的视觉一致性。

Logo

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

更多推荐