请添加图片描述

写在前面

做过App的朋友都知道,启动页是用户打开应用后看到的第一个画面。虽然它停留的时间很短,但承担的任务却不少——展示品牌、预加载数据、判断用户状态然后跳转到对应页面。

我们这个健康管理App的启动页,除了基本功能外,还加了一些有意思的东西:用Canvas画了一整套健康饮食主题的背景插画,包括一个正在吃饭的人物、各种食材和餐具。这样做的好处是不用引入图片资源,包体积小,而且矢量图形在任何分辨率下都清晰。

废话不多说,直接开干。


先把依赖导进来

import 'package:flutter/material.dart';
import 'dart:math' as math;
import 'package:provider/provider.dart';

dart:math 起了个别名叫 math,因为后面画背景的时候要用到 pi 这个常量,加个前缀能避免跟其他库冲突。provider 是我们用来做状态管理的,启动页需要读取用户数据来决定往哪跳。

再导入项目里自己写的文件:

import '../../utils/colors.dart';
import '../../providers/user_provider.dart';
import '../onboarding/goal_page.dart';
import '../tab/tab_page.dart';

colors.dart 定义了App的主题色,user_provider.dart 管理用户相关的数据和状态,GoalPage 是新用户引导流程的第一页,TabPage 是主页面。


创建页面骨架

启动页需要播放动画,所以用 StatefulWidget

class SplashPage extends StatefulWidget {
  const SplashPage({super.key});

  
  State<SplashPage> createState() => _SplashPageState();
}

这里用了 super.key 这个Dart 2.17之后的简化写法,比以前 Key? key 然后 super(key: key) 简洁不少。

State类需要混入 SingleTickerProviderStateMixin

class _SplashPageState extends State<SplashPage> with SingleTickerProviderStateMixin {
  late AnimationController _controller;
  late Animation<double> _fadeAnimation;

为啥要混入这个?因为 AnimationController 需要一个 TickerProvider 来驱动动画帧的刷新。你可以把Ticker理解成一个节拍器,每一帧都会敲一下,动画才能动起来。

late 关键字的意思是"我保证用之前会初始化",这样就不用把变量声明成可空类型了。


初始化动画

initState 里配置动画控制器:


void initState() {
  super.initState();
  _controller = AnimationController(
    vsync: this, 
    duration: const Duration(milliseconds: 1500)
  );

vsync: this 把当前State作为TickerProvider传进去,动画时长1.5秒。

接着创建淡入动画:

  _fadeAnimation = Tween<double>(begin: 0.0, end: 1.0).animate(
    CurvedAnimation(parent: _controller, curve: Curves.easeIn),
  );

Tween 定义了数值从0到1的变化,对应透明度从完全透明到完全不透明。CurvedAnimation 加了个 easeIn 缓动曲线,让动画先慢后快,看起来更自然。

最后启动动画和导航逻辑:

  _controller.forward();
  _navigateToNext();
}

forward() 让动画正向播放。


页面跳转的核心逻辑

这部分决定了用户接下来看到什么:

void _navigateToNext() async {
  await Future.delayed(const Duration(seconds: 3));
  if (!mounted) return;

先等3秒让用户看看启动动画。mounted 检查非常重要——万一用户在这3秒内退出了App,页面已经销毁了,再执行后面的代码就会报错。这是Flutter开发中很容易踩的坑。

然后获取用户数据:

  final userProvider = context.read<UserProvider>();

context.read 是Provider提供的便捷方法,用来获取状态但不监听变化。启动页只需要读一次数据,不需要监听。

等待数据加载完成:

  while (userProvider.isLoading) {
    await Future.delayed(const Duration(milliseconds: 100));
    if (!mounted) return;
  }

while 循环每100毫秒检查一次加载状态。你可能会问为啥不用 FutureBuilder?因为启动页场景比较特殊,我们只需要等一次,用循环反而更直观,代码也更好控制。

根据用户状态决定跳转目标:

  final nextPage = userProvider.onboardingCompleted 
      ? const TabPage() 
      : const GoalPage();

如果用户已经完成了新手引导(onboardingCompleted 为true),直接进主页;否则进引导流程。这个逻辑在大部分App里都差不多。


自定义页面切换动画

Flutter默认的页面切换是从右往左滑入,但启动页用淡入淡出更合适:

  Navigator.of(context).pushReplacement(
    PageRouteBuilder(
      pageBuilder: (_, __, ___) => nextPage,
      transitionDuration: const Duration(milliseconds: 500),
      transitionsBuilder: (_, animation, __, child) {
        return FadeTransition(opacity: animation, child: child);
      },
    ),
  );
}

pushReplacement 会把当前页面从导航栈里移除,用户按返回键就不会回到启动页了。PageRouteBuilder 让我们自定义过渡动画,这里用 FadeTransition 实现500毫秒的淡入效果。

那三个下划线 _ 是Dart的惯例写法,表示"这个参数我不用"。


释放资源

动画控制器用完要释放,不然会内存泄漏:


void dispose() {
  _controller.dispose();
  super.dispose();
}

养成习惯就好,所有Controller类的东西用完都要dispose。


构建UI

整体布局

启动页用 Stack 布局,底层是背景插画,上层是Logo和标语:


Widget build(BuildContext context) {
  final size = MediaQuery.of(context).size;
  return Scaffold(
    backgroundColor: const Color(0xFFF5F7FA),
    body: Stack(
      children: [

MediaQuery.of(context).size 获取屏幕尺寸,后面用来做响应式定位。背景色用了一个很淡的灰蓝色 #F5F7FA,看起来清爽干净。

背景插画用 CustomPaint 绘制:

        Positioned.fill(
          child: CustomPaint(
            painter: _HealthBackgroundPainter(),
          ),
        ),

Positioned.fill 让背景铺满整个屏幕。_HealthBackgroundPainter 是我们自定义的画笔类,后面会详细讲。

主要内容用 FadeTransition 包裹实现淡入效果:

        FadeTransition(
          opacity: _fadeAnimation,
          child: Center(
            child: Column(
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                SizedBox(height: size.height * 0.15),
                _buildLogo(),
                const SizedBox(height: 24),

顶部留了15%屏幕高度的空白,让Logo往上偏一点,视觉上更舒服。

标语文字

Logo下面加一句标语:

                const Text(
                  'Better diet, better life!',
                  style: TextStyle(
                    fontSize: 22,
                    fontWeight: FontWeight.w600,
                    color: Color(0xFF2E3A4D),
                  ),
                ),
                const Spacer(),
              ],
            ),
          ),
        ),
      ],
    ),
  );
}

字号22,半粗体,深灰色。Spacer() 把剩余空间撑开,让内容整体偏上。


Logo组件

Logo是一个带阴影的圆角方块,里面用Canvas画了个图案:

Widget _buildLogo() {
  return Container(
    width: 100,
    height: 100,
    decoration: BoxDecoration(
      color: AppColors.primary,
      borderRadius: BorderRadius.circular(24),
      boxShadow: [
        BoxShadow(
          color: AppColors.primary.withOpacity(0.3),
          blurRadius: 20,
          offset: const Offset(0, 10),
        ),
      ],
    ),
    child: CustomPaint(painter: _LogoPainter()),
  );
}

100x100的正方形,圆角24像素。阴影用了主题色的30%透明度,模糊半径20,向下偏移10像素,这样Logo看起来像是悬浮在页面上。


自定义Logo绘制

_LogoPainter 继承自 CustomPainter,用来画Logo里面的图案:

class _LogoPainter extends CustomPainter {
  
  void paint(Canvas canvas, Size size) {
    final paint = Paint()
      ..color = Colors.white
      ..style = PaintingStyle.stroke
      ..strokeWidth = 3
      ..strokeCap = StrokeCap.round;

    final center = Offset(size.width / 2, size.height / 2);

画笔配置成白色描边,线宽3像素,端点圆角。center 是画布中心点,后面所有坐标都基于这个点计算。

Dart的级联操作符 .. 很好用,可以连续设置同一个对象的属性,省得写一堆 paint.xxx = xxx

画叶子轮廓

用贝塞尔曲线画一个叶子形状:

    final leafPath = Path();
    leafPath.moveTo(center.dx - 15, center.dy + 15);

moveTo 把画笔移到起始点,这里是叶子的左下角。

    leafPath.quadraticBezierTo(
      center.dx - 25, center.dy - 20, 
      center.dx, center.dy - 25
    );

quadraticBezierTo 画二次贝塞尔曲线,第一个参数是控制点(决定曲线弯曲程度),第二个是终点。这条曲线从左下角画到顶部中间。

继续画右半边和底部:

    leafPath.quadraticBezierTo(
      center.dx + 25, center.dy - 20, 
      center.dx + 15, center.dy + 15
    );
    leafPath.quadraticBezierTo(
      center.dx, center.dy + 5, 
      center.dx - 15, center.dy + 15
    );
    canvas.drawPath(leafPath, paint);

三条曲线连起来就是完整的叶子轮廓。

画叶脉

在叶子里面画几条线模拟叶脉:

    canvas.drawLine(
      Offset(center.dx - 8, center.dy - 5), 
      Offset(center.dx - 8, center.dy + 20), 
      paint
    );
    canvas.drawLine(
      Offset(center.dx, center.dy - 5), 
      Offset(center.dx, center.dy + 20), 
      paint
    );
    canvas.drawLine(
      Offset(center.dx + 8, center.dy - 5), 
      Offset(center.dx + 8, center.dy + 20), 
      paint
    );

三条竖线,间隔8像素。

    canvas.drawLine(
      Offset(center.dx - 10, center.dy + 20), 
      Offset(center.dx + 10, center.dy + 20), 
      paint
    );
  }

底部加一条横线连接三条竖线,整体看起来像叉子托着叶子,暗示"健康饮食"的主题。

  
  bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
}

shouldRepaint 返回false表示这个图案是静态的,不需要重绘。


背景插画

背景上画了一整套健康饮食相关的元素,让页面更生动。

画布入口

class _HealthBackgroundPainter extends CustomPainter {
  
  void paint(Canvas canvas, Size size) {
    _drawPerson(canvas, size);
    _drawCup(canvas, Offset(size.width * 0.12, size.height * 0.52), 35);
    _drawAvocado(canvas, Offset(size.width * 0.15, size.height * 0.72), 28);
    _drawEgg(canvas, Offset(size.width * 0.08, size.height * 0.65), 18);
    _drawPlate(canvas, Offset(size.width * 0.45, size.height * 0.58), 70);
    _drawBroccoli(canvas, Offset(size.width * 0.38, size.height * 0.56), 20);
    _drawFriedEgg(canvas, Offset(size.width * 0.48, size.height * 0.52), 22);
    _drawCroissant(canvas, Offset(size.width * 0.55, size.height * 0.58), 18);
  }

画了人物、杯子、牛油果、鸡蛋、盘子、西兰花、煎蛋、牛角包。位置都用屏幕尺寸的百分比定位,这样在不同大小的屏幕上都能保持相对位置。

画人物

人物是背景的主体,分几个部分来画:

void _drawPerson(Canvas canvas, Size size) {
  final paint = Paint()..style = PaintingStyle.fill;
  
  paint.color = const Color(0xFFE8DCD0);
  final hairPath = Path();
  hairPath.addOval(Rect.fromCenter(
    center: Offset(size.width * 0.85, size.height * 0.42),
    width: 90, height: 100,
  ));
  canvas.drawPath(hairPath, paint);

头发用一个浅棕色的椭圆表示,addOval 往Path里添加椭圆。

  paint.color = const Color(0xFFFAE5D3);
  canvas.drawCircle(
    Offset(size.width * 0.82, size.height * 0.44), 
    35, 
    paint
  );

脸是一个肤色的圆,半径35像素。

  paint.color = const Color(0xFFB8D4CE);
  final bodyPath = Path();
  bodyPath.moveTo(size.width * 0.72, size.height * 0.52);
  bodyPath.quadraticBezierTo(
    size.width * 0.75, size.height * 0.65, 
    size.width * 0.68, size.height * 0.85
  );
  bodyPath.lineTo(size.width * 1.1, size.height * 0.85);
  bodyPath.lineTo(size.width * 1.05, size.height * 0.52);
  bodyPath.close();
  canvas.drawPath(bodyPath, paint);

身体用浅绿色,形状是曲线和直线组合的不规则四边形。close() 自动把路径闭合。

手臂要画成伸向盘子的姿势:

  paint.color = const Color(0xFFFAE5D3);
  final armPath = Path();
  armPath.moveTo(size.width * 0.72, size.height * 0.55);
  armPath.quadraticBezierTo(
    size.width * 0.55, size.height * 0.58, 
    size.width * 0.48, size.height * 0.52
  );
  armPath.quadraticBezierTo(
    size.width * 0.52, size.height * 0.48, 
    size.width * 0.58, size.height * 0.50
  );
  armPath.quadraticBezierTo(
    size.width * 0.65, size.height * 0.52, 
    size.width * 0.72, size.height * 0.52
  );
  armPath.close();
  canvas.drawPath(armPath, paint);
  
  canvas.drawCircle(
    Offset(size.width * 0.48, size.height * 0.51), 
    12, 
    paint
  );
}

手臂是弯曲的形状,末端加个小圆表示手,整体看起来像人物正在拿餐具吃东西。

画杯子

杯子是梯形加把手:

void _drawCup(Canvas canvas, Offset center, double radius) {
  final paint = Paint()..style = PaintingStyle.fill;
  
  paint.color = const Color(0xFF7DB8A8);
  final cupPath = Path();
  cupPath.moveTo(center.dx - radius * 0.7, center.dy - radius);
  cupPath.lineTo(center.dx - radius * 0.5, center.dy + radius);
  cupPath.lineTo(center.dx + radius * 0.5, center.dy + radius);
  cupPath.lineTo(center.dx + radius * 0.7, center.dy - radius);
  cupPath.close();
  canvas.drawPath(cupPath, paint);

四个点连成上宽下窄的梯形,颜色是清新的青绿色。

把手用 drawArc 画半圆弧:

  paint.style = PaintingStyle.stroke;
  paint.strokeWidth = 4;
  canvas.drawArc(
    Rect.fromCenter(
      center: Offset(center.dx + radius * 0.8, center.dy), 
      width: radius * 0.6, 
      height: radius
    ),
    -math.pi / 2, 
    math.pi, 
    false, 
    paint,
  );

起始角度 -π/2 是12点钟方向,扫过角度 π 就是半圆。

杯子上面加几缕热气:

  paint.color = const Color(0xFFB8D4CE);
  paint.strokeWidth = 2;
  for (int i = 0; i < 3; i++) {
    final startX = center.dx - radius * 0.3 + i * radius * 0.3;
    final path = Path();
    path.moveTo(startX, center.dy - radius * 1.2);
    path.quadraticBezierTo(
      startX + 5, center.dy - radius * 1.5, 
      startX, center.dy - radius * 1.8
    );
    canvas.drawPath(path, paint);
  }
}

用循环画三条波浪线,每条都是S形曲线。这种小细节能让画面更有生活气息。

画牛油果

牛油果是健康饮食的代表食材:

void _drawAvocado(Canvas canvas, Offset center, double radius) {
  final paint = Paint()..style = PaintingStyle.fill;
  
  paint.color = const Color(0xFF8BC34A);
  final outerPath = Path();
  outerPath.addOval(Rect.fromCenter(
    center: center, 
    width: radius * 2, 
    height: radius * 2.5
  ));
  canvas.drawPath(outerPath, paint);

外皮是绿色椭圆,高度比宽度大一点,符合牛油果的形状。

  paint.color = const Color(0xFFCDDC39);
  canvas.drawOval(Rect.fromCenter(
    center: center, 
    width: radius * 1.4, 
    height: radius * 1.8
  ), paint);
  
  paint.color = const Color(0xFF795548);
  canvas.drawCircle(
    Offset(center.dx, center.dy + radius * 0.2), 
    radius * 0.5, 
    paint
  );
}

果肉是浅黄绿色,果核是棕色圆。三层叠加就是切开的牛油果。

画煎蛋

void _drawFriedEgg(Canvas canvas, Offset center, double radius) {
  final paint = Paint()..style = PaintingStyle.fill;
  
  paint.color = Colors.white;
  final whitePath = Path();
  whitePath.addOval(Rect.fromCenter(
    center: center, 
    width: radius * 2.2, 
    height: radius * 1.8
  ));
  canvas.drawPath(whitePath, paint);

蛋白是白色椭圆,稍微扁一点。

  paint.color = const Color(0xFFFFB74D);
  canvas.drawCircle(center, radius * 0.5, paint);
  
  paint.color = const Color(0xFFFFE082);
  canvas.drawCircle(
    Offset(center.dx - radius * 0.15, center.dy - radius * 0.15), 
    radius * 0.15, 
    paint
  );
}

蛋黄是橙黄色圆,左上角加个小亮点作为高光,让蛋黄看起来更有光泽。

画西兰花

void _drawBroccoli(Canvas canvas, Offset center, double radius) {
  final paint = Paint()..style = PaintingStyle.fill;
  
  paint.color = const Color(0xFF7CB342);
  canvas.drawRect(Rect.fromCenter(
    center: Offset(center.dx, center.dy + radius * 0.8),
    width: radius * 0.4, 
    height: radius,
  ), paint);

茎是浅绿色矩形。

  paint.color = const Color(0xFF4CAF50);
  canvas.drawCircle(center, radius * 0.6, paint);
  canvas.drawCircle(
    Offset(center.dx - radius * 0.5, center.dy + radius * 0.2), 
    radius * 0.5, 
    paint
  );
  canvas.drawCircle(
    Offset(center.dx + radius * 0.5, center.dy + radius * 0.2), 
    radius * 0.5, 
    paint
  );
  canvas.drawCircle(
    Offset(center.dx, center.dy + radius * 0.4), 
    radius * 0.4, 
    paint
  );
}

花冠用四个深绿色圆叠加,模拟西兰花蓬松的样子。

画盘子

void _drawPlate(Canvas canvas, Offset center, double radius) {
  final paint = Paint()..style = PaintingStyle.fill;
  
  paint.color = const Color(0xFFE0E0E0).withOpacity(0.5);
  canvas.drawOval(Rect.fromCenter(
    center: Offset(center.dx, center.dy + radius * 0.3),
    width: radius * 2.2, 
    height: radius * 0.8,
  ), paint);

先画盘子阴影,半透明灰色椭圆,位置往下偏一点。

  paint.color = Colors.white;
  canvas.drawOval(Rect.fromCenter(
    center: center, 
    width: radius * 2, 
    height: radius * 0.7
  ), paint);
  
  paint.color = const Color(0xFFF5F5F5);
  paint.style = PaintingStyle.stroke;
  paint.strokeWidth = 3;
  canvas.drawOval(Rect.fromCenter(
    center: center, 
    width: radius * 2, 
    height: radius * 0.7
  ), paint);
}

盘子本体是白色椭圆,边缘加一圈浅灰色描边。

画牛角包

void _drawCroissant(Canvas canvas, Offset center, double radius) {
  final paint = Paint()..style = PaintingStyle.fill;
  paint.color = const Color(0xFFFFCC80);
  
  final path = Path();
  path.moveTo(center.dx - radius, center.dy);
  path.quadraticBezierTo(
    center.dx - radius * 0.5, center.dy - radius * 0.8, 
    center.dx, center.dy - radius * 0.3
  );
  path.quadraticBezierTo(
    center.dx + radius * 0.5, center.dy - radius * 0.8, 
    center.dx + radius, center.dy
  );
  path.quadraticBezierTo(
    center.dx + radius * 0.5, center.dy + radius * 0.5, 
    center.dx, center.dy + radius * 0.3
  );
  path.quadraticBezierTo(
    center.dx - radius * 0.5, center.dy + radius * 0.5, 
    center.dx - radius, center.dy
  );
  canvas.drawPath(path, paint);

牛角包用四条贝塞尔曲线围成月牙形,颜色是金黄色。

  paint.color = const Color(0xFFE6A23C);
  paint.style = PaintingStyle.stroke;
  paint.strokeWidth = 1.5;
  for (int i = 0; i < 3; i++) {
    final y = center.dy - radius * 0.2 + i * radius * 0.2;
    canvas.drawLine(
      Offset(center.dx - radius * 0.5, y), 
      Offset(center.dx + radius * 0.5, y), 
      paint
    );
  }
}

加三条横线作为纹理,让牛角包看起来更真实。

最后别忘了 shouldRepaint

  
  bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
}

背景是静态的,不需要重绘。


小结

这篇文章我们实现了一个完整的启动页,用Canvas画了一整套健康饮食主题的背景插画。虽然代码量不少,但每个部分都不复杂,主要就是 CustomPainter 的使用。

掌握了Canvas绑定,什么图案都能画出来,而且不用引入图片资源,包体积小,矢量图形在任何分辨率下都清晰。


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

Logo

开源鸿蒙跨平台开发社区汇聚开发者与厂商,共建“一次开发,多端部署”的开源生态,致力于降低跨端开发门槛,推动万物智联创新。

更多推荐