请添加图片描述

写在前面

做健康管理类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.primaryAppColors.dark 这些常量,全局统一用,改起来方便。


踩过的坑

写这个页面的时候踩了几个坑,记录一下:

1. 点击没反应

一开始用 InkWell 包裹按钮,发现点击没有水波纹效果。查了半天发现是因为 Containerdecoration,把 InkWell 的效果挡住了。后来换成 GestureDetector,反正我们也不需要水波纹。

2. 跳转后按返回键回到目标选择页

最开始用的 Navigator.push,后来发现逻辑不对,改成 pushReplacement

3. 延迟跳转时页面已销毁

没加 mounted 检查,用户快速点击返回键时会报错。加上检查就好了。


小结

目标选择页面代码量不大,但有几个设计上的考量:

  • 用渐变色按钮提升质感
  • 选中后延迟跳转,给用户视觉反馈的时间
  • 自定义页面过渡动画,体验更流畅
  • 底部保留登录入口,照顾老用户

下一篇我们来实现个人信息设置页面,那个页面有意思,要做一个可滑动的标尺来输入体重,还要支持公斤、磅、英石三种单位切换。


欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net

Logo

腾讯云面向开发者汇聚海量精品云计算使用和开发经验,营造开放的云计算技术生态圈。

更多推荐