AtomGit Flutter 鸿蒙客户端:倒计时与触觉反馈
倒计时不只是数字递减——配合触觉反馈,可以为放松体验增加一层"物理感",让用户即使闭着眼睛也能感知时间的流动。
一、设计理念:让用户不必看屏幕
白噪音模块的核心使用场景是:用户闭上眼睛,沉浸在放松状态中。在这种场景下,屏幕上的视觉信息对用户是"不可见"的。
传统的解决方式是音频提示("叮"的一声),但 E-Brufen 当前没有音频播放能力。于是我们转向了触觉通道:
视觉通道(眼睛闭着)
↓ 不可用
听觉通道(无音频播放)
↓ 不可用
触觉通道 ← E-Brufen 的主反馈通道!
↓
用户感知到震动 → 知道时间在流逝 → 保持放松状态
二、定时器选项设计

2.1 预设时长
final List<int> _durations = [15, 30, 60, 90];
Wrap(spacing: 8, children: [
..._durations.map((d) => ChoiceChip(
label: Text('$d分'),
selected: _remainingSec > 0 && _remainingSec ~/ 60 == d,
onSelected: (_) => setState(() {
_remainingSec = d * 60;
widget.settings.timerDuration = d; // 持久化偏好
}),
)),
ChoiceChip(
label: const Text('不限'),
selected: _remainingSec == 0,
onSelected: (_) => setState(() => _remainingSec = 0),
),
])
设计考量:
| 时长 | 适用场景 | 触觉反馈次数 |
|---|---|---|
| 15 分钟 | 快速放松、课间休息 | 14 次轻触 + 1 次重触 |
| 30 分钟 | 午休小憩 | 29 次轻触 + 1 次重触 |
| 60 分钟 | 深度放松 | 59 次轻触 + 1 次重触 |
| 90 分钟 | 完整睡眠周期 | 89 次轻触 + 1 次重触 |
| 不限 | 背景白噪音(办公等) | 无(不启动 Timer) |
⚠️ "不限"选项不启动 Timer,
_remainingSec == 0作为特殊标记。此时_startCountdown()直接 return,不会消耗系统资源。
三、Timer.periodic 倒计时实现
3.1 核心代码
void _startCountdown() {
_countdown?.cancel(); // 取消旧的 Timer(防重复)
if (_remainingSec <= 0) return; // "不限"模式直接返回
_countdown = Timer.periodic(const Duration(seconds: 1), (timer) {
if (!_isPlaying) { // 手动停止 → 取消计时
timer.cancel();
return;
}
setState(() {
_remainingSec--;
if (_remainingSec <= 0) { // 时间到
_onTimerDone();
timer.cancel();
return;
}
// ⏰ 每分钟一次轻触觉——"无声的报时"
if (_remainingSec % 60 == 0) {
HapticFeedback.lightImpact();
}
});
});
}
3.2 状态流程图
用户点击播放
↓
_startCountdown() → Timer.periodic(每秒)
↓
每秒回调:
├─ !_isPlaying? → timer.cancel() (用户手动停止)
├─ _remainingSec == 0? → _onTimerDone() (自然结束)
├─ _remainingSec % 60 == 0? → lightImpact() (分钟标记)
└─ _remainingSec-- (继续倒计时)
★ Insight ─────────────────────────────────────_remainingSec % 60 == 0是"无声提醒"的核心。用户闭眼放松时,每分钟一次轻触觉让他知道"又过了一分钟",而不需要睁眼看屏幕。这是 E-Brufen 最精妙的设计细节之一——它把"必须看"变成了"可以感受"。─────────────────────────────────────────────────
四、完成时的双重反馈
void _onTimerDone() {
setState(() {
_isPlaying = false;
});
_pulse.stop();
_pulse.reset();
HapticFeedback.heavyImpact(); // 强烈的"唤醒"震动
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('时间到,放松结束 🌙'),
duration: Duration(seconds: 2),
),
);
}
}
双重信号确保用户一定感知到结束:
- heavyImpact() — 强烈的触觉(即使在口袋中也能感知)
- SnackBar — 视觉确认(如果屏幕可见)
⚠️
if (mounted)检查是必须的——Timer 回调可能在 Widget 已销毁后触发(如用户快速返回首页),不检查会导致setState() called after dispose()崩溃。
五、HapticFeedback 类型详解
Flutter 提供四种触觉反馈强度:
| 类型 | 强度 | 持续时间 | E-Brufen 中的使用 |
|---|---|---|---|
lightImpact() |
轻 | ~10ms | ✅ 每分钟标记 |
mediumImpact() |
中 | ~15ms | 可用于场景切换确认 |
heavyImpact() |
重 | ~20ms | ✅ 定时结束唤醒 |
selectionClick() |
极轻 | ~5ms | 可用于 emoji 选择反馈 |
选择 lightImpact 而非 selectionClick 的原因:selectionClick 太轻,在口袋或桌面上可能感知不到。lightImpact 提供了足够的感知强度而不至于"惊醒"放松中的用户。
六、播放/停止的对称控制
void _togglePlay() {
setState(() {
_isPlaying = !_isPlaying;
if (_isPlaying) {
_pulse.repeat(reverse: true); // 启动脉冲动画
_startCountdown(); // 启动倒计时
} else {
_pulse.stop(); // 停止动画
_pulse.reset();
_countdown?.cancel(); // 取消倒计时
}
});
}
一对对称操作:播放时同时启动动画和 Timer,停止时同时取消两者。这确保了动画和计时永远同步。
七、资源释放
void dispose() {
_pulse.dispose(); // 释放 AnimationController
_countdown?.cancel(); // 取消 Timer(防止内存泄漏)
super.dispose();
}
释放顺序:先 dispose 动画(可能有回调引用 Timer),再 cancel Timer。两个异步资源都要清理——漏掉任何一个都会导致内存泄漏。
八、时间格式化
String _formatTime(int s) {
final m = s ~/ 60;
final sec = s % 60;
return '${m.toString().padLeft(2, '0')}:${sec.toString().padLeft(2, '0')}';
}
// 03:45, 00:30, 01:00
作者简介:E-Brufen Dev,Flutter & 鸿蒙开发者。触觉反馈设计基于 HapticFeedback API 在鸿蒙 6.0 真机上的实测体验,项目地址:AtomGit - E-Brufen。
更多推荐


所有评论(0)