请添加图片描述

写在前面

卡路里详情页面是用户查看每日热量收支的地方。和首页的卡路里卡片不同,详情页面提供了更丰富的信息:环形进度图、每日摘要、各餐明细等。

这个页面的核心是一个自定义绘制的环形进度图,用渐变色展示剩余卡路里的比例。我们会用 CustomPainter 来实现这个效果。这篇文章会详细讲解如何绘制自定义图表、如何组织复杂的页面布局、以及如何让数据实时更新。


导入依赖

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

dart:math 用于数学计算,画圆弧需要用到 pi 常量。


创建页面

class CaloriesDetailPage extends StatelessWidget {
  const CaloriesDetailPage({super.key});

构建主体

  
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: AppColors.background,
      appBar: AppBar(
        backgroundColor: Colors.transparent,
        elevation: 0,
        leading: IconButton(
          icon: const Icon(Icons.arrow_back_ios, color: AppColors.dark),
          onPressed: () => Navigator.pop(context),
        ),
        centerTitle: true,
        title: const Text(
          'Calories',
          style: TextStyle(
            color: AppColors.dark,
            fontSize: 18,
            fontWeight: FontWeight.w600,
          ),
        ),
        actions: [
          IconButton(
            icon: const Icon(Icons.calendar_today, color: AppColors.dark),
            onPressed: () {},
          ),
        ],
      ),

AppBar右边加了一个日历图标,点击可以选择查看其他日期的数据。

      body: SingleChildScrollView(
        padding: const EdgeInsets.all(16),
        child: Column(
          children: [
            _buildCaloriesRingCard(),
            const SizedBox(height: 16),
            _buildSummaryCard(),
            const SizedBox(height: 16),
            _buildMealsCard(),
            const SizedBox(height: 40),
          ],
        ),
      ),
    );
  }

页面由三个卡片组成:环形图卡片、每日摘要卡片、餐食明细卡片。


环形图卡片

这是页面的核心组件,展示剩余卡路里和各项统计:

  Widget _buildCaloriesRingCard() {
    return Container(
      padding: const EdgeInsets.all(24),
      decoration: BoxDecoration(
        color: Colors.white,
        borderRadius: BorderRadius.circular(20),
      ),
      child: Column(
        children: [
          SizedBox(
            height: 200,
            child: Stack(
              alignment: Alignment.center,
              children: [
                CustomPaint(
                  size: const Size(180, 180),
                  painter: _CaloriesRingPainter(progress: 0.78),
                ),

Stack 把环形图和中间的文字叠加在一起。CustomPaint 绑制环形图,progress: 0.78 表示已消耗78%的卡路里预算。

                Column(
                  mainAxisSize: MainAxisSize.min,
                  children: [
                    const Text(
                      '1800',
                      style: TextStyle(
                        fontSize: 42,
                        fontWeight: FontWeight.bold,
                        color: AppColors.primary,
                      ),
                    ),
                    Text(
                      'Remaining',
                      style: TextStyle(
                        fontSize: 14,
                        color: Colors.grey.shade500,
                      ),
                    ),
                  ],
                ),
              ],
            ),
          ),

中间显示剩余卡路里数字和"Remaining"标签。

          const SizedBox(height: 24),
          Row(
            mainAxisAlignment: MainAxisAlignment.spaceAround,
            children: [
              _buildStatItem('2300', 'Budget', AppColors.primary),
              _buildStatItem('500', 'Activity', AppColors.orange),
              _buildStatItem('1000', 'Meals', AppColors.grey),
            ],
          ),
        ],
      ),
    );
  }

底部三个统计项:预算、活动消耗、餐食摄入。

统计项组件

  Widget _buildStatItem(String value, String label, Color color) {
    return Column(
      children: [
        Row(
          mainAxisSize: MainAxisSize.min,
          children: [
            Container(
              width: 10,
              height: 10,
              decoration: BoxDecoration(
                color: color,
                shape: BoxShape.circle,
              ),
            ),
            const SizedBox(width: 8),
            Text(
              value,
              style: const TextStyle(
                fontSize: 20,
                fontWeight: FontWeight.bold,
                color: AppColors.dark,
              ),
            ),
          ],
        ),
        const SizedBox(height: 4),
        Text(
          label,
          style: TextStyle(
            fontSize: 13,
            color: Colors.grey.shade500,
          ),
        ),
      ],
    );
  }

每个统计项由彩色圆点、数值、标签组成。圆点颜色和环形图的颜色对应。


自定义环形图绑制

这是页面的技术重点,用 CustomPainter 绑制渐变色环形进度图:

class _CaloriesRingPainter extends CustomPainter {
  final double progress;

  _CaloriesRingPainter({required this.progress});

接收一个 progress 参数,表示进度(0.0-1.0)。

绑制方法

  
  void paint(Canvas canvas, Size size) {
    final center = Offset(size.width / 2, size.height / 2);
    final radius = size.width / 2 - 15;
    const strokeWidth = 18.0;
    const startAngle = math.pi * 0.75;
    const sweepAngle = math.pi * 1.5;

计算圆心、半径、线宽。startAngle 是起始角度,从左下方开始(0.75π)。sweepAngle 是扫过的角度,270度(1.5π),形成一个3/4圆弧。

绘制背景弧

    final bgPaint = Paint()
      ..color = const Color(0xFFE8F5E9)
      ..style = PaintingStyle.stroke
      ..strokeWidth = strokeWidth
      ..strokeCap = StrokeCap.round;

    canvas.drawArc(
      Rect.fromCircle(center: center, radius: radius),
      startAngle,
      sweepAngle,
      false,
      bgPaint,
    );

背景弧用浅绿色,strokeCap: StrokeCap.round 让端点是圆形的。

绘制进度弧

    final progressSweep = sweepAngle * progress;
    final rect = Rect.fromCircle(center: center, radius: radius);

    final gradient = SweepGradient(
      startAngle: startAngle,
      endAngle: startAngle + progressSweep,
      colors: const [
        Color(0xFF4DB6AC),
        Color(0xFF26A69A),
        Color(0xFF2E7D6B),
      ],
      stops: const [0.0, 0.5, 1.0],
      transform: GradientRotation(startAngle),
    );

进度弧用渐变色,从浅青绿到深青绿。SweepGradient 是扫描渐变,颜色沿着圆弧方向变化。GradientRotation 让渐变从起始角度开始。

    final progressPaint = Paint()
      ..shader = gradient.createShader(rect)
      ..style = PaintingStyle.stroke
      ..strokeWidth = strokeWidth
      ..strokeCap = StrokeCap.round;

    canvas.drawArc(rect, startAngle, progressSweep, false, progressPaint);
  }

把渐变应用到画笔上,绘制进度弧。

重绘判断

  
  bool shouldRepaint(covariant _CaloriesRingPainter oldDelegate) =>
      oldDelegate.progress != progress;
}

只有当 progress 变化时才需要重绘。


每日摘要卡片

这个卡片展示卡路里的计算过程:

  Widget _buildSummaryCard() {
    return Container(
      padding: const EdgeInsets.all(20),
      decoration: BoxDecoration(
        color: Colors.white,
        borderRadius: BorderRadius.circular(16),
      ),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          const Text(
            'Daily Summary',
            style: TextStyle(
              fontSize: 16,
              fontWeight: FontWeight.w600,
              color: AppColors.dark,
            ),
          ),
          const SizedBox(height: 16),
          _buildSummaryRow('Base Goal', '2,000 kcal'),
          _buildSummaryRow('Exercise', '+300 kcal'),
          _buildSummaryRow('Food', '-1,000 kcal'),
          const Divider(height: 24),
          _buildSummaryRow('Remaining', '1,300 kcal', isHighlight: true),
        ],
      ),
    );
  }

四行数据:基础目标、运动消耗(加)、食物摄入(减)、剩余。最后一行用高亮样式。

摘要行组件

  Widget _buildSummaryRow(String label, String value, {bool isHighlight = false}) {
    return Padding(
      padding: const EdgeInsets.symmetric(vertical: 8),
      child: Row(
        mainAxisAlignment: MainAxisAlignment.spaceBetween,
        children: [
          Text(
            label,
            style: TextStyle(
              fontSize: 15,
              color: isHighlight ? AppColors.primary : AppColors.grey,
              fontWeight: isHighlight ? FontWeight.w600 : FontWeight.normal,
            ),
          ),
          Text(
            value,
            style: TextStyle(
              fontSize: 15,
              color: isHighlight ? AppColors.primary : AppColors.dark,
              fontWeight: isHighlight ? FontWeight.w600 : FontWeight.w500,
            ),
          ),
        ],
      ),
    );
  }

高亮行用主题色,普通行用灰色标签和深色数值。


餐食明细卡片

这个卡片展示各餐的卡路里摄入:

  Widget _buildMealsCard() {
    return Container(
      padding: const EdgeInsets.all(20),
      decoration: BoxDecoration(
        color: Colors.white,
        borderRadius: BorderRadius.circular(16),
      ),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          const Text(
            'Meals',
            style: TextStyle(
              fontSize: 16,
              fontWeight: FontWeight.w600,
              color: AppColors.dark,
            ),
          ),
          const SizedBox(height: 16),
          _buildMealItem('Breakfast', '350 kcal', 0.35, const Color(0xFFFFF3E0)),
          _buildMealItem('Lunch', '450 kcal', 0.45, const Color(0xFFE8F5E9)),
          _buildMealItem('Dinner', '200 kcal', 0.20, const Color(0xFFFCE4EC)),
          _buildMealItem('Snacks', '0 kcal', 0.0, const Color(0xFFFFEBEE)),
        ],
      ),
    );
  }

四餐数据,每餐有名称、卡路里、进度比例、背景色。

餐食项组件

  Widget _buildMealItem(String name, String calories, double progress, Color color) {
    return Padding(
      padding: const EdgeInsets.symmetric(vertical: 10),
      child: Column(
        children: [
          Row(
            mainAxisAlignment: MainAxisAlignment.spaceBetween,
            children: [
              Text(
                name,
                style: const TextStyle(
                  fontSize: 15,
                  color: AppColors.dark,
                ),
              ),
              Text(
                calories,
                style: const TextStyle(
                  fontSize: 15,
                  color: AppColors.grey,
                ),
              ),
            ],
          ),

顶部是餐食名称和卡路里数值。

          const SizedBox(height: 8),
          ClipRRect(
            borderRadius: BorderRadius.circular(4),
            child: LinearProgressIndicator(
              value: progress,
              backgroundColor: color,
              valueColor: const AlwaysStoppedAnimation<Color>(AppColors.primary),
              minHeight: 6,
            ),
          ),
        ],
      ),
    );
  }
}

底部是进度条,用 LinearProgressIndicator 实现。ClipRRect 给进度条加圆角。每餐用不同的背景色区分。


环形图的数学原理

理解环形图的绘制需要一些数学知识:

角度单位:Flutter的Canvas使用弧度制,不是角度制。一个完整的圆是2π弧度(约6.28),半圆是π弧度(约3.14)。

起始角度:0弧度在3点钟方向,顺时针增加。我们的环形图从7点半方向开始(0.75π),到4点半方向结束(0.75π + 1.5π = 2.25π)。

扫过角度:sweepAngle 是从起始角度开始扫过的角度。正值顺时针,负值逆时针。

进度计算:progressSweep = sweepAngle * progress,比如progress是0.78,sweepAngle是1.5π,那么progressSweep就是1.17π。


渐变色的实现

SweepGradient 是扫描渐变,颜色沿着圆周方向变化:

final gradient = SweepGradient(
  startAngle: startAngle,
  endAngle: startAngle + progressSweep,
  colors: const [
    Color(0xFF4DB6AC),  // 浅青绿
    Color(0xFF26A69A),  // 中青绿
    Color(0xFF2E7D6B),  // 深青绿
  ],
  stops: const [0.0, 0.5, 1.0],
  transform: GradientRotation(startAngle),
);

colors 定义渐变的颜色序列。

stops 定义每个颜色的位置,0.0是起点,1.0是终点。

transform 旋转渐变,让它从起始角度开始。


进度条的使用

LinearProgressIndicator 是Flutter内置的线性进度条:

LinearProgressIndicator(
  value: progress,           // 进度值,0.0-1.0
  backgroundColor: color,    // 背景色
  valueColor: AlwaysStoppedAnimation<Color>(AppColors.primary),  // 进度色
  minHeight: 6,              // 高度
)

value 为null时显示不确定进度(动画),有值时显示确定进度。

valueColorAlwaysStoppedAnimation 包裹,表示颜色不变化。如果想要颜色动画,可以用 AnimationController


卡路里计算公式

页面上的数据遵循这个公式:

剩余卡路里 = 基础目标 + 运动消耗 - 食物摄入

以页面数据为例:

基础目标:2000 kcal

运动消耗:+300 kcal

食物摄入:-1000 kcal

剩余:2000 + 300 - 1000 = 1300 kcal

环形图显示的是已消耗比例:1000 / (2000 + 300) ≈ 0.43,但页面上显示的是0.78,可能是示例数据不一致。

实际应用中,这些数据应该从数据库读取并实时计算。


小结

这篇文章我们实现了卡路里详情页面,核心是用 CustomPainter 绑制渐变色环形进度图。

环形图的绘制涉及到弧度计算、渐变色应用等知识点。SweepGradient 配合 GradientRotation 可以实现沿圆弧方向的渐变效果。

页面还包含了每日摘要和餐食明细两个卡片,用简单的列表展示数据。


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

Logo

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

更多推荐