Flutter for OpenHarmony 健康管理App应用实战 - 启动页实现
做过App的朋友都知道,启动页是用户打开应用后看到的第一个画面。虽然它停留的时间很短,但承担的任务却不少——展示品牌、预加载数据、判断用户状态然后跳转到对应页面。我们这个健康管理App的启动页,除了基本功能外,还加了一些有意思的东西:用Canvas画了一整套健康饮食主题的背景插画,包括一个正在吃饭的人物、各种食材和餐具。这样做的好处是不用引入图片资源,包体积小,而且矢量图形在任何分辨率下都清晰。废

写在前面
做过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
更多推荐
所有评论(0)