Flutter for OpenHarmony 健康管理App应用实战 - 目标选择实现
做健康管理类App,第一件事就是搞清楚用户想干嘛。是想减肥?增肌?还是就想维持现状?这个选择看起来简单,但它决定了后面所有的计算逻辑——每天该吃多少卡路里、营养素怎么配比、运动量要多大,全都跟这个挂钩。所以我们把目标选择放在引导流程的第一步,用户打开App,看完启动页,紧接着就是这个页面。今天这篇文章,我们就来把这个页面从零开始撸出来。

写在前面
做健康管理类App,第一件事就是搞清楚用户想干嘛。是想减肥?增肌?还是就想维持现状?这个选择看起来简单,但它决定了后面所有的计算逻辑——每天该吃多少卡路里、营养素怎么配比、运动量要多大,全都跟这个挂钩。
所以我们把目标选择放在引导流程的第一步,用户打开App,看完启动页,紧接着就是这个页面。
今天这篇文章,我们就来把这个页面从零开始撸出来。
先想清楚要做什么
在动手写代码之前,先捋一下这个页面要实现哪些东西:
功能层面:
- 展示三个目标选项:减重、增重、保持体重
- 用户点击某个选项后,要有视觉反馈
- 选中后自动跳转到下一个页面(个人信息设置)
- 底部留个登录入口,方便老用户
交互层面:
- 点击要有即时反馈,不能让用户觉得卡
- 跳转要平滑,别太生硬
- 整体风格要清爽,毕竟是健康类App
想清楚了,开干。
搭建页面骨架
先把基本结构搭起来。因为要记录用户选了哪个目标,所以用 StatefulWidget:
class GoalPage extends StatefulWidget {
const GoalPage({super.key});
State<GoalPage> createState() => _GoalPageState();
}
这里用了 super.key 这个写法,是 Dart 2.17 之后的语法糖。以前得写 Key? key 然后 super(key: key),现在一行搞定,清爽多了。
接下来是 State 类:
class _GoalPageState extends State<GoalPage> {
int? _selectedGoal;
final List<String> _goals = [
'Lose weight',
'Gain weight',
'Maintain weight',
];
_selectedGoal 用可空的 int? 类型,初始值是 null。为啥不用 -1 或者 0?因为 null 语义更清晰——没选就是没选,不用去猜 -1 到底是没选还是选了第一个。
三个目标用 List<String> 存着,后面要传给下一个页面用。你可能会问,为啥不用枚举?其实用枚举也行,但这里字符串够用了,而且后面显示的时候直接拿来用,不用再转一道。
处理用户的选择
用户点了某个按钮之后要干嘛?两件事:更新UI 和 跳转页面。
void _onGoalSelected(int index) {
setState(() => _selectedGoal = index);
Future.delayed(const Duration(milliseconds: 300), () {
if (mounted) {
// 跳转逻辑
}
});
}
先说 setState,这个没啥好说的,更新选中状态,触发重绘,让按钮样式变一下。
重点是那个 Future.delayed。为啥要延迟 300 毫秒?
用户体验的小心机:如果点完立刻跳转,用户根本看不到自己选了啥,会有种"我点了吗?"的困惑。延迟一下,让用户看到按钮变色、阴影变大,心里踏实了,再跳转。
mounted 检查是个好习惯。万一用户在这 300 毫秒内按了返回键,页面已经销毁了,再执行导航代码就会报错。Flutter 开发中这种坑踩多了就知道了。
页面跳转的讲究
跳转代码长这样:
Navigator.of(context).pushReplacement(
PageRouteBuilder(
pageBuilder: (_, __, ___) => ProfileSetupPage(goal: _goals[index]),
transitionDuration: const Duration(milliseconds: 400),
transitionsBuilder: (_, animation, __, child) {
return FadeTransition(opacity: animation, child: child);
},
),
);
这里有几个点值得说说。
为啥用 pushReplacement 而不是 push?
push 是把新页面压到栈顶,原来的页面还在下面。用户按返回键会回到目标选择页。但这不对啊,用户都选完目标了,干嘛还让他回来重选?
pushReplacement 会把当前页面从栈里移除,用新页面替换掉。这样用户按返回键就直接退出App了(或者回到启动页,取决于你的导航栈结构)。
为啥要自定义过渡动画?
Flutter 默认的页面切换是从右往左滑入,Material Design 风格。但目标选择页到个人信息页,逻辑上是"确认选择"而不是"进入下一级",用淡入淡出更合适。
PageRouteBuilder 让我们可以自定义过渡效果。transitionsBuilder 里用 FadeTransition 实现淡入,时长 400 毫秒,不快不慢刚刚好。
那三个下划线 _ 是 Dart 的惯例写法,意思是"这个参数我不用"。比写 context, animation, secondaryAnimation 然后不用它们要干净。
构建页面UI
整体结构
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.white,
body: SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 32),
child: Column(
children: [
// 内容
],
),
),
),
);
}
SafeArea 是个好东西,自动避开刘海屏、底部手势条这些系统UI。不加的话,内容可能会被遮住一部分。
水平方向留 32 像素的边距,这个数值是试出来的。太小显得挤,太大浪费空间。32 在大多数手机上看起来都比较舒服。
标题部分
const Spacer(flex: 2),
const Text(
"What's your goal?",
style: TextStyle(
fontSize: 28,
fontWeight: FontWeight.bold,
color: AppColors.dark,
),
),
const Spacer(flex: 2),
Spacer 是个弹性空间,配合 flex 参数可以控制占比。这里标题上下各放一个 flex: 2 的 Spacer,意思是上下空间相等。
为啥不用 SizedBox(height: xxx)?因为不同手机屏幕高度不一样,写死数值在小屏手机上可能会溢出,在大屏手机上又显得空旷。用 Spacer 让它自适应,省心。
按钮列表
...List.generate(_goals.length, (index) {
return Padding(
padding: const EdgeInsets.only(bottom: 20),
child: _GoalButton(
text: _goals[index],
isSelected: _selectedGoal == index,
colorIndex: index,
onTap: () => _onGoalSelected(index),
),
);
}),
const Spacer(flex: 3),
List.generate 根据目标数量动态生成按钮。前面那三个点 ... 是展开操作符,把列表里的元素一个个铺开放进 Column 的 children 里。
不用展开操作符的话,你得这么写:
children: [
Spacer(),
Text(...),
Spacer(),
_GoalButton(...),
_GoalButton(...),
_GoalButton(...),
Spacer(),
]
用了展开操作符,代码更简洁,而且以后要加第四个目标,改 _goals 列表就行,不用动 UI 代码。
底部的 Spacer(flex: 3) 比上面的大,让整体内容偏上一点,视觉上更舒服。
底部登录入口
老用户可能已经有账号了,给他们留个入口:
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'Already have an account? ',
style: TextStyle(
fontSize: 14,
color: Colors.grey.shade600,
),
),
GestureDetector(
onTap: () {
// 跳转登录页
},
child: const Text(
'Login',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: AppColors.primary,
decoration: TextDecoration.underline,
),
),
),
],
),
const SizedBox(height: 40),
这里用 GestureDetector 而不是 TextButton,是因为我们只想让"Login"这个词可点击,不想要按钮的默认样式(比如点击时的水波纹效果)。
TextDecoration.underline 给文字加下划线,这是链接的通用视觉暗示,用户一看就知道能点。
底部留 40 像素的空间,别让内容贴着屏幕底边,看着难受。
目标按钮组件
按钮单独抽成组件,代码更清晰:
class _GoalButton extends StatelessWidget {
final String text;
final bool isSelected;
final int colorIndex;
final VoidCallback onTap;
const _GoalButton({
required this.text,
required this.isSelected,
required this.colorIndex,
required this.onTap,
});
类名前面的下划线 _ 表示这是私有类,只能在当前文件里用。目标按钮这种东西,别的页面用不着,没必要暴露出去。
四个参数都用 required 标记,强制调用方传值。这样万一漏传了,编译时就能发现,不用等到运行时才报错。
渐变色配置
三个按钮用不同深浅的绿色,从上到下逐渐加深:
final gradients = [
const [Color(0xFF7DD8C7), Color(0xFF4ECDC4)], // 浅绿
const [Color(0xFF4ECDC4), Color(0xFF2E9E8F)], // 中绿
const [Color(0xFF2E9E8F), Color(0xFF1A7A6C)], // 深绿
];
为啥用渐变而不是纯色?质感。纯色按钮看起来平,渐变有光影感,更有层次。
颜色从浅到深排列,视觉上有引导作用。用户的视线自然会从上往下扫,颜色变化强化了这个流向。
按钮容器
return GestureDetector(
onTap: onTap,
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
width: double.infinity,
height: 56,
decoration: BoxDecoration(
gradient: LinearGradient(
colors: gradients[colorIndex],
begin: Alignment.centerLeft,
end: Alignment.centerRight,
),
borderRadius: BorderRadius.circular(28),
AnimatedContainer 是 Flutter 的隐式动画组件。只要它的属性变了(比如阴影大小),就会自动做过渡动画,不用手动写 AnimationController 那一套。
width: double.infinity 让按钮撑满父容器宽度。height: 56 是 Material Design 推荐的按钮高度,手指点起来刚好。
圆角 28 像素,正好是高度的一半,形成胶囊形状。这种形状比直角矩形更友好,没有攻击性。
阴影效果
boxShadow: [
BoxShadow(
color: gradients[colorIndex][0].withOpacity(isSelected ? 0.5 : 0.3),
blurRadius: isSelected ? 16 : 8,
offset: const Offset(0, 4),
),
],
阴影颜色跟按钮颜色一致,这样看起来像是按钮本身在发光,而不是悬浮在一个灰色阴影上。
选中时阴影更大更明显(blurRadius: 16,透明度 0.5),未选中时小一点淡一点(blurRadius: 8,透明度 0.3)。这个变化配合 AnimatedContainer 的过渡动画,点击时会有"按钮亮起来"的感觉。
offset: Offset(0, 4) 让阴影往下偏移 4 像素,模拟光源从上方照下来的效果。
按钮文字
child: Center(
child: Text(
text,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: Colors.white,
),
),
),
白色文字在绿色背景上对比度够高,看得清楚。字号 16,不大不小。fontWeight: FontWeight.w600 是半粗体,比普通字体重一点,但又不像 bold 那么粗,刚好。
完整代码结构
把上面的代码组装起来,文件结构大概是这样:
lib/
pages/
onboarding/
goal_page.dart <- 我们写的这个
profile_setup_page.dart <- 下一个页面
utils/
colors.dart <- 颜色常量
colors.dart 里定义了 AppColors.primary 和 AppColors.dark 这些常量,全局统一用,改起来方便。
踩过的坑
写这个页面的时候踩了几个坑,记录一下:
1. 点击没反应
一开始用 InkWell 包裹按钮,发现点击没有水波纹效果。查了半天发现是因为 Container 有 decoration,把 InkWell 的效果挡住了。后来换成 GestureDetector,反正我们也不需要水波纹。
2. 跳转后按返回键回到目标选择页
最开始用的 Navigator.push,后来发现逻辑不对,改成 pushReplacement。
3. 延迟跳转时页面已销毁
没加 mounted 检查,用户快速点击返回键时会报错。加上检查就好了。
小结
目标选择页面代码量不大,但有几个设计上的考量:
- 用渐变色按钮提升质感
- 选中后延迟跳转,给用户视觉反馈的时间
- 自定义页面过渡动画,体验更流畅
- 底部保留登录入口,照顾老用户
下一篇我们来实现个人信息设置页面,那个页面有意思,要做一个可滑动的标尺来输入体重,还要支持公斤、磅、英石三种单位切换。
欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net
更多推荐
所有评论(0)