请添加图片描述

写在前面

上一篇我们实现了卡路里卡片,用环形进度条展示了今日的卡路里摄入情况。这篇文章我们来实现营养素卡片,它用饼图的形式展示碳水化合物、蛋白质、脂肪三大宏量营养素的摄入比例。

营养素卡片和卡路里卡片有些相似,都用到了 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),
        ),
),

中心内容的设计:

CustomPaintchild 属性可以在画布上叠加一个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 是扇区的角度,百分比乘以 就是对应的弧度。

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

Logo

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

更多推荐