Flutter for OpenHarmony 健康管理App应用实战 - 营养素卡片实现
上一篇我们实现了卡路里卡片,用环形进度条展示了今日的卡路里摄入情况。这篇文章我们来实现营养素卡片,它用饼图的形式展示碳水化合物、蛋白质、脂肪三大宏量营养素的摄入比例。营养素卡片和卡路里卡片有些相似,都用到了来绘制图表。不过饼图的绘制逻辑和环形进度条有所不同,我们会详细讲解。用一个列表存储每个扇区的数据,包括百分比和颜色。这里用了Dart 3的Record语法,比创建一个专门的类更简洁。三个扇区的颜

写在前面
上一篇我们实现了卡路里卡片,用环形进度条展示了今日的卡路里摄入情况。这篇文章我们来实现营养素卡片,它用饼图的形式展示碳水化合物、蛋白质、脂肪三大宏量营养素的摄入比例。
营养素卡片和卡路里卡片有些相似,都用到了 CustomPaint 来绘制图表。不过饼图的绘制逻辑和环形进度条有所不同,我们会详细讲解。
先看效果
营养素卡片的布局比较简单:顶部是标题和下拉菜单,中间是一个饼图,底部是三个营养素的数值标签。饼图中心显示各营养素的百分比,点击卡片可以跳转到营养素详情页。
饼图采用环形设计,三种颜色分别代表脂肪(橙色)、碳水化合物(绿色)和蛋白质(灰色)。这种配色方案在健康类App中比较常见,用户一眼就能识别。
导入依赖
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'dart:math' as math;
import '../utils/colors.dart';
import '../l10n/app_localizations.dart';
import '../providers/food_provider.dart';
import '../pages/detail/nutrients_detail_page.dart';
关于这些导入的说明:
dart:math用于数学计算,画饼图需要用到pi和三角函数FoodProvider提供今日的营养素摄入数据NutrientsDetailPage是点击卡片后跳转的详情页
dart:math 这里用了别名 as math,是为了避免和其他库的命名冲突,调用时写 math.pi 比直接写 pi 更清晰。
创建卡片组件
class NutrientsCard extends StatelessWidget {
const NutrientsCard({super.key});
Widget build(BuildContext context) {
final colors = context.appColors;
final l10n = context.l10n;
final isDark = Theme.of(context).brightness == Brightness.dark;
为什么选择StatelessWidget:
营养素卡片是无状态组件,所有数据都来自Provider。这样做的好处是:
- 卡片本身不维护任何状态,逻辑清晰
- 当Provider数据变化时,自动重建卡片
- 代码简洁,不需要处理生命周期
开头先获取主题颜色、国际化字符串和深色模式标志,这三个变量在后面会频繁用到。context.appColors 是我们定义的扩展方法,可以方便地获取当前主题的颜色配置。
使用Consumer监听数据
return Consumer<FoodProvider>(
builder: (context, provider, _) {
final carbs = provider.todayCarbs;
final protein = provider.todayProtein;
final fat = provider.todayFat;
final total = carbs + protein + fat;
final carbsPercent = total > 0 ? carbs / total : 0.33;
final proteinPercent = total > 0 ? protein / total : 0.33;
final fatPercent = total > 0 ? fat / total : 0.34;
数据计算的逻辑:
用 Consumer 监听 FoodProvider,当数据变化时自动重建UI。从Provider获取今日的碳水、蛋白质、脂肪摄入量(单位是克)。
计算各营养素占总量的百分比时要注意一个边界情况:如果用户今天还没有记录任何食物,total 会是0,直接除会报错。所以我们用三元表达式处理:如果 total > 0 就正常计算百分比,否则给一个默认值(各占三分之一)。
这里 fatPercent 用的是 0.34 而不是 0.33,是为了保证三个百分比加起来等于1。这是一个小细节,但很重要。
卡片容器
return GestureDetector(
onTap: () {
Navigator.push(
context,
MaterialPageRoute(builder: (context) => const NutrientsDetailPage()),
);
},
child: Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: colors.cardBackground,
borderRadius: BorderRadius.circular(20),
),
容器的设计细节:
整个卡片用 GestureDetector 包裹,点击时跳转到营养素详情页。容器有20像素的内边距,圆角20像素,背景色使用主题定义的卡片背景色。
在深色模式下,colors.cardBackground 会返回深灰色(#2C2C2C),浅色模式下返回白色。这样就不用在代码里写一堆 isDark ? darkColor : lightColor 的判断了,代码更清晰。
顶部标题栏
child: Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
l10n.nutrients,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: isDark ? AppColors.primaryLight : AppColors.primary,
),
),
Row(
children: [
Text(
l10n.macronutrients,
style: TextStyle(fontSize: 14, color: colors.textSecondary),
),
Icon(Icons.keyboard_arrow_down, color: colors.textSecondary),
],
),
],
),
标题栏的布局设计:
顶部是一个 Row,左边显示"营养素"标题,右边是一个下拉菜单的样式(文字加向下箭头图标)。
标题的颜色根据深色模式切换:深色模式用 primaryLight(浅绿色),浅色模式用 primary(深绿色)。这样在深色背景上文字更清晰。
右边的"宏量营养素"文字和箭头图标用次要文字颜色,视觉上不那么突出,暗示这是一个可交互的元素。
饼图区域
const SizedBox(height: 20),
SizedBox(
height: 150,
width: 150,
child: CustomPaint(
painter: _NutrientsPieChartPainter(
fatPercent: fatPercent,
carbsPercent: carbsPercent,
proteinPercent: proteinPercent,
isDark: isDark,
),
为什么用CustomPaint:
饼图用 CustomPaint 绘制,尺寸是150x150像素。_NutrientsPieChartPainter 是我们自定义的画笔类,接收三个营养素的百分比和深色模式标志作为参数。
为什么要传 isDark 参数?因为饼图的颜色在深色模式下需要调整,用更亮的颜色才能在深色背景上看清楚。这是深色模式适配的关键。
饼图中心的百分比显示
child: Center(
child: total > 0
? Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'${(fatPercent * 100).toStringAsFixed(0)}%',
style: TextStyle(
fontSize: 12,
color: isDark ? AppColors.orangeLight : AppColors.orange,
),
),
Text(
'${(carbsPercent * 100).toStringAsFixed(0)}%',
style: TextStyle(
fontSize: 12,
color: isDark ? AppColors.primaryLight : AppColors.primary,
),
),
Text(
'${(proteinPercent * 100).toStringAsFixed(0)}%',
style: TextStyle(fontSize: 12, color: colors.textSecondary),
),
],
)
: Text(
'No data',
style: TextStyle(fontSize: 12, color: colors.textSecondary),
),
),
中心内容的设计:
CustomPaint 的 child 属性可以在画布上叠加一个Widget。我们在饼图中心显示三个百分比数字,颜色和对应的饼图扇区一致。
如果没有数据(total 为0),显示"No data"提示。toStringAsFixed(0) 把小数转成整数字符串,比如 0.333 变成 "33"。
这样的设计让用户能够快速看到各营养素的占比,不需要去计算。
底部营养素标签
const SizedBox(height: 20),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_buildNutrientChip(context, '${fat.toStringAsFixed(0)}g', l10n.fat, AppColors.orange),
const SizedBox(width: 12),
_buildNutrientChip(context, '${carbs.toStringAsFixed(0)}g', l10n.carbs, AppColors.primary),
const SizedBox(width: 12),
_buildNutrientChip(context, '${protein.toStringAsFixed(0)}g', l10n.protein, colors.textPrimary, isHighlight: true),
],
),
标签的布局设计:
底部用三个标签显示各营养素的具体克数。标签之间间隔12像素,整体居中对齐。
注意蛋白质标签传了 isHighlight: true,这会让它的样式和其他两个不同,更加突出。这是一个设计选择,蛋白质对于健身人群来说是最关注的营养素。
营养素标签组件
Widget _buildNutrientChip(BuildContext context, String value, String label, Color color, {bool isHighlight = false}) {
final isDark = Theme.of(context).brightness == Brightness.dark;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
decoration: BoxDecoration(
color: isHighlight ? color : color.withOpacity(isDark ? 0.2 : 0.1),
borderRadius: BorderRadius.circular(12),
border: isHighlight ? null : Border.all(color: color.withOpacity(0.3)),
),
标签样式的设计逻辑:
这个方法构建单个营养素标签。标签是一个圆角矩形容器,内边距是水平16像素、垂直10像素。
普通标签的背景色是主色的10%透明度(深色模式下是20%),加上30%透明度的边框。高亮标签直接用主色填充,没有边框。
深色模式下背景透明度稍高一些(0.2 vs 0.1),这样在深色背景上更容易看清。这是一个细微但重要的设计细节。
标签内容
child: Column(
children: [
Text(
value,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: isHighlight ? (isDark ? Colors.black : Colors.white) : color,
),
),
Text(
label,
style: TextStyle(
fontSize: 12,
color: isHighlight
? (isDark ? Colors.black54 : Colors.white70)
: color.withOpacity(0.7),
),
),
],
),
文字样式的设计:
标签内部是一个 Column,上面是数值(如"45g"),下面是名称(如"脂肪")。
高亮标签的文字颜色需要和背景形成对比:深色模式下背景是亮色,文字用黑色;浅色模式下背景是深色,文字用白色。
普通标签的文字直接用主色,名称文字稍微透明一点(0.7透明度),形成主次层级。这样的设计让用户能够快速区分重要信息。
饼图画笔类
接下来是重头戏——饼图的绘制。我们创建一个继承自 CustomPainter 的类:
class _NutrientsPieChartPainter extends CustomPainter {
final double fatPercent;
final double carbsPercent;
final double proteinPercent;
final bool isDark;
_NutrientsPieChartPainter({
required this.fatPercent,
required this.carbsPercent,
required this.proteinPercent,
this.isDark = false,
});
画笔类的设计:
画笔类接收三个营养素的百分比和深色模式标志。这些参数会在 paint 方法中用到。
使用私有类(_NutrientsPieChartPainter)是因为这个类只在这个文件内部使用,不需要暴露给外部。
paint方法
void paint(Canvas canvas, Size size) {
final center = Offset(size.width / 2, size.height / 2);
final radius = math.min(size.width, size.height) / 2 - 5;
初始化绘制参数:
paint 方法是绘制的核心。首先计算画布的中心点和饼图的半径。半径取宽高的较小值的一半,再减去5像素的边距,防止饼图贴着边缘。
这样的设计让饼图在不同尺寸的容器中都能正确显示。
定义扇区数据
final segments = [
(fatPercent, isDark ? AppColors.orangeLight : AppColors.orange),
(carbsPercent, isDark ? AppColors.primaryLight : AppColors.primary),
(proteinPercent, isDark ? AppColors.darkTextSecondary : AppColors.grey),
];
扇区数据的组织方式:
用一个列表存储每个扇区的数据,包括百分比和颜色。这里用了Dart 3的Record语法 (value1, value2),比创建一个专门的类更简洁。
三个扇区的颜色分别是:脂肪用橙色,碳水用绿色(主题色),蛋白质用灰色。深色模式下用对应的亮色版本。
这样的设计让代码更易读,也更容易维护。
绘制扇区
double startAngle = -math.pi / 2;
for (final segment in segments) {
if (segment.$1 <= 0) continue;
final sweepAngle = segment.$1 * 2 * math.pi;
final paint = Paint()
..color = segment.$2
..style = PaintingStyle.stroke
..strokeWidth = 20
..strokeCap = StrokeCap.butt;
canvas.drawArc(
Rect.fromCircle(center: center, radius: radius - 10),
startAngle,
sweepAngle - 0.05,
false,
paint,
);
startAngle += sweepAngle;
}
扇区绘制的核心逻辑:
这段代码是饼图绘制的核心,我们来逐行分析。
startAngle = -math.pi / 2 表示从12点钟方向开始画(在数学坐标系中,0度是3点钟方向,-90度即 -π/2 是12点钟方向)。
遍历每个扇区,如果百分比小于等于0就跳过。sweepAngle 是扇区的角度,百分比乘以 2π 就是对应的弧度。
Paint对象的配置:
color- 扇区颜色style = PaintingStyle.stroke- 只画边框,不填充strokeWidth = 20- 边框宽度20像素,这样画出来是一个环形而不是实心圆strokeCap = StrokeCap.butt- 线条端点是平的,不是圆头
drawArc方法的参数:
- 外接矩形(用圆心和半径定义)
- 起始角度
- 扫过的角度(减去0.05是为了在扇区之间留一点间隙)
- 是否连接到圆心(false表示只画弧线)
- 画笔
每画完一个扇区,startAngle 加上这个扇区的角度,下一个扇区从这里开始。这样就能连续画出三个扇区。
shouldRepaint方法
bool shouldRepaint(covariant _NutrientsPieChartPainter oldDelegate) =>
oldDelegate.fatPercent != fatPercent ||
oldDelegate.carbsPercent != carbsPercent ||
oldDelegate.proteinPercent != proteinPercent ||
oldDelegate.isDark != isDark;
性能优化的关键:
shouldRepaint 告诉Flutter什么时候需要重新绘制。只有当任何一个参数发生变化时才返回 true,避免不必要的重绘。
这是一个性能优化点。如果每次 build 都重绘,即使数据没变,也会浪费CPU资源。对于复杂的自定义绘制来说,这个优化很重要。
关于颜色扩展
前面代码中用到了 context.appColors,这是我们定义的一个扩展方法,可以方便地获取当前主题的颜色配置:
extension AppColorsExtensionBuildContext on BuildContext {
AppColorsExtension get appColors =>
Theme.of(this).extension<AppColorsExtension>() ?? AppColorsExtension.light;
}
扩展方法的好处:
AppColorsExtension 是一个 ThemeExtension,在亮色和暗色主题中定义了不同的颜色值:
static const light = AppColorsExtension(
cardBackground: Colors.white,
textPrimary: AppColors.dark,
textSecondary: AppColors.grey,
divider: Color(0xFFE0E0E0),
inputBackground: AppColors.background,
);
static const dark = AppColorsExtension(
cardBackground: AppColors.darkCard,
textPrimary: AppColors.darkText,
textSecondary: AppColors.darkTextSecondary,
divider: Color(0xFF424242),
inputBackground: AppColors.darkSurface,
);
这样做的好处是把颜色定义集中管理,组件里只需要用 colors.cardBackground 这样的语义化名称,不用关心具体是什么颜色值。切换主题时,颜色会自动变化。
为什么用环形饼图
在实现这个卡片的过程中,有几个设计决策值得说一下。
环形vs实心:
为什么用环形饼图而不是实心饼图?环形饼图中间可以放内容(百分比数字),视觉上也更现代。实心饼图在小尺寸下各扇区的面积差异不明显,环形饼图通过弧长来表示比例,更容易区分。
蛋白质高亮的原因:
为什么蛋白质标签要高亮?这是一个设计选择,蛋白质对于健身人群来说是最关注的营养素,高亮显示可以让用户一眼看到。当然你也可以根据实际需求调整,比如高亮显示占比最大的营养素。
扇区间隙的作用:
扇区之间为什么要留间隙?如果三个扇区紧挨着,颜色相近时不容易区分边界。留一点间隙(sweepAngle - 0.05)可以让各扇区更清晰。
可能的改进方向
当前的实现已经能满足基本需求,但如果想做得更好,还有一些可以改进的地方。
第一个是动画效果
现在饼图是静态的,数据变化时直接跳变。可以加一个动画,让扇区从0度逐渐展开到目标角度,视觉上更流畅。实现方式是把 NutrientsCard 改成 StatefulWidget,用 AnimationController 控制动画进度。
第二个是交互反馈
点击卡片时可以加一个水波纹效果,用 InkWell 替代 GestureDetector。不过 InkWell 需要配合 Material 组件使用,会稍微复杂一点。
第三个是无障碍支持
可以给饼图加上 Semantics 标签,让屏幕阅读器能够读出营养素的比例信息,方便视障用户使用。这是一个很重要的功能,但经常被忽视。
第四个是数据验证
可以添加更多的边界情况处理。比如某个营养素的值是负数(虽然不太可能),或者数据格式不对。这样可以让应用更稳定。
性能考虑
在实现营养素卡片时,有几个性能方面的考虑:
第一,避免频繁重绘
shouldRepaint 方法很关键。如果实现不当,即使数据没变也会重绘,浪费CPU资源。
第二,合理使用Consumer
只监听需要的Provider,不要监听整个Provider。这样可以减少不必要的重建。
第三,使用const优化
尽量使用 const 修饰Widget,让Flutter能够复用实例。
小结
这篇文章我们实现了营养素卡片,主要涉及:
- Consumer监听 - 监听Provider数据变化
- CustomPaint绘制 - 绘制自定义饼图
- 深色模式适配 - 根据主题调整颜色
- 边界情况处理 - 无数据时的默认值
- 性能优化 - shouldRepaint避免不必要的重绘
和卡路里卡片相比,营养素卡片的绘制逻辑更简单一些,没有渐变色和端点圆点。但核心思路是一样的:用 drawArc 画弧线,通过控制起始角度和扫过角度来绘制不同的扇区。
下一篇我们来实现步数卡片,它会用到另一种图表——条形图。敬请期待!
欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net
更多推荐
所有评论(0)