Flutter for OpenHarmony 打造沉浸式呼吸引导应用:用动画疗愈身心
在快节奏的现代生活中,呼吸------这一最自然却常被忽视的生命节律------正成为连接身心、缓解焦虑的关键工具。科学研究表明,有意识的深呼吸练习能有效降低心率、减轻压力、提升专注力。然而,许多人虽知其益,却苦于缺乏引导而难以坚持。
🌐 加入社区 欢迎加入 开源鸿蒙跨平台开发者社区 ,获取最新资源与技术支持: 👉 开源鸿蒙跨平台开发者社区
完整效果



一、设计理念:让呼吸"可见"
该应用的核心思想是 "可视化呼吸":
- 中心呼吸球:随呼吸节奏放大(吸气)与缩小(呼气),模拟肺部的扩张与收缩;
- 动态色彩系统:每个阶段使用不同颜色,强化心理暗示;
- 实时状态反馈:顶部显示循环次数,底部指示当前阶段与操作指令;
- 进度条:直观展示四阶段循环的进程。
💡 目标:用户无需思考"现在该做什么",只需跟随视觉引导,自然进入呼吸节奏。
二、呼吸训练模型:4-7-8 的变体
虽然代码中未显式写出各阶段时长,但从 AnimationController(duration: const Duration(seconds: 24)) 和四阶段均分可推断,每阶段约 6 秒 ,形成一个 6-6-6-6 的对称循环:
- 吸气(Inhale):6 秒,缓慢深吸;
- 屏息(Hold):6 秒,保持气息;
- 呼气(Exhale):6 秒,缓慢深呼;
- 空息(Hold) :6 秒,保持空腔。

🌿 这种对称设计简化了认知负担,适合初学者建立呼吸节奏感。
三、核心技术实现
1. 动画驱动:AnimationController + CurvedAnimation
dart
_animationController = AnimationController(
duration: const Duration(seconds: 24), // 一个完整循环24秒
vsync: this,
);
_breathAnimation = Tween<double>(begin: 0.3, end: 1.0).animate(
CurvedAnimation(parent: _animationController, curve: Curves.easeInOut),
);

Tween<double>:将动画值从0.3(最小缩放)映射到1.0(最大缩放);Curves.easeInOut:使呼吸球的膨胀/收缩更符合自然呼吸的加速度变化(非线性);addListener:监听动画值变化,实时计算当前所处阶段。
2. 阶段识别:从连续动画到离散状态
dart
_breathAnimation.addListener(() {
setState(() {
_currentPhase = (_animationController.value * 4).floor();
if (_currentPhase >= 4) _currentPhase = 3;
});
});
- 将
[0, 1)的动画值乘以 4,得到[0, 4)的区间;floor()取整后得到0, 1, 2, 3,分别对应四个阶段;- 边界处理确保
_currentPhase永远不会越界。
3. 循环控制:自动重置与计数
dart
..addStatusListener((status) {
if (status == AnimationStatus.completed) {
setState(() { _cycleCount++; _currentPhase = 0; });
_animationController.reset();
if (_isRunning) _animationController.forward(); // 自动开始下一循环
}
});
- 完成一次循环后自动重置并递增计数器;
- 若训练仍在进行,则无缝衔接下一轮,实现"无限循环"。
四、UI/UX 设计亮点
1. 色彩心理学应用
| 阶段 | 颜色 | 心理暗示 |
|---|---|---|
| 吸气 | 🟢 绿色 (green.shade400) |
生长、能量、吸入生命力 |
| 屏息(吸后) | 🟡 琥珀色 (amber.shade300) |
温暖、稳定、蓄势待发 |
| 呼气 | 🔴 红色 (red.shade400) |
释放、排出、代谢废物 |
| 屏息(呼后) | 🔵 蓝色 (blue.shade300) |
冷静、空灵、内在平静 |
每种颜色不仅用于中心球,还同步应用于:
- 背景渐变
- 阶段指示器文字
- 指导文字
- 进度条
2. 多层次视觉反馈
- 背景脉动圆:大范围柔和光晕,营造氛围;
- 中心呼吸球:高对比度、带发光阴影,成为视觉焦点;
- 图标指引:↑(吸)、⏸(屏)、↓(呼),直观易懂;
- 底部状态栏:明确告知当前动作;
- 顶部状态:显示整体进度(循环次数)和运行状态。
3. 交互设计
- 主按钮:绿色"开始" / 红色"暂停",符合直觉;
- 重置按钮:独立于主流程,方便重新开始;
- 运行状态标签:顶部右侧实时显示"进行中"或"已暂停",配色与状态一致。
五、代码结构与健壮性
with TickerProviderStateMixin:为AnimationController提供vsync,防止后台动画消耗资源;dispose():正确释放动画控制器,避免内存泄漏;setState()优化:仅在必要时更新 UI,保证流畅性;- 深色主题 :
Color(0xFF0F172A)营造宁静、专注的冥想环境,减少视觉刺激。
六、应用场景与扩展可能
适用场景
- 日常减压:工作间隙快速放松;
- 睡前助眠:帮助大脑从活跃状态过渡到平静;
- 冥想辅助:作为正念练习的入门工具;
- 呼吸训练:提升肺活量与呼吸控制能力。
可扩展方向
- 自定义节奏:允许用户设置各阶段时长(如经典的 4-7-8 法);
- 声音引导:加入白噪音或提示音;
- 数据记录:统计每日训练时长与循环次数;
- 多模式:提供"能量唤醒"、"深度放松"等不同配色与节奏方案。
七、结语:技术为身心服务
这段代码远不止是一个动画演示,它体现了 "科技向善" 的理念------用精巧的技术手段,服务于最基础的人类需求:呼吸。
完整代码
bash
import 'package:flutter/material.dart';
void main() {
runApp(const BreathTrainerApp());
}
class BreathTrainerApp extends StatelessWidget {
const BreathTrainerApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
debugShowCheckedModeBanner: false,
title: '🌬️ 呼吸引导',
theme: ThemeData(
brightness: Brightness.dark,
scaffoldBackgroundColor: const Color(0xFF0F172A),
primarySwatch: Colors.blue,
textTheme: const TextTheme(
displayLarge:
TextStyle(fontFamily: 'Arial', fontWeight: FontWeight.w300),
),
),
home: const BreathTrainerScreen(),
);
}
}
class BreathTrainerScreen extends StatefulWidget {
const BreathTrainerScreen({super.key});
@override
State<BreathTrainerScreen> createState() => _BreathTrainerScreenState();
}
class _BreathTrainerScreenState extends State<BreathTrainerScreen>
with TickerProviderStateMixin {
late AnimationController _animationController;
late Animation<double> _breathAnimation;
int _currentPhase = 0; // 0: inhale, 1: hold, 2: exhale, 3: hold
bool _isRunning = false;
int _cycleCount = 0;
final List<String> _phases = ['吸气', '屏息', '呼气', '屏息'];
final List<Color> _phaseColors = [
Colors.green.shade400,
Colors.amber.shade300,
Colors.red.shade400,
Colors.blue.shade300,
];
final List<String> _instructions = [
'缓慢深吸气...',
'保持呼吸...',
'缓慢深呼气...',
'保持空息...',
];
final List<IconData> _phaseIcons = [
Icons.arrow_upward,
Icons.pause_circle_outline,
Icons.arrow_downward,
Icons.pause_circle_outline,
];
@override
void initState() {
super.initState();
_animationController = AnimationController(
duration: const Duration(seconds: 24),
vsync: this,
)..addStatusListener((status) {
if (status == AnimationStatus.completed) {
setState(() {
_cycleCount++;
_currentPhase = 0;
});
_animationController.reset();
if (_isRunning) _animationController.forward();
}
});
_breathAnimation = Tween<double>(begin: 0.3, end: 1.0).animate(
CurvedAnimation(parent: _animationController, curve: Curves.easeInOut),
)..addListener(() {
setState(() {
_currentPhase = (_animationController.value * 4).floor();
if (_currentPhase >= 4) _currentPhase = 3;
});
});
}
@override
void dispose() {
_animationController.dispose();
super.dispose();
}
void _toggleTraining() {
setState(() {
_isRunning = !_isRunning;
if (_isRunning) {
_animationController.forward();
} else {
_animationController.stop();
}
});
}
void _resetTraining() {
setState(() {
_isRunning = false;
_cycleCount = 0;
_currentPhase = 0;
_animationController.reset();
});
}
@override
Widget build(BuildContext context) {
final currentColor = _phaseColors[_currentPhase];
final safeAreaHeight = MediaQuery.of(context).padding.top;
return Scaffold(
body: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.black87,
Color.lerp(Colors.black87, currentColor.withOpacity(0.15), 0.3)!,
Color.lerp(Colors.black87, currentColor.withOpacity(0.05), 0.6)!,
Colors.black87,
],
),
),
child: SafeArea(
child: Column(
children: [
// 顶部状态栏
Padding(
padding: EdgeInsets.only(
top: safeAreaHeight + 8, left: 20, right: 20),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'🌬️ 呼吸引导',
style: TextStyle(
fontSize: 28, fontWeight: FontWeight.bold),
),
const SizedBox(height: 4),
Text(
'${_cycleCount} 次循环',
style:
const TextStyle(fontSize: 16, color: Colors.grey),
),
],
),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 16, vertical: 6),
decoration: BoxDecoration(
color: _isRunning
? Colors.green.withOpacity(0.2)
: Colors.red.withOpacity(0.2),
borderRadius: BorderRadius.circular(20),
),
child: Row(
children: [
Icon(
_isRunning ? Icons.play_arrow : Icons.stop,
size: 18,
color: _isRunning ? Colors.green : Colors.red,
),
const SizedBox(width: 4),
Text(
_isRunning ? '进行中' : '已暂停',
style: TextStyle(
fontSize: 14,
color: _isRunning ? Colors.green : Colors.red,
fontWeight: FontWeight.w600,
),
),
],
),
),
],
),
),
const SizedBox(height: 30),
// 呼吸可视化区域
Expanded(
child: Stack(
alignment: Alignment.center,
children: [
// 背景脉动圆
AnimatedBuilder(
animation: _breathAnimation,
builder: (context, child) {
return Container(
width: 320 * _breathAnimation.value,
height: 320 * _breathAnimation.value,
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: RadialGradient(
colors: [
currentColor.withOpacity(0.15),
currentColor.withOpacity(0.05),
],
),
),
);
},
),
// 中心呼吸球
AnimatedBuilder(
animation: _breathAnimation,
builder: (context, child) {
return Container(
width: 180 * _breathAnimation.value,
height: 180 * _breathAnimation.value,
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: RadialGradient(
colors: [
currentColor.withOpacity(0.9),
currentColor.withOpacity(0.7),
],
),
boxShadow: [
BoxShadow(
color: currentColor.withOpacity(0.4),
blurRadius: 30,
spreadRadius: 10,
),
],
),
child: Center(
child: Icon(
_phaseIcons[_currentPhase],
size: 60 * _breathAnimation.value,
color: Colors.white,
),
),
);
},
),
// 阶段指示器
Positioned(
bottom: 40,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 24, vertical: 12),
decoration: BoxDecoration(
color: Colors.black87.withOpacity(0.7),
borderRadius: BorderRadius.circular(30),
border:
Border.all(color: currentColor.withOpacity(0.5)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
_phaseIcons[_currentPhase],
color: currentColor,
size: 24,
),
const SizedBox(width: 12),
Text(
_phases[_currentPhase],
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: currentColor,
),
),
],
),
),
),
],
),
),
// 指导文字
Padding(
padding: const EdgeInsets.only(bottom: 24),
child: Text(
_instructions[_currentPhase],
style: TextStyle(
fontSize: 22,
fontWeight: FontWeight.w300,
color: currentColor,
height: 1.5,
),
textAlign: TextAlign.center,
),
),
// 控制按钮
Container(
padding:
const EdgeInsets.symmetric(horizontal: 24, vertical: 20),
decoration: BoxDecoration(
color: Colors.black87.withOpacity(0.8),
borderRadius:
const BorderRadius.vertical(top: Radius.circular(30)),
),
child: Column(
children: [
// 进度指示器
Row(
children: List.generate(4, (index) {
final isActive = index == _currentPhase;
return Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: Container(
height: 8,
decoration: BoxDecoration(
color: isActive
? _phaseColors[index]
: _phaseColors[index].withOpacity(0.3),
borderRadius: BorderRadius.circular(4),
),
),
),
);
}),
),
const SizedBox(height: 24),
// 主控制按钮
Row(
children: [
Expanded(
child: OutlinedButton.icon(
onPressed: _resetTraining,
icon: const Icon(Icons.refresh, size: 20),
label: const Text('重置',
style: TextStyle(fontSize: 16)),
style: OutlinedButton.styleFrom(
foregroundColor: Colors.grey,
side: const BorderSide(color: Colors.grey),
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16)),
),
),
),
const SizedBox(width: 16),
Expanded(
flex: 2,
child: ElevatedButton.icon(
onPressed: _toggleTraining,
icon: Icon(
_isRunning ? Icons.pause : Icons.play_arrow,
size: 28,
),
label: Text(
_isRunning ? '暂停训练' : '开始训练',
style: const TextStyle(
fontSize: 18, fontWeight: FontWeight.bold),
),
style: ElevatedButton.styleFrom(
backgroundColor:
_isRunning ? Colors.red : Colors.green,
padding: const EdgeInsets.symmetric(vertical: 18),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20)),
elevation: 4,
),
),
),
],
),
],
),
),
],
),
),
),
);
}
}