Flutter 中的曲线图

1,858 阅读16分钟

了解如何使用Canvas API在Flutter应用上绘制曲线图。

如果,你有一些数据,你想做一个曲线图?有很多不错的Flutter库可以完成这项工作。但是,如果您想要一个独特漂亮的图表,完全符合您的应用程序的设计,您将需要从头开始构建他。

Flutter的Canvas API是绘制自定义图标的完美工具。这个API非常直观。

在深入了解Canvas API之前,你应该至少具有中等水平的Flutter经验。如果这听起来像你,那么系好安全带并准备好构建一些很棒的图标!

在本教程中,您将将构建LOLTracker,这是一款可以记录您笑的频率的应用程序。这个简单的应用程序将帮助您掌握一下Flutter原则:

  • 学习使用小部件绘制曲线CustomPaint()
  • 映射曲线以跟踪数据集中的数据
  • 在图标的x轴和y轴添加标签

一、入门

为了更专注的绘制曲线,准备几个文件,运行之后会在设备上看到如下效果。

image.png

  1. main.dart是应用程序的入口点,包含用于三周的笑声数据之间切换的预构建UI。

  2. layghing_data.dart包含模型类和要在图标上绘制的数据。

  3. components/slide_selector.dart包含一个在一周之间切换的开关。

  4. components/week_summary.dart包含每周摘要UI。

  5. components/chart_labels.dart包含显示图表标签的UI。

最终项目的目标是变成下面的效果,咱们先睹为快:

final-app.gif

如果想要快速运行Demo,请跳转最后章节直接复制代码。

二、添加一个CustomPaint()小部件

您将使用CustomPainter绘制您自己的折线图。在main.dart文件中,将第113行的Placeholder()的widget替换为CustomPaint() widget.如下所示:

CustomPaint(
  size: Size(MediaQuery.of(context).size.width, chartHeight),
  painter: PathPainter(
    path: drawPath(),
  ),
),

保存代码。您可能已经看到红色错误警告。那是因为您仍然需要定义PathPainter,以及其中的drawPath()函数。

在小部件之后的文件底部DashboardBackground下面,创建CustomPainter的拓展类PathPainter

class PathPainter extends CustomPainter {
  Path path;
  PathPainter({required this.path});

  @override
  void paint(Canvas canvas, Size size) {
    // paint the line
    final paint = Paint()
        ..color = Colors.white
        ..style = PaintingStyle.stroke
        ..strokeWidth = 4.0;
    canvas.drawPath(path, paint);
  }

  @override
  bool shouldRepaint(covariant CustomPainter oldDelegate) => true;
  
}

这个painter会 沿着你进入的任何路径画一条白线。 在你的_DashboardState类中,在build函数某处创建一个方法drawPath()

Path drawPath() {
  final width = MediaQuery.of(context).size.width;
  final height = chartHeight;
  final path = Path();
  path.moveTo(0, height);
  path.lineTo(width / 2, height * 0.5);
  path.lineTo(width, height * 0.75);
  return path;
}

上面的代码将定义您要回最后的线的路径PathPainter:

请注意,moveTo()lineTo()都将x坐标和y坐标作为参数。因此,您正在使用moveTo(0, height将线条的起点移动到painter的左下角。

请记住,painter中y轴是倒置的,这意味着0在顶部,而charHeighty轴的底部。因此,当您使用height * 0.75作为第三段的坐标时,该点是图标底部向上的25%。

构建并运行您的代码。瞧!你已经制作了折线图。

image.png

哦,你还在吗?刚刚学会的漂亮的线条你还不满意吗?好吧,好吧,那么是时候调高冷静并学习如何将这条线连接到一些数据了。

三、添加数据

打开laughing_data.dart文件并查看您将绘制图表的所有数据。

看数据,第二周一定是精神抖擞。那个星期有好几天都是两位数的笑声,有一天你竟然笑出声来了14次!然而,第三周并不是那么好,因为你在任何一天都不会笑超过四次。好难过...

您知道您希望图表在任何给定时间显示一周数据。

由于您需要在三周的每一周的不同图表之间切换,因为您需要对数据进行标准化。这意味着对于任何一周的数据集,值都会缩小到0.0到1.0之间的数字。如果您的图表使用归一化数据集,它会在图表顶部绘制最大数据点,无论是4还是400.

您将添加一个normalizeData()函数来执行此操作,但首先您需要在你所在的创建一个列表来保存您的图表数据。首先,您需要定义图表中使用的数据类型。

在文件的最底部,在PathPainter之后定义ChartDataPoint类:

class ChartDataPoint {
  final double value;

  ChartDataPoint({required this.value});
}

现在列表清单。chartHeight在泪中确定后立即添加下面的代码_DashboardState:

late List<ChartDataPoint> chartData;

这只是为您的图标保存了一系列数据点。现在规范化图标数据。normalizeData()在initState方法之后添加函数:

List<ChartDataPoint> normalizeData(WeekData weekData) {
  final maxDay = weekData.days.reduce((DayData dayA, DayData dayB) {
    return dayA.laughs > dayB.laughs ? dayA : dayB;
  });
  final normalizedList = <ChartDataPoint>[];
  for (var element in weekData.days) {
    normalizedList.add(ChartDataPoint(value: maxDay.laughs == 0 ? 0 : element.laughs / maxDay.laughs));
  }
  return normalizedList;
}

上面代码从laughing_data.dart中获得了一周的数据。它计算笑声最大的那天,并返回一个ChartDataPoints数值范围为0.0到1.0的标准化列表。

此时,是时候调用它了。在initState方法中,使用normalizeData()函数初始化您的chartData:

@override
void initState() {
  super.initState();
  setState(() {
    chartData = normalizeData(weeksData[activeWeek - 1]);
  });
}

chartData当用户在周之间切换时,您还需要更新。在您的changeWeek()函数内,在您设置之后activeWeek,添加以下内容:

chartData = normalizeData(weeksData[activeWeek - 1]);

现在这chartData是一个规范化的列表,在你的drawPath()函数中循环遍历它:

Path drawPath() {
  final width = MediaQuery.of(context).size.width;
  final height = chartHeight;
  final segmentWidth = width / (chartData.length - 1);
  final path = Path();
  path.moveTo(0, height - chartData[0].value * height);
  for (var i = 0; i < chartData.length; ++i) {
    final x = i * segmentWidth;
    final y = height - (chartData[i].value * height);
    path.lineTo(x, y);
  }
  return path;
}

在深入研究这个新drawPath()代码之前,构建并运行您的代码;您可能需要进行热重启。您会看到一条包含七个数据点的线;当您在几周之间切换时,这也应该更新。

chart-changing.gif

如果您查看laughing_data.dart中的数据,你会发现这些行与每周的数据相匹配。但是这条路径究竟是如何创建的呢?

仔细查看您刚刚添加的代码drawPath(),您可以看到原始数据试试如何转换为从屏幕左侧开始的行。该path.moveTo()方法是将图表的第一个点x坐标设置为0,将y做哦表设置为height - chartDara[0].value * height。然后循环遍历剩余的chartData值以在路径中创建另外六个lineTo()段。

该循环使用i * segmentWidth计算每个点的x坐标,其中segmentWidth是屏幕宽度的六分之一。它以与第一个点相同的方式计算y坐标height - (chartData[i].value * height)

现在您已经有了包含数据的功能图表,下一步是对其进行一些修饰。您向路径添加padding并学习如何将这些直线变成曲线。

四、创建渐变填充

要为您的图表添加渐变填充,您需要在PathPainterpaint()方法中添加另一个部分。在使用canvas.drawPath(path, paint)绘制线条路径后,立即添加一下代码:

import 'dart:ui' as ui;
// paint the gradient fill
paint.style = PaintingStyle.fill;
paint.shader = ui.Gradient.linear(
  Offset.zero,
  Offset(0.0, size.height),
  [
    Colors.white.withOpacity(0.2),
    Colors.white.withOpacity(1),
  ]
);
canvas.drawPath(path, paint);

构建并运行该应用程序,您会看到您已经在路径中添加了填充。但这并不是想要的。

image.png

您希望该渐变连接到图表底部,因此您需要向drawPath()函数添加另外两个段以关闭历经。在drawPath()中的for循环之后,添加以下两行:

path.lineTo(width, height);
path.lineTo(0, height);

这将关闭路径,但如果仔细观察,您还会发现白色实线也在沿着关闭的路径移动。这会产生一些不需要的视觉伪影。

image.png

和不好,因此您需要设置两条不同的路径参数——一条用于实线,一条用于渐变填充。为此,请向drawPath()函数添加closePath参数。请注意,这会破坏您的应用程序,但是别担心,您很快就会修复它。将drawPath更新为以下内容:

Path drawPath(bool closePath) {

然后将closePat的两行包装在if语句中。生成的drawPath将如下所示:

Path drawPath(bool closePath) {
  final width = MediaQuery.of(context).size.width;
  final height = chartHeight;
  final segmentWidth = width / (chartData.length - 1);
  final path = Path();
  path.moveTo(0, height - chartData[0].value * height);
  for (var i = 0; i < chartData.length; ++i) {
    final x = i * segmentWidth;
    final y = height - (chartData[i].value * height);
    path.lineTo(x, y);
  }
  
  if (closePath) {
    path.lineTo(width, height);
    path.lineTo(0, height);
  }
  return path;
}

由于您现在要将两个不同的路径传递给您的painter,因此将第二个fillPath参数添加到您的PathPainter类,如下所示:

class PathPainter extends CustomPainter {
  Path path;
  Path fillPath;
  PathPainter({required this.path, required this.fillPath});

当您绘制渐变时,请使用fillPath而不是路径。您可以在方法的右大括号之前找到它:

  canvas.drawPath(fillPath, paint);
}

最后,更新您的CustomPaint()小部件将两条路径传递到PathPainter,一条关闭,一条不关闭:

CustomPaint(
  size: Size(MediaQuery.of(context).size.width, chartHeight),
  painter: PathPainter(
    path: drawPath(false),
    fillPath: drawPath(true)
  ),
)

热重载应用程序!您现在将两个几乎相同的路径传递到您的CustomPainter;一个用于关闭填充,另一个用于不关闭的线。您的图表看起来不错,但那些尖锐的锯齿状边缘看起来很危险。是时候学习如何平滑你的线条并使其弯曲。

五、绘制曲线

在Flutter中创建曲线乍一看似乎令人生畏,但您很快就会发现它非常直观。Path 类包括许多绘制曲线的方法。实际上,您可以使用以下任何一种方法来创建曲线:

您将只关注cubicTo方法,因为它在尝试通过多个数据点回合制曲线时似乎工作得很好。该cubicTo方法采用六个参数好,或者更确切地说是三对不同的(x, y)坐标。(x3, y3)点是线段的最终点。(x1, y1)(x2, y2)坐标创建控制点,用作弯曲线条的手柄。

如果您还没有100%了解cubicTo()工作原理,请不要担心。出现了一张图表,应该有助于理清思路。修改drawPath()函数如下:

 Path drawPath(bool closePath) {
    final width = MediaQuery.of(context).size.width;
    final height = chartHeight;
    final path = Path();
    final segmentWidth = width / 3 / 2;
    path.moveTo(0, height);
    path.cubicTo(segmentWidth, height, 2 * segmentWidth, 0, 3 * segmentWidth, 0);
    path.cubicTo(4 * segmentWidth, 0, 5 * segmentWidth, height, 6 * segmentWidth, height);
    return path;
  }
}

构建并运行此代码;您应该看到一条漂亮的大曲线。

image.png

可以看到这段代码做的第一件事就是定义segmentWidth,也就是线段的宽度。自有两个cubicTo()段,但记住每个cubicTo()都有三个不同的坐标对。因此,通过将segmentWidth设置为登录页面宽度除以六,您可以在两个cubicTo()段中的每一个中拥有三个均匀间隔的做哦表。

此图将帮助您可视化这两个cubicTo()方法使用的六个部分:

image.png

覆盖在该图上的店用颜色编码为moveTocubicTo()方法。您会看到屏幕左侧的第三个红点是第一个cubicTo()方法的目标目的地。第一个和第二个红点是将直线弯曲成曲线的控制点。

花一些时间研究此图,直到您对cubicTo()方法的工作方式感到满意为止,继续下一届,准备好后,您将在笑声数据中使用此方法。

六、将曲线和数据结合

您需要再次更新drawPath()函数以循环遍历笑声数据并使用cubicTo()绘制一些曲线。在此之前,现在是添加一些天从常量的好是时机。

6.1、添加填充

你需要在图表的左侧和右侧进行填充,以便为标签留出空间。

在您的_DashboardState小部件顶部奋进,在您定义chartHeight之后,添加这些常量:

static const leftPadding = 60.0;
static const rightPadding = 60.0;

现在您有了这些填充常量,可以使用它们来定义图表的段宽度。请记住,每个cubicTo()方法有三个段,因此总共有18个。这意味着您的段宽度是屏幕宽度减去填充,除以18.更新drawPath()函数中的segmentWidth:

final segmentWidth =
    (width - leftPadding - rightPadding) / ((chartData.length - 1) * 3);

6.2、创建一个cubicTo()

现在用一下代码替换您之前绘制的路径。这将循环遍历您的数据点并为每个数据点创建一个cubicTo()段:

path.moveTo(0, height - chartData[0].value * height);
path.lineTo(leftPadding, height - chartData[0].value * height);
// curved line
for (var i = 1; i < chartData.length; i++) {
  path.cubicTo(
      (3 * (i - 1) + 1) * segmentWidth + leftPadding,
      height - chartData[i - 1].value * height,
      (3 * (i - 1) + 2) * segmentWidth + leftPadding,
      height - chartData[i].value * height,
      (3 * (i - 1) + 3) * segmentWidth + leftPadding,
      height - chartData[i].value * height);
}
path.lineTo(width, height - chartData[chartData.length - 1].value * height);
// for the gradient fill, we want to close the path
if (closePath) {
  path.lineTo(width, height);
  path.lineTo(0, height);
}

这是很多代码,但是这个cubicTo()方法遵循您之前的图表所学到的相同原则。您可以看到在for循环内,cubicTo()的y1值始终是前一个图表数据点的y值。这将确保每个cubicTo()方法之间的平滑过渡。

如果您需要可视化这些控制点的工作方式,请再次查看之前的图表。

构建并运行应用程序。现在您可以在每周之间切换以查看三个不同的曲线。

curved-lines.gif

剩下要做的最后一件事情是向图表添加标签,然后您将拥有一个合适的自定义折现。

七、添加标签

CustomPainters不限于绘画路径;您也可以在CustomPainter中绘制文本。但是在图表上绘制文本标签需要大量的数学知识。使用标准布局小部件创建堆叠在CustomPainter后面的图表标签通常更清晰、变简单。

您的CustomPaint()小部件已经位于Stack()小部件内,因此您可以在同一个Stack()中添加您的标签,以便它们呈现在您的图表后面。查看包装在CustonPaint()

7.1、添加X轴标签

您要做的第一件事是在图表下方的x轴标签创建一些空间。查看CustonPaint()小部件的Container()。它的高度应为chartHeight。在下面添加一些填充,如下所示:

height: chartHeight + 80,

接下来,将CustomPaint()包裹在一个Positioned()小部件中,该小部件距离Stack()顶部40像素:

Positioned(
  top: 40,
  child: CustomPaint(
    size:
        Size(MediaQuery.of(context).size.width, chartHeight),
    painter: PathPainter(
        path: drawPath(false), fillPath: drawPath(true)),
  ),
)

然后,在此Positioned()之上,将另一个Positioned()添加到包含ChartDayLabels()小部件的Stack()底部。这个小部件将是您的x轴标签。此小部件已为您设置好,但接下来您将详细了解它的工作原理。不要忘记将图表填充常量传递给小部件,如下所示:

Container(
  height: chartHeight + 80,
  color: const Color(0XFF158443),
  child: Stack(
    children: [ // old code
      const Positioned(
          bottom: 0,
          left: 0,
          right: 0,
          child: ChartDayLabels(
            leftPadding: leftPadding,
            rightPadding: rightPadding,
          )
      ),

在深入了解这个标签小部件的工作原理之前,构建您应该看到的每一天的标签都位于图表中相应数据点的正下方:

image.png

打开compinents/chart_labels.dart,您将在其中看到这个ChartDayLabels()无状态小部件。请注意,它创建了一个从"Sun"到"Sat"的字符串映射为Row()内的FractioonalTranslation()小部件。辅助函数labelOffset()帮助计算每个Text()小部件的精确偏移量。此函数将每个Text()小部件的精确偏移量,此函数将每个Text()小部件与数据点对齐,而不是每个Text()的左边缘。

ChartDayLabels()还具有从Colors.whiteColors.white.withOpacity(0.85)的渐变背景。当然当前标签和图表之间的渐变重置时,这看起来有点儿傻。幸运的是,有一个简单的修复方法。

回到main.dart文件中的PathPainter,将渐变中的第二种颜色从Colors.white.withOpacity(1)更改为Colors.white.withOpacity(0.85)

Colors.white.withOpacity(0.85),

现在你应该在标签后面的渐变和图表后面的渐变之间建立无缝连接。

image.png

7.2、添加Y轴标签

现在您有了x轴标签,是时候添加y轴标签了,在Stack()包装图表和x轴标签最开始,像这样在Positioned小部件上方添加y轴笑声坐标:

ChartLaughLabels(
    chartHeight: chartHeight, 
    topPadding: 40, 
    leftPadding: leftPadding, 
    rightPadding: rightPadding, 
    weekData: weeksData[activeWeek - 1],
),

构建并运行次代码。您会很好地看到y轴标签和网络渲染。

image.png

查看components/chart_labels.dart中的ChartLaughLabels()。这个标签小部件需要五个属性。topPadding属性为40像素,因为如果您还记得的话,您的CustomPaint()小部件位于距Stack()顶部40像素的Positioned()中。它使用weekData属性来计算maxDay,这是数据集中的最大数字。然后将maxDay变量用于最高标签。

ChartLaughLabels()将创建您使用labelCount变量指定任意多个标签。在这种情况下,您要绘制四个y轴标签。它创建了一个名为labels的列表,它将存储所有四个标签的双精度值。您的标签在0和maxDay值之间平均分配,因此如果您的最大值为4,那么您的标签将为0.0,1.3,2.7和4.0。

然后将这些标签映射到Column()内部,并为每个标签创建一条细网格线。每个标签的Text()使用前面的FractionalTranslation()技巧与其网格线居中对齐。

你有它!您已经使用CustomPainter创建了一个漂亮的曲线图。

final-app.gif

十二、全部代码

  1. main.dart是应用程序的入口点,包含用于三周的笑声数据之间切换的预构建UI。
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'components/slide_selector.dart';
import 'components/week_summary.dart';
import 'components/chart_labels.dart';
import 'laughing_data.dart';

void main() {
  SystemChrome.setSystemUIOverlayStyle(const SystemUiOverlayStyle(
    statusBarColor: Colors.transparent,
    statusBarBrightness: Brightness.dark,
    systemNavigationBarColor: Colors.white,
  ));
  runApp(const LOLTrackerApp());
}

class LOLTrackerApp extends StatelessWidget {
  const LOLTrackerApp({Key? key}) : super(key: key);
  @override
  Widget build(BuildContext context) {
    return const MaterialApp(
      title: 'LOLTracker',
      debugShowCheckedModeBanner: false,
      home: Scaffold(
        extendBodyBehindAppBar: true,
        body: Dashboard(),
      ),
    );
  }
}

class Dashboard extends StatefulWidget {
  const Dashboard({Key? key}) : super(key: key);

  @override
  _DashboardState createState() => _DashboardState();
}

class _DashboardState extends State<Dashboard>
    with SingleTickerProviderStateMixin {
  int activeWeek = 3;
  static const leftPadding = 60.0;
  static const rightPadding = 60.0;

  PageController summaryController = PageController(
    viewportFraction: 1,
    initialPage: 3,
  );
  double chartHeight = 240;
  late List<ChartDataPoint> chartData;

  @override
  void initState() {
    super.initState();
    setState(() {
      chartData = normalizeData(weeksData[activeWeek - 1]);
    });
  }

  List<ChartDataPoint> normalizeData(WeekData weekData) {
    final maxDay = weekData.days.reduce((DayData dayA, DayData dayB) {
      return dayA.laughs > dayB.laughs ? dayA : dayB;
    });
    final normalizedList = <ChartDataPoint>[];
    weekData.days.forEach((element) {
      normalizedList.add(ChartDataPoint(
          value: maxDay.laughs == 0 ? 0 : element.laughs / maxDay.laughs));
    });
    return normalizedList;
  }

  void changeWeek(int week) {
    setState(() {
      activeWeek = week;
      chartData = normalizeData(weeksData[week - 1]);
      summaryController.animateToPage(week,
          duration: const Duration(milliseconds: 300), curve: Curves.ease);
    });
  }

  Path drawPath(bool closePath) {
    final width = MediaQuery.of(context).size.width;
    final height = chartHeight;
    final path = Path();
    final segmentWidth =
        (width - leftPadding - rightPadding) / ((chartData.length - 1) * 3);

    path.moveTo(0, height - chartData[0].value * height);
    path.lineTo(leftPadding, height - chartData[0].value * height);
// curved line
    for (var i = 1; i < chartData.length; i++) {
      path.cubicTo(
          (3 * (i - 1) + 1) * segmentWidth + leftPadding,
          height - chartData[i - 1].value * height,
          (3 * (i - 1) + 2) * segmentWidth + leftPadding,
          height - chartData[i].value * height,
          (3 * (i - 1) + 3) * segmentWidth + leftPadding,
          height - chartData[i].value * height);
    }
    path.lineTo(width, height - chartData[chartData.length - 1].value * height);
// for the gradient fill, we want to close the path
    if (closePath) {
      path.lineTo(width, height);
      path.lineTo(0, height);
    }

    return path;
  }

  @override
  Widget build(BuildContext context) {
    return Stack(
      children: [
        const DashboardBackground(),
        ListView(
          padding: EdgeInsets.zero,
          children: [
            Container(
              height: 60,
              margin: const EdgeInsets.only(top: 60),
              alignment: Alignment.center,
              child: const Text(
                'LOL 😆 Tracker',
                style: TextStyle(
                  fontSize: 24,
                  fontWeight: FontWeight.w600,
                  color: Colors.white,
                ),
              ),
            ),
            Padding(
              padding: const EdgeInsets.all(20),
              child: SlideSelector(
                defaultSelectedIndex: activeWeek - 1,
                items: <SlideSelectorItem>[
                  SlideSelectorItem(
                    text: 'Week 1',
                    onTap: () {
                      changeWeek(1);
                    },
                  ),
                  SlideSelectorItem(
                    text: 'Week 2',
                    onTap: () {
                      changeWeek(2);
                    },
                  ),
                  SlideSelectorItem(
                    text: 'Week 3',
                    onTap: () {
                      changeWeek(3);
                    },
                  ),
                ],
              ),
            ),
            const SizedBox(height: 20),
            Container(
              height: chartHeight + 80,
              color: const Color(0xFF158443),
              child: Stack(
                children: [
                  ChartLaughLabels(
                    chartHeight: chartHeight,
                    topPadding: 40,
                    leftPadding: leftPadding,
                    rightPadding: rightPadding,
                    weekData: weeksData[activeWeek - 1],
                  ),
                  const Positioned(
                    bottom: 0,
                    left: 0,
                    right: 0,
                    child: ChartDayLabels(
                      leftPadding: leftPadding,
                      rightPadding: rightPadding,
                    ),
                  ),
                  Positioned(
                    top: 40,
                    child: CustomPaint(
                        size: Size(
                            MediaQuery.of(context).size.width, chartHeight),
                        painter: PathPainter(
                          path: drawPath(false),
                          fillPath: drawPath(true),
                        )),
                  )
                ],
              ),
            ),
            Container(
              color: Colors.white,
              height: 400,
              child: PageView.builder(
                clipBehavior: Clip.none,
                physics: const NeverScrollableScrollPhysics(),
                controller: summaryController,
                itemCount: 4,
                itemBuilder: (_, i) {
                  return WeekSummary(week: i);
                },
              ),
            ),
          ],
        ),
      ],
    );
  }
}

class DashboardBackground extends StatelessWidget {
  const DashboardBackground({Key? key}) : super(key: key);
  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        Expanded(
          child: Container(
            color: const Color(0xFF158443),
          ),
        ),
        Expanded(
          child: Container(
            color: Colors.white,
          ),
        ),
      ],
    );
  }
}

class PathPainter extends CustomPainter {
  Path path;
  Path fillPath;
  PathPainter({required this.path, required this.fillPath});

  @override
  void paint(Canvas canvas, Size size) {
    // paint the line
    final paint = Paint()
      ..color = Colors.white
      ..style = PaintingStyle.stroke
      ..strokeWidth = 4.0;
    canvas.drawPath(path, paint);
    // paint the gradient fill
    paint.style = PaintingStyle.fill;
    paint.shader = ui.Gradient.linear(
      Offset.zero,
      Offset(0.0, size.height),
      [
        Colors.white.withOpacity(0.2),
        Colors.white.withOpacity(0.85),
      ],
    );
    canvas.drawPath(fillPath, paint);
  }

  @override
  bool shouldRepaint(CustomPainter oldDelegate) => true;
}

class ChartDataPoint {
  double value;
  ChartDataPoint({required this.value});
}
  1. layghing_data.dart包含模型类和要在图标上绘制的数据。
List<WeekData> weeksData = [  WeekData(    days: [      DayData(        day: 0,        laughs: 2,      ),      DayData(        day: 1,        laughs: 8,      ),      DayData(        day: 2,        laughs: 3,      ),      DayData(        day: 3,        laughs: 1,      ),      DayData(        day: 4,        laughs: 7,      ),      DayData(        day: 5,        laughs: 9,      ),      DayData(        day: 6,        laughs: 5,      ),    ],
  ),
  WeekData(
    days: [
      DayData(
        day: 0,
        laughs: 9,
      ),
      DayData(
        day: 1,
        laughs: 6,
      ),
      DayData(
        day: 2,
        laughs: 11,
      ),
      DayData(
        day: 3,
        laughs: 8,
      ),
      DayData(
        day: 4,
        laughs: 14,
      ),
      DayData(
        day: 5,
        laughs: 10,
      ),
      DayData(
        day: 6,
        laughs: 4,
      ),
    ],
  ),
  WeekData(
    days: [
      DayData(
        day: 0,
        laughs: 0,
      ),
      DayData(
        day: 1,
        laughs: 2,
      ),
      DayData(
        day: 2,
        laughs: 3,
      ),
      DayData(
        day: 3,
        laughs: 0,
      ),
      DayData(
        day: 4,
        laughs: 4,
      ),
      DayData(
        day: 5,
        laughs: 3,
      ),
      DayData(
        day: 6,
        laughs: 3,
      ),
    ],
  ),
];

WeekData zeroStateData = WeekData(
  days: [
    DayData(
      day: 0,
      laughs: 0,
    ),
    DayData(
      day: 1,
      laughs: 0,
    ),
    DayData(
      day: 2,
      laughs: 0,
    ),
    DayData(
      day: 3,
      laughs: 0,
    ),
    DayData(
      day: 4,
      laughs: 0,
    ),
    DayData(
      day: 5,
      laughs: 0,
    ),
    DayData(
      day: 6,
      laughs: 0,
    ),
  ],
);

class WeekData {
  WeekData({required this.days});
  List<DayData> days;
}

class DayData {
  DayData({required this.day, required this.laughs});
  int day;
  int laughs;
}
  1. components/slide_selector.dart包含一个在一周之间切换的开关。
import 'package:flutter/material.dart';

class SlideSelector extends StatefulWidget {
  List items;
  int defaultSelectedIndex;
  bool tappable;
  SlideSelector(
      {Key? key,
      required this.items,
      this.defaultSelectedIndex = 0,
      this.tappable = true})
      : super(key: key);
  @override
  _SlideSelectorState createState() => _SlideSelectorState();
}

class _SlideSelectorState extends State<SlideSelector> {
  int activeItemIndex = 0;

  @override
  void initState() {
    setState(() {
      activeItemIndex = widget.defaultSelectedIndex;
    });
    super.initState();
  }

  @override
  Widget build(BuildContext context) {
    return LayoutBuilder(
        builder: (BuildContext context, BoxConstraints constraints) {
      final buttonWidth = (constraints.maxWidth) / 3;
      return Container(
        height: 52,
        decoration: BoxDecoration(
          color: Colors.black.withOpacity(0.1),
          borderRadius: BorderRadius.circular(8),
        ),
        child: Stack(
          children: <Widget>[
            AnimatedPositioned(
              top: 0,
              left: activeItemIndex * buttonWidth,
              duration: const Duration(milliseconds: 200),
              curve: Curves.ease,
              child: Container(
                width: buttonWidth,
                height: 52,
                decoration: BoxDecoration(
                  color: Colors.white,
                  borderRadius: BorderRadius.circular(8),
                  boxShadow: [
                    BoxShadow(
                      spreadRadius: 0,
                      blurRadius: 8,
                      offset: const Offset(0.0, 3.0),
                      color: Colors.black.withOpacity(0.12),
                    ),
                    BoxShadow(
                      spreadRadius: 0,
                      blurRadius: 1,
                      offset: const Offset(0.0, 3.0),
                      color: Colors.black.withOpacity(0.04),
                    ),
                  ],
                ),
              ),
            ),
            Row(
              crossAxisAlignment: CrossAxisAlignment.center,
              mainAxisAlignment: MainAxisAlignment.spaceAround,
              mainAxisSize: MainAxisSize.max,
              children: widget.items.asMap().entries.map((entry) {
                final index = entry.key;
                final item = entry.value as SlideSelectorItem;
                return Flexible(
                  flex: 1,
                  child: GestureDetector(
                    onTap: () {
                      if (widget.tappable) {
                        item.onTap();
                        setState(() {
                          activeItemIndex = index;
                        });
                      }
                    },
                    child: Container(
                      height: 52,
                      // GestureDetector requires a color on the
                      // Container in order to recognize it
                      color: Colors.black.withOpacity(0.01),
                      padding: const EdgeInsets.symmetric(horizontal: 16),
                      child: Align(
                        alignment: Alignment.center,
                        child: Text(
                          item.text,
                          style: TextStyle(
                            fontSize: 16,
                            fontWeight: FontWeight.w600,
                            color: index == activeItemIndex
                                ? Colors.black
                                : Colors.white,
                          ),
                        ),
                      ),
                    ),
                  ),
                );
              }).toList(),
            ),
          ],
        ),
      );
    });
  }
}

class SlideSelectorItem {
  String text;
  Function onTap;
  SlideSelectorItem({required this.text, required this.onTap});
}
  1. components/week_summary.dart包含每周摘要UI。
import 'package:flutter/material.dart';
import '../laughing_data.dart';

class WeekSummary extends StatelessWidget {
  const WeekSummary({Key? key, required this.week}) : super(key: key);
  final int week;

  String calculateLaughs([String filter = '']) {
    final total = weeksData[week - 1].days.fold(0, (int acc, DayData cur) {
      if ((filter == 'weekday' && (cur.day == 0 || cur.day == 6)) ||
          (filter == 'weekend' && (cur.day > 0 && cur.day < 6))) {
        return acc;
      }
      return acc + cur.laughs;
    });
    return total.toString();
  }

  String calculateMinMax([String filter = '']) {
    final dayMax = weeksData[week - 1].days.reduce((DayData a, DayData b) {
      if (a.laughs > b.laughs) {
        return filter == 'worst' ? b : a;
      } else
        return filter == 'worst' ? a : b;
    });
    return [      'Sunday',      'Monday',      'Tuesday',      'Wednesday',      'Thursday',      'Friday',      'Saturday'    ][dayMax.day];
  }

  @override
  Widget build(BuildContext context) {
    return Container(
      padding: const EdgeInsets.all(20),
      color: Colors.white,
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Padding(
            padding: const EdgeInsets.symmetric(vertical: 12),
            child: Text(
              'Week $week',
              style: const TextStyle(
                color: Colors.black,
                fontSize: 18,
                fontWeight: FontWeight.w600,
              ),
            ),
          ),
          ListItem(label: 'Total laughs', value: calculateLaughs()),
          ListItem(label: 'Weekday laughs', value: calculateLaughs('weekday')),
          ListItem(label: 'Weekend laughs', value: calculateLaughs('weekend')),
          ListItem(label: 'Funniest day', value: calculateMinMax('funniest')),
          ListItem(
            label: 'Worst day',
            value: calculateMinMax('worst'),
            hideDivider: true,
          ),
        ],
      ),
    );
  }
}

class ListItem extends StatelessWidget {
  const ListItem(
      {Key? key,
      required this.label,
      required this.value,
      this.hideDivider = false})
      : super(key: key);
  final String label;
  final String value;
  final bool hideDivider;

  @override
  Widget build(BuildContext context) {
    return Container(
      padding: const EdgeInsets.symmetric(vertical: 20),
      decoration: BoxDecoration(
        border: Border(
          bottom: BorderSide(
            color: const Color(0xFFD4D4D4).withOpacity(hideDivider ? 0 : 1),
            width: 1,
          ),
        ),
      ),
      child: Row(
        children: [
          Text(
            label,
            style: const TextStyle(
              fontSize: 14,
              fontWeight: FontWeight.w400,
              color: Color(0xFF565656),
            ),
          ),
          const Spacer(),
          Text(
            value,
            style: const TextStyle(
              fontSize: 14,
              fontWeight: FontWeight.w600,
              color: Color(0xFF565656),
            ),
          )
        ],
      ),
    );
  }
}
  1. components/chart_labels.dart包含显示图表标签的UI。
import 'package:flutter/material.dart';

import '../laughing_data.dart';

class ChartDayLabels extends StatelessWidget {
  const ChartDayLabels(
      {Key? key, required this.leftPadding, required this.rightPadding})
      : super(key: key);

  final double leftPadding;
  final double rightPadding;

  Offset labelOffset(int length, double i) {
    final segment = 1 / (length - 1);
    final offsetValue = (i - ((length - 1) / 2)) * segment;
    return Offset(offsetValue, 0);
  }

  @override
  Widget build(BuildContext context) {
    return Container(
      height: 40,
      decoration: BoxDecoration(
        gradient: LinearGradient(
          stops: [0.0, 1.0],
          begin: Alignment.bottomCenter,
          end: Alignment.topCenter,
          colors: [Colors.white, Colors.white.withOpacity(0.85)],
        ),
      ),
      child: Padding(
        padding: EdgeInsets.only(left: leftPadding, right: rightPadding),
        child: Row(
          mainAxisAlignment: MainAxisAlignment.spaceBetween,
          children: ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
              .asMap()
              .entries
              .map(
                (entry) => FractionalTranslation(
                  translation: labelOffset(7, entry.key.toDouble()),
                  child: SizedBox(
                    width: 36,
                    child: Text(
                      entry.value,
                      textAlign: TextAlign.center,
                      style: const TextStyle(
                        fontSize: 14,
                        fontWeight: FontWeight.w600,
                        color: Color(0xFFA9A9A9),
                      ),
                    ),
                  ),
                ),
              )
              .toList(),
        ),
      ),
    );
  }
}

class ChartLaughLabels extends StatelessWidget {
  const ChartLaughLabels({
    Key? key,
    required this.chartHeight,
    required this.topPadding,
    required this.leftPadding,
    required this.rightPadding,
    required this.weekData,
  }) : super(key: key);

  final double chartHeight;
  final double topPadding;
  final double leftPadding;
  final double rightPadding;
  final WeekData weekData;

  @override
  Widget build(BuildContext context) {
    const labelCount = 4;
    final maxDay = weekData.days.reduce((DayData a, DayData b) {
      return a.laughs > b.laughs ? a : b;
    });
    final rowHeight = (chartHeight) / labelCount;

    final labels = <double>[];
    for (var i = 0; i < labelCount; i++) {
      labels.add(maxDay.laughs.toDouble() -
          (i * maxDay.laughs.toDouble() / (labelCount - 1)));
    }

    Offset labelOffset(int length, double i) {
      final segment = 1 / (length - 1);
      final offsetValue = (i - ((length - 1) / 2)) * segment;
      return Offset(0, offsetValue);
    }

    return Container(
      height: chartHeight + topPadding,
      padding: EdgeInsets.only(top: topPadding),
      child: Column(
        mainAxisAlignment: MainAxisAlignment.spaceBetween,
        children: labels
            .asMap()
            .entries
            .map(
              (entry) => FractionalTranslation(
                translation: labelOffset(labelCount, entry.key.toDouble()),
                child: Container(
                  height: rowHeight,
                  alignment: Alignment.center,
                  child: Row(
                    children: [
                      SizedBox(
                        width: leftPadding,
                        child: Text(
                          entry.value.toStringAsFixed(1),
                          textAlign: TextAlign.center,
                        ),
                      ),
                      Expanded(
                        child: Container(
                          height: 2,
                          color: Colors.black.withOpacity(0.15),
                        ),
                      ),
                      SizedBox(width: rightPadding),
                    ],
                  ),
                ),
              ),
            )
            .toList(),
      ),
    );
  }
}

final-app.gif

十三、github仓库地址

GitHub地址:curce_line_demo 欢迎star、fork、issue。

原文翻译博客:Curved Line Charts in Flutter