一个好的情绪选择器不只是五个表情的排列------它需要动画、反馈、数据模型和直觉化的交互设计。本文从真实项目 E-Brufen 出发,拆解 MoodPicker 组件的完整设计与实现。
一、组件概览:受控组件模式

MoodPicker 是 E-Brufen 中使用频率最高的交互组件,同时出现在"情绪日记"的记录页和首页"今日速记"中。它遵循 Flutter 中经典的**受控组件(Controlled Component)**模式:
dart
// lib/widgets/mood_picker.dart
class MoodPicker extends StatelessWidget {
final MoodType? selected;
final ValueChanged<MoodType> onChanged;
const MoodPicker({
super.key,
required this.selected,
required this.onChanged,
});
}
受控组件的核心思想是:状态外提 。MoodPicker 自身不持有任何 State------当前选中的情绪由父组件通过 selected 传入,用户点击时通过 onChanged 回调通知父组件。这种模式带来了三个关键好处:
- 单一数据源:父组件是状态的唯一持有者,不存在组件内外状态不同步的问题。
- 可组合性 :父组件可以根据
selected的值自由控制其他 UI(如保存按钮的启用/禁用)。 - 可测试性 :测试时只需传入不同
selected值并验证onChanged回调,无需操作组件内部状态。
二、数据模型:五种情绪的语义化表达
在深入 UI 之前,先看 MoodPicker 依赖的数据模型------MoodType 枚举。它将情绪映射为数值、表情符号和中文标签的三元组:
dart
// lib/models/mood_entry.dart
enum MoodType {
angry(1, '😡', '生气'),
sad(2, '😢', '难过'),
tired(3, '😴', '疲惫'),
calm(4, '😐', '平静'),
happy(5, '😊', '开心');
final int value;
final String emoji;
final String label;
const MoodType(this.value, this.emoji, this.label);
}
这里有一个值得注意的设计决策:用 1-5 的整数值而非 0-4 。原因是 value 不仅用于排序,还直接映射到 MoodChart 统计图的 Y 轴------值为 0 在柱状图中会被渲染为"无数据"的灰色,而 1-5 的区间让"生气"到"开心"的梯度在视觉上更符合直觉。"平静"(calm)作为默认值(orElse: () => MoodType.calm),在序列化异常时提供了安全的兜底。
整套数据的持久化通过 MoodEntry 类完成,它包含情绪类型、可选备注、创建时间和更新时间。存储层使用 Hive(一个纯 Dart 的键值存储)而非 SQLite------这是为了兼容鸿蒙平台,避免原生 SQLite 依赖。
三、五种情绪的视觉呈现
回到 UI 层,MoodPicker 的核心渲染逻辑:
dart
@override
Widget build(BuildContext context) {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: MoodType.values.map((mood) {
final isSelected = selected == mood;
return GestureDetector(
onTap: () => onChanged(mood),
child: AnimatedScale(
scale: isSelected ? 1.3 : 1.0,
duration: const Duration(milliseconds: 200),
child: Column(
children: [
Text(mood.emoji,
style: TextStyle(fontSize: isSelected ? 44 : 36)
),
const SizedBox(height: 4),
Text(mood.label,
style: TextStyle(
fontSize: 12,
color: isSelected ? AppTheme.primaryGreen : Colors.grey,
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
),
),
],
),
),
);
}).toList(),
);
}
选中态变化完整对照表:
| 属性 | 未选中 | 选中 | 设计意图 |
|---|---|---|---|
| Scale | 1.0x | 1.3x | 空间上的"弹出感",吸引视觉焦点 |
| Emoji 字号 | 36px | 44px | 表情本身放大,强化选中反馈 |
| 标签颜色 | Grey | primaryGreen (#66BB6A) | 语义色映射------绿色传递积极确认 |
| 标签字重 | normal | bold | 文字变粗,从"可选提示"变为"已确认状态" |
这些变化不是随意的------它们构成了三重暗示系统:大小(空间暗示)、颜色(语义暗示)、粗细(层级暗示)。三者同时触发,确保用户在 200ms 内就能感知到状态变化,无需阅读任何文本说明。
四、AnimatedScale:隐式动画的甜点
AnimatedScale 是 Flutter "隐式动画"家族的成员。它的工作方式极其简单:你只需要改变 scale 属性,Flutter 会在指定的 duration 内自动完成平滑过渡。整个动画的生命周期如下:
用户点击 😊
→ GestureDetector.onTap 触发
→ onChanged(MoodType.happy) 回调父组件
→ 父组件 setState { selected = MoodType.happy }
→ Flutter 重建 widget 树
→ 新选中的 AnimatedScale 检测到 scale: 1.0 → 1.3
→ 自动启动 200ms 插值动画(Curves.linear → 弹性缩放)
→ 之前选中的 AnimatedScale 检测到 scale: 1.3 → 1.0
→ 同步回缩,形成"切换"效果
不需要手动创建 AnimationController,不需要在 dispose() 中释放资源------这些正是隐式动画的核心优势。Flutter 的隐式动画家族包括 AnimatedContainer、AnimatedOpacity、AnimatedPadding 等,它们覆盖了大多数"A→B 状态切换"的场景。
Insight :隐式动画是 Flutter 的"甜点层"------对于选择器、开关、Toggle、Tab 切换等场景,它们用最少的代码实现最自然的过渡效果。只有当需要连续循环动画(如呼吸球、无限滚动)或需要精确控制动画曲线的中间帧时,才需要升级到显式的
AnimationController+Tween方案。
参数选择上,scale: 1.3 而非 1.2 或 1.5 是经过权衡的:1.2 的视觉差异太小,用户可能注意不到变化;1.5 则过于夸张,会让相邻的表情被遮挡。1.3 在 36px 的基础 emoji 上增加约 11px 的视觉尺寸,刚好形成清晰的"弹出"效果而不会溢出。duration: 200ms 遵循了 Android Material Design 的推荐------200-300ms 是移动端"感知流畅"的最佳区间。
五、交互细节:GestureDetector、SpaceEvenly 与触觉反馈
5.1 GestureDetector vs InkWell
MoodPicker 使用 GestureDetector 而非 InkWell。这不是随意的选择------InkWell 会在点击时绘制 Material Design 的水波纹(ripple effect),但水波纹需要在一个有明确边界的 Material 容器中才能正常渲染。MoodPicker 的每个情绪项是 emoji + 文字的 Column 组合,没有矩形背景,使用 InkWell 会产生半截波纹或不完整的视觉效果。GestureDetector 更轻量,不引入任何视觉副作用,是纯手势检测场景的正确选择。
选择指南:
- 按钮、卡片、列表项等有矩形背景的组件 →
InkWell(提供符合 Material 规范的视觉反馈) - 纯图标、emoji、自定义图形等无背景的点击区域 →
GestureDetector(更干净、无副作用)
5.2 SpaceEvenly 布局策略
dart
mainAxisAlignment: MainAxisAlignment.spaceEvenly
spaceEvenly 确保所有间距相等------包括元素之间的间距和首尾元素到容器边缘的间距。对比其他选项:
| 对齐方式 | 首尾间距 | 元素间距 | 适用场景 |
|---|---|---|---|
| spaceEvenly | = 元素间距 | = 首尾间距 | MoodPicker------5 个表情均匀分布,视觉平衡 |
| spaceBetween | 0 | 均匀 | 需要贴边排列的内容 |
| spaceAround | = 元素间距/2 | 均匀 | 间距稍紧但有边距 |
对于五个表情的横向排列,spaceEvenly 让每个表情到屏幕边缘的距离与表情之间的距离保持一致,在 360-414dp 宽度的手机上形成约 45-55dp 的间隔------刚好够手指精确点击单个表情而不会误触相邻项。
5.3 可选的触觉反馈
当前版本没有加入触觉反馈。如果后续需要增强体验,可以在 GestureDetector 的 onTap 中加入 HapticFeedback.selectionClick()------这是 Android/iOS 原生的轻触振动,给用户一个微妙的物理确认。需要注意两点:一是仅在选中变化时触发(避免重复点击同一项时振动);二是鸿蒙平台对 HapticFeedback 的支持需要额外验证。
六、在日记页面中使用:完整记录流程
在 DiaryPage 中,MoodPicker 作为完整记录流程的第一步,与日期选择、备注输入、保存按钮串联:
dart
// lib/pages/diary/diary_page.dart --- _buildRecordTab()
MoodPicker(
selected: _selectedMood,
onChanged: (m) => setState(() => _selectedMood = m),
)
// 保存按钮与选中状态联动
ElevatedButton.icon(
onPressed: _selectedMood != null ? _saveMood : null,
icon: const Icon(Icons.check),
label: Text(_editingId != null ? '更新' : '保存'),
)
这里的模式是**"强制选择"UX**:_selectedMood 初始为 null,保存按钮在用户做出选择之前保持禁用状态(onPressed: null)。这确保了数据库中不会出现没有情绪类型的记录。同时,编辑已有记录时(_editingId != null),_selectedMood 会被预设为该记录的情绪值,按钮文案切换为"更新"------MoodPicker 的受控模式让这种"编辑回填"变得自然:只需 setState 更新 _selectedMood,组件自动渲染选中态。
保存逻辑 _saveMood() 展示了完整的写入链路:构造 MoodEntry(含 moodType、可选 note、时间戳)→ moodStorage.insert() → Hive 持久化 → notifyListeners() 触发 UI 刷新 → SnackBar 反馈。保存后 _selectedMood 重置为 null,MoodPicker 回到无选中状态,为下一次记录做好准备。
七、在首页速记中使用:点完即走的轻量模式
首页"今日速记"采用了完全不同的使用模式------不显示完整的 MoodPicker,而是用更轻量的内联 emoji 行:
dart
// lib/pages/home_page.dart --- 今日速记区域
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: MoodType.values.map((mood) => Tooltip(
message: mood.label,
child: GestureDetector(
onTap: () => _quickCheckIn(context, mood),
child: Text(mood.emoji, style: const TextStyle(fontSize: 32)),
),
)).toList(),
)
两种模式的对比揭示了组件设计的核心权衡:
| 维度 | 日记页 MoodPicker | 首页速记 |
|---|---|---|
| 选中态 | 显示(scale+颜色+粗细) | 无------点完即走 |
| 动画 | AnimatedScale 过渡 | 无------无需状态切换 |
| 标签文字 | 显示中文标签 | 无------仅 emoji |
| 备注 | 支持 TextField 输入 | 无------仅记录情绪 |
| 保存 | 显式按钮 | 自动保存 + SnackBar |
| 编辑/删除 | 支持 | 不支持 |
这种差异符合渐进式复杂度 原则:日记页提供完整能力(选日期、写备注、编辑历史),适合深度记录场景;首页速记仅需一次点击,适合"随手记一笔"的快节奏场景。两者的数据最终都写入同一个 MoodStorage 实例,在统计页和时间线中统一展示。
八、数据层集成:从 UI 到持久化的完整链路
MoodPicker 的 onChanged 回调是整个情绪记录系统的入口,后续的数据流如下:
MoodPicker.onChanged(mood)
→ 父组件 setState → _selectedMood = mood
→ 用户点击保存 → _saveMood()
→ MoodEntry(moodType, note, createdAt, updatedAt)
→ moodStorage.insert(entry) // ChangeNotifier
→ Hive.box.put(id, jsonEncode) // 持久化
→ notifyListeners() // 广播变化
→ _loadMoods() 回调 // 监听器触发
→ _allMoods = getAll() // 重新读取
→ setState → UI 重建 // 时间线/统计图更新
关键设计点:
MoodStorage继承ChangeNotifier,通过addListener/removeListener实现观察者模式,UI 层无需轮询- 使用 Hive 而非 SQLite 是鸿蒙兼容性驱动的决策------Hive 是纯 Dart 实现,不依赖原生平台代码
MoodEntry.toJson()/fromJson()提供序列化边界,将领域对象与存储格式解耦
九、测试策略
MoodPicker 的受控模式让测试变得直接------不需要操作组件内部状态,只需验证"给定输入,产生输出":
dart
// test/widgets/mood_picker_test.dart
testWidgets('MoodPicker shows 5 emojis and calls onChanged', (tester) async {
MoodType? picked;
await tester.pumpWidget(MaterialApp(
home: Scaffold(
body: MoodPicker(selected: null, onChanged: (m) => picked = m),
),
));
expect(find.text('😊'), findsOneWidget);
expect(find.text('😐'), findsOneWidget);
expect(find.text('😢'), findsOneWidget);
expect(find.text('😡'), findsOneWidget);
expect(find.text('😴'), findsOneWidget);
await tester.tap(find.text('😊'));
expect(picked, MoodType.happy);
});
这个测试覆盖了两个核心行为:所有五个表情都渲染了,点击"开心"正确回调了 MoodType.happy。可以扩展的测试场景包括:验证选中态的 scale 变化(通过 tester.renderObject 检查 Transform widget)、验证重复点击同一项的行为、验证 selected 从非 null 切换到另一个值时的动画。
十、未来可能的增强方向
- 语义化动画:为五种情绪设计不同的选中动画------"开心"用弹跳(bounce)、"生气"用抖动(shake)、"平静"用呼吸(pulse),让动画本身传达情绪特征
- 无障碍支持 :为每个情绪项添加
Semanticswidget,设置label和state(selected/not selected),让 TalkBack 等屏幕阅读器能正确播报 - 长按预览:长按某个表情时显示该情绪的统计信息("本月开心 12 天"),将选择器从"输入工具"升级为"洞察入口"
- 滑动选择 :支持手指在表情间滑动时实时切换选中态,配合
hitTestBehavior优化触摸区域
小结
MoodPicker 只有约 30 行核心 UI 代码,却浓缩了良好组件设计的多个关键要素:受控模式 将状态外提实现单一数据源,隐式动画 用零 AnimationController 代价获得流畅过渡,三重视觉暗示 (大小+颜色+粗细)让用户无需阅读就能理解状态变化,枚举数据模型 为 UI 和统计提供统一的语义基础。这种"小而美"的组件方法论可以复用到任何选择器、Toggle、Tab 等交互场景中------好的设计不在于代码量,而在于每一行代码都承载明确的设计意图。
项目源码 :https://gitcode.com/PengXiansheng/E-Brufen
作者简介:E-Brufen Dev,Flutter & 鸿蒙开发者,专注于跨平台移动应用开发与心理健康数字化。