教你如何写一个简单的Flutter折线图控件

2,808 阅读8分钟

先来看一下效果

表格可以左右滑动,我写的比较简单,就是做个演示,知识点如下:

  1. CustomPainter的使用
  2. 如何画连续的线段
  3. 如何添加滚动事件
  4. 如何使用clipRect截取绘制范围,并且不影响其他图层
  5. 如何绘制文字

Let's go!

1. 定义自定义表格的属性

先给我们这个示例定义一些属性

  //折线图的背景颜色
  Color bgColor;
  //x轴与y轴的颜色
  Color xyColor;
  //是否显示x轴与y轴的基准线,就是中间的网格线
  bool showBaseline;
  //实际的数据,用来显示折线
  List<ChartData> dataList;
  //x轴之间的间隔,就是图中Data之间的间隔
  double columnSpace;
  //表格距离左边的距离,不设置距离的话,x轴和y轴的标识没空间显示
  int paddingLeft;
  //表格距离顶部的距离
  int paddingTop;
  //表格距离底部的距离
  int paddingBottom;
  //绘制x轴、y轴、标记文字的画笔
  Paint linePaint;
  //标记线的长度,就是表格外面的那段线
  int markLineLength;
  //y轴数据最大值
  int maxYValue;
  //y轴分多少行
  int yCount;
  //折线的颜色
  Color polygonalLineColor;
  //x轴所有内容的偏移量,用来在滑动的时候改变内容的位置
  double xOffset;
  //该值保证最后一条数据的底部文字能正常显示出来
  int paddingRight = 30;
  //内部折线图的实际宽度
  double realChartRectWidth;
  Function xOffsetSet = (double xOffset) {};

然后是构造函数,在这里做一些属性的初始化:

  LineChartWidget({
    @required this.dataList,
    @required this.maxYValue,
    @required this.yCount,
    @required this.xOffsetSet,
    this.bgColor = Colors.white,
    this.xyColor = Colors.black,
    this.showBaseline = false,
    this.columnSpace,
    this.paddingLeft,
    this.paddingTop,
    this.paddingBottom,
    this.markLineLength,
    this.polygonalLineColor = Colors.blue,
    this.xOffset,
  }) {
    linePaint = Paint()..color = xyColor;
    realChartRectWidth = (dataList.length - 1) * columnSpace;
  }

realChartRectWidth是我们内部矩形的宽度,如下图红色区域所示,创建这个矩形是为了方便后面坐标的计算

然后我们就开始绘制吧,绘制在paint(Canvas canvas, Size size)方法里:

2. 绘制背景颜色

canvas.drawRect(Rect.fromLTWH(0, 0, size.width, size.height), Paint()..color = bgColor);

其中size是控件的宽高大小,Paint是我们的画笔,color就是画笔的颜色

3. 创建一个矩形,方便后续绘制

Rect innerRect = Rect.fromPoints(
   Offset(paddingLeft.toDouble(), paddingTop.toDouble()),
   Offset(size.width, size.height - paddingBottom),
 );

这个矩形就是上图的红色部分,paddingLeft和paddingTop就是红色矩形距离左边缘和上边缘的距离

4. 画y轴

 canvas.drawLine(innerRect.topLeft, innerRect.bottomLeft.translate(0, markLineLength.toDouble()), linePaint);

innerRect.bottomLeft.translate这个方法只是将你传入的参数做了一个相加,可以看内部实现,这样我们的y轴就比innerRect长了一点点,就是图中Data A等标签上面多的那一小部分线段。

5. 画x轴

canvas.drawLine(innerRect.bottomLeft, innerRect.bottomRight, linePaint);

这个就是上图中红色矩形的最下面的那条边的绘制

6. 画y轴标记

 double ySpace = innerRect.height / yCount;
 double startX = innerRect.topLeft.dx - markLineLength;
 double startY;
 for (int i = 0; i < yCount + 1; i++) {
   startY = innerRect.topLeft.dy + i * ySpace;
   if (showBaseline) {
     canvas.drawLine(
       Offset(innerRect.topLeft.dx - markLineLength, startY),
       Offset(innerRect.topLeft.dx + innerRect.width, startY),
       linePaint,
     );
   } else {
     canvas.drawLine(
       Offset(innerRect.topLeft.dx - markLineLength, startY),
       Offset(innerRect.topLeft.dx, startY),
       linePaint,
     );
   }
   drawYText(
     (i * maxYValue ~/ yCount).toString(),
     Offset(innerRect.topLeft.dx - markLineLength, innerRect.bottomLeft.dy - i * ySpace),
     canvas,
   );
 }

ySpace就是y轴每个标签之间的距离间隔,startX就是红色矩形的左边的x左边减去那个小线段的长度,就是这个小线段的起始坐标,再下面的逻辑也很简单,如果显示基准线,就是网格,就从这个小线段的x轴画到红色矩形的最右边,否则就只画到红色矩形的左边。drawYText方法要说明一下,Canvas是可以画text的,但是我觉得直接用TextPainter更简单一些,这个方法的实现如下(drawXText一起给出):

 List getTextPainterAndSize(String text) {
   TextPainter textPainter = TextPainter(
     textDirection: TextDirection.ltr,
     text: TextSpan(
       text: text,
       style: TextStyle(color: Colors.black),
     ),
   );
   textPainter.layout();
   Size size = textPainter.size;
   return [textPainter, size];
 }

 void drawYText(String text, Offset topLeftOffset, Canvas canvas) {
   List list = getTextPainterAndSize(text);
   list[0].paint(canvas, topLeftOffset.translate(-list[1].width, -list[1].height / 2));
 }

 void drawXText(String text, Offset topLeftOffset, Canvas canvas) {
   List list = getTextPainterAndSize(text);
   list[0].paint(canvas, topLeftOffset.translate(-list[1].width / 2, 0));
 }

7. 然后我们用一个集合保存每个实际数据的值在屏幕中的x、y坐标值,也就是折线图的点集合

List<Pair<double, double>> pointList = [];

8. 画x轴下面的标志文字

 //画x轴标记
 int xCount = dataList.length;
 startY = innerRect.bottom + markLineLength;
 for (int i = 0; i < xCount; i++) {
   startX = innerRect.bottomLeft.dx + i * columnSpace + xOffset;
   if (innerRect.bottomLeft.dx + xOffset < innerRect.left) {//标记1
     canvas.save();
     canvas.clipRect(
       Rect.fromLTWH(
         innerRect.left,
         innerRect.top,
         innerRect.width,
         innerRect.height,
       ),
     );
   }
   //保证向右拖动的时候第一个数据保持在起始位置
   if (i == 0 && startX > paddingLeft) {//标记2
     startX = innerRect.bottomLeft.dx;
     // 在这里将LineChart的xOffset置为0,否则LineChart向右滑到第一个值的时候继续向右滑动会导致xOffset累加向右拖动的值,
     // 然后会导致向左拖动的时候只能等xOffset等于0的时候UI才会变化,这样看起来就是向左拖动但是UI没有变化
     // 所以这里加此判断
     xOffset = 0;
     xOffsetSet(0.toDouble());
   }
   pointList.add(
     Pair(
       startX,
       //内矩形高度减去数据实际值的实际像素大小,再加上顶部空白的距离
       innerRect.height - dataList[i].value / maxYValue * innerRect.height + paddingTop,
     ),
   );
   if (showBaseline) {
     canvas.drawLine(
       Offset(startX, innerRect.top),
       Offset(startX, startY),
       linePaint,
     );
   } else {
     canvas.drawLine(
       Offset(startX, innerRect.bottom),
       Offset(startX, startY),
       linePaint,
     );
   }
   if (innerRect.bottomLeft.dx + xOffset < innerRect.left) {//标记3
     canvas.restore();
   }
   drawXText(
     dataList[i].type,
     Offset(innerRect.bottomLeft.dx + i * columnSpace + xOffset, startY),
     canvas,
   );
 }

这部分代码多一些,其中columnSpace是我们在创建折线图的时候,给x轴的标记之间设置的间隔距离。而xOffset是我们拖动折线图的时候,x轴的标签和竖线移动的偏移量。

其中标记1的代码说明一下,为什么有这个判断呢,这是为了折线图在向左拖动的时候,绘制的折线图竖线保持只显示在红色矩形里,做到这一点是靠canvas.save();canvas.clipRect这两个方法,save方法可以保证canvas接下来的绘制不影响之前的绘制,然后clipRect方法指定了接下来的绘制范围在红色矩形中。

然后标记2的判断,是为了在向右拖动折线图的时候,如果第一个数据已经到了红色矩形最左边的位置,就让用户继续向右拖动无效,不再改变x轴的坐标和竖线的startX的值,不再改变xOffset的偏移量,这里有个关键操作是将刚才重置的xOffset偏移量通知到上层节点(一会给出代码),告诉上层节点改变xOffset偏移量,因为此时已经将xOffset的值改变了,需要告诉上层节点以保持两个值的统一,否则只在这里改变了偏移量而上层节点没有改变,会导致下次传入xOffset这个值的时候不正确。

pointList.add这个方法保存了实际数据的实际x、y坐标,计算方法应该不用说了吧很简单。

其中标记3,是为了和上面的sava方法对应,这两个地方的判断方式一样,sava必须和restore方法一一对应,在这里我们恢复了之前的绘制范围,然后我们继续绘制文字,如果不恢复绘制范围的话,我们的文字是看不到的,因为文字的坐标是在红色矩形外面。

9. 画折线

 canvas.save();
 canvas.clipRect(
   Rect.fromLTWH(
     paddingLeft.toDouble(),
     paddingTop.toDouble(),
     innerRect.width,
     innerRect.height,
   ),
 );
 canvas.drawPoints(
   ///PointMode的枚举类型有三个,points(点),lines(线,隔点连接),polygon(线,相邻连接)
   PointMode.polygon,
   pointList.map((pair) => Offset(pair.first, pair.last)).toList(),
   Paint()
     ..color = polygonalLineColor
     ..strokeWidth = 2,
 );
 canvas.restore();

同样,我们的折线必须在红色矩形范围里,所以这里又有一个save和restore操作,这里很简单就不多说了。

10. 然后我们向此CustomPainter添加手势操作,手势操作主要就是改变刚才的xOffset的值,代码如下

class LineChart extends StatefulWidget {
   final double width;
   final double height;
   //柱状图的背景颜色
   final Color bgColor;
   //x轴与y轴的颜色
   final Color xyColor;
   //柱状图的颜色
   final Color columnarColor;
   //是否显示x轴与y轴的基准线
   final bool showBaseline;
   //实际的数据
   final List<ChartData> dataList;
   //每列之间的间隔
   final double columnSpace;
   //控件距离左边的距离
   final int paddingLeft;
   //控件距离顶部的距离
   final int paddingTop;
   //控件距离底部的距离
   final int paddingBottom;
   //标记线的长度
   final int markLineLength;
   //y轴最大值
   final int maxYValue;
   //y轴分多少行
   final int yCount;
   //折线的颜色
   final Color polygonalLineColor;
   //x轴所有内容的偏移量
   final double xOffset;
 
   LineChart(
     this.width,
     this.height, {
     @required this.dataList,
     @required this.maxYValue,
     @required this.yCount,
     this.bgColor = Colors.white,
     this.xyColor = Colors.black,
     this.columnarColor = Colors.blue,
     this.showBaseline = false,
     this.columnSpace = 60,
     this.paddingLeft = 40,
     this.paddingTop = 30,
     this.paddingBottom = 30,
     this.markLineLength = 10,
     this.polygonalLineColor = Colors.blue,
     this.xOffset = 0,
   });
 
   @override
   _LineChartState createState() => _LineChartState();
 }
 
 class _LineChartState extends State<LineChart> {
   double xOffset;
 
   @override
   void initState() {
     xOffset = widget.xOffset;
     super.initState();
   }
 
   @override
   Widget build(BuildContext context) {
     return GestureDetector(
       onHorizontalDragUpdate: (DragUpdateDetails details) {
         setState(() {
           xOffset += details.primaryDelta;
         });
       },
       onHorizontalDragDown: (DragDownDetails details){
         print("onHorizontalDragDown");
       },
       onHorizontalDragCancel: (){
         print("onHorizontalDragCancel");
       },
       onHorizontalDragEnd: (DragEndDetails details){
         print("onHorizontalDragEnd");
       },
       onHorizontalDragStart: (DragStartDetails details){
         print("onHorizontalDragStart");
       },
       child: CustomPaint(
         size: Size(widget.width, widget.height),
         painter: LineChartWidget(
           bgColor: widget.bgColor,
           xyColor: widget.xyColor,
           showBaseline: widget.showBaseline,
           dataList: widget.dataList,
           maxYValue: widget.maxYValue,
           yCount: widget.yCount,
           columnSpace: widget.columnSpace,
           paddingLeft: widget.paddingLeft,
           paddingTop: widget.paddingTop,
           paddingBottom: widget.paddingBottom,
           markLineLength: widget.markLineLength,
           polygonalLineColor: Colors.blue,
           xOffset: xOffset,
           xOffsetSet: (double xOffset) {//标记4
             this.xOffset = xOffset;
           },
         ),
       ),
     );
   }
 }

这段代码最关键的地方在于将CustomPaint作为了GestureDetector的child,然后在onHorizontalDragUpdate方法里,我们将xOffset记录下来并且刷新UI,而该参数传给了LineChartWidget所以LineChartWidget中的xOffset也发生了改变,这样红色矩形里的折线图,通过偏移量就可以计算x轴的标签和竖线的x坐标值,然后标记4就是最上面的折线图类在向右拖动的时候如果第一个数据已经到了红色矩形的左边缘,就停止滑动,在这里将xOffset值传递了过来这样改变了LineChart这里的值。

11. 完整代码如下:

 import 'dart:ui';
 
 import 'package:campsite_flutter/util/collection.dart';
 import 'package:flutter/material.dart';
 
 /// 自定义折线图
 /// 作者:liuhc
 class LineChartWidget extends CustomPainter {
   //折线图的背景颜色
   Color bgColor;
   //x轴与y轴的颜色
   Color xyColor;
   //是否显示x轴与y轴的基准线
   bool showBaseline;
   //实际的数据
   List<ChartData> dataList;
   //x轴之间的间隔
   double columnSpace;
   //表格距离左边的距离
   int paddingLeft;
   //表格距离顶部的距离
   int paddingTop;
   //表格距离底部的距离
   int paddingBottom;
   //绘制x轴、y轴、标记文字的画笔
   Paint linePaint;
   //标记线的长度
   int markLineLength;
   //y轴数据最大值
   int maxYValue;
   //y轴分多少行
   int yCount;
   //折线的颜色
   Color polygonalLineColor;
   //x轴所有内容的偏移量,用来在滑动的时候改变内容的位置
   double xOffset;
   //该值保证最后一条数据的底部文字能正常显示出来
   int paddingRight = 30;
   //内部折线图的实际宽度
   double realChartRectWidth;
   Function xOffsetSet = (double xOffset) {};
 
   LineChartWidget({
     @required this.dataList,
     @required this.maxYValue,
     @required this.yCount,
     @required this.xOffsetSet,
     this.bgColor = Colors.white,
     this.xyColor = Colors.black,
     this.showBaseline = false,
     this.columnSpace,
     this.paddingLeft,
     this.paddingTop,
     this.paddingBottom,
     this.markLineLength,
     this.polygonalLineColor = Colors.blue,
     this.xOffset,
   }) {
     linePaint = Paint()..color = xyColor;
     realChartRectWidth = (dataList.length - 1) * columnSpace;
   }
 
   @override
   void paint(Canvas canvas, Size size) {
     //画背景颜色
     canvas.drawRect(Rect.fromLTWH(0, 0, size.width, size.height), Paint()..color = bgColor);
     //创建一个矩形,方便后续绘制
     Rect innerRect = Rect.fromPoints(
       Offset(paddingLeft.toDouble(), paddingTop.toDouble()),
       Offset(size.width, size.height - paddingBottom),
     );
     //画y轴
     canvas.drawLine(innerRect.topLeft, innerRect.bottomLeft.translate(0, markLineLength.toDouble()), linePaint);
     //画x轴
     canvas.drawLine(innerRect.bottomLeft, innerRect.bottomRight, linePaint);
     //画y轴标记
     double ySpace = innerRect.height / yCount;
     double startX = innerRect.topLeft.dx - markLineLength;
     double startY;
     for (int i = 0; i < yCount + 1; i++) {
       startY = innerRect.topLeft.dy + i * ySpace;
       if (showBaseline) {
         canvas.drawLine(
           Offset(innerRect.topLeft.dx - markLineLength, startY),
           Offset(innerRect.topLeft.dx + innerRect.width, startY),
           linePaint,
         );
       } else {
         canvas.drawLine(
           Offset(innerRect.topLeft.dx - markLineLength, startY),
           Offset(innerRect.topLeft.dx, startY),
           linePaint,
         );
       }
       drawYText(
         (i * maxYValue ~/ yCount).toString(),
         Offset(innerRect.topLeft.dx - markLineLength, innerRect.bottomLeft.dy - i * ySpace),
         canvas,
       );
     }
     //保存每个实际数据的值在屏幕中的x、y坐标值
     List<Pair<double, double>> pointList = [];
     //画x轴标记
     int xCount = dataList.length;
     startY = innerRect.bottom + markLineLength;
     for (int i = 0; i < xCount; i++) {
       startX = innerRect.bottomLeft.dx + i * columnSpace + xOffset;
       if (innerRect.bottomLeft.dx + xOffset < innerRect.left) {
         canvas.save();
         canvas.clipRect(
           Rect.fromLTWH(
             innerRect.left,
             innerRect.top,
             innerRect.width,
             innerRect.height,
           ),
         );
       }
       //保证向右拖动的时候第一个数据保持在起始位置
       if (i == 0 && startX > paddingLeft) {
         startX = innerRect.bottomLeft.dx;
         // 在这里将LineChart的xOffset置为0,否则LineChart向右滑到第一个值的时候继续向右滑动会导致xOffset累加向右拖动的值,
         // 然后会导致向左拖动的时候只能等xOffset等于0的时候UI才会变化,这样看起来就是向左拖动但是UI没有变化
         // 所以这里加此判断
         xOffset = 0;
         xOffsetSet(0.toDouble());
       }
       pointList.add(
         Pair(
           startX,
           //内矩形高度减去数据实际值的实际像素大小,再加上顶部空白的距离
           innerRect.height - dataList[i].value / maxYValue * innerRect.height + paddingTop,
         ),
       );
       if (showBaseline) {
         canvas.drawLine(
           Offset(startX, innerRect.top),
           Offset(startX, startY),
           linePaint,
         );
       } else {
         canvas.drawLine(
           Offset(startX, innerRect.bottom),
           Offset(startX, startY),
           linePaint,
         );
       }
       if (innerRect.bottomLeft.dx + xOffset < innerRect.left) {
         canvas.restore();
       }
       drawXText(
         dataList[i].type,
         Offset(innerRect.bottomLeft.dx + i * columnSpace + xOffset, startY),
         canvas,
       );
     }
     //画折线
     canvas.save();
     canvas.clipRect(
       Rect.fromLTWH(
         paddingLeft.toDouble(),
         paddingTop.toDouble(),
         innerRect.width,
         innerRect.height,
       ),
     );
     canvas.drawPoints(
       ///PointMode的枚举类型有三个,points(点),lines(线,隔点连接),polygon(线,相邻连接)
       PointMode.polygon,
       pointList.map((pair) => Offset(pair.first, pair.last)).toList(),
       Paint()
         ..color = polygonalLineColor
         ..strokeWidth = 2,
     );
     canvas.restore();
   }
 
   List getTextPainterAndSize(String text) {
     TextPainter textPainter = TextPainter(
       textDirection: TextDirection.ltr,
       text: TextSpan(
         text: text,
         style: TextStyle(color: Colors.black),
       ),
     );
     textPainter.layout();
     Size size = textPainter.size;
     return [textPainter, size];
   }
 
   void drawYText(String text, Offset topLeftOffset, Canvas canvas) {
     List list = getTextPainterAndSize(text);
     list[0].paint(canvas, topLeftOffset.translate(-list[1].width, -list[1].height / 2));
   }
 
   void drawXText(String text, Offset topLeftOffset, Canvas canvas) {
     List list = getTextPainterAndSize(text);
     list[0].paint(canvas, topLeftOffset.translate(-list[1].width / 2, 0));
   }
 
   @override
   bool shouldRepaint(CustomPainter oldDelegate) {
     return oldDelegate != this;
   }
 }
 
 class ChartData {
   String type;
   double value;
 
   ChartData(this.type, this.value);
 }
 
 class LineChart extends StatefulWidget {
   final double width;
   final double height;
   //柱状图的背景颜色
   final Color bgColor;
   //x轴与y轴的颜色
   final Color xyColor;
   //柱状图的颜色
   final Color columnarColor;
   //是否显示x轴与y轴的基准线
   final bool showBaseline;
   //实际的数据
   final List<ChartData> dataList;
   //每列之间的间隔
   final double columnSpace;
   //控件距离左边的距离
   final int paddingLeft;
   //控件距离顶部的距离
   final int paddingTop;
   //控件距离底部的距离
   final int paddingBottom;
   //标记线的长度
   final int markLineLength;
   //y轴最大值
   final int maxYValue;
   //y轴分多少行
   final int yCount;
   //折线的颜色
   final Color polygonalLineColor;
   //x轴所有内容的偏移量
   final double xOffset;
 
   LineChart(
     this.width,
     this.height, {
     @required this.dataList,
     @required this.maxYValue,
     @required this.yCount,
     this.bgColor = Colors.white,
     this.xyColor = Colors.black,
     this.columnarColor = Colors.blue,
     this.showBaseline = false,
     this.columnSpace = 60,
     this.paddingLeft = 40,
     this.paddingTop = 30,
     this.paddingBottom = 30,
     this.markLineLength = 10,
     this.polygonalLineColor = Colors.blue,
     this.xOffset = 0,
   });
 
   @override
   _LineChartState createState() => _LineChartState();
 }
 
 class _LineChartState extends State<LineChart> {
   double xOffset;
 
   @override
   void initState() {
     xOffset = widget.xOffset;
     super.initState();
   }
 
   @override
   Widget build(BuildContext context) {
     return GestureDetector(
       onHorizontalDragUpdate: (DragUpdateDetails details) {
 //        print("DragUpdateDetails");
         setState(() {
           xOffset += details.primaryDelta;
         });
       },
       onHorizontalDragDown: (DragDownDetails details){
         print("onHorizontalDragDown");
       },
       onHorizontalDragCancel: (){
         print("onHorizontalDragCancel");
       },
       onHorizontalDragEnd: (DragEndDetails details){
         print("onHorizontalDragEnd");
       },
       onHorizontalDragStart: (DragStartDetails details){
         print("onHorizontalDragStart");
       },
       child: CustomPaint(
         size: Size(widget.width, widget.height),
         painter: LineChartWidget(
           bgColor: widget.bgColor,
           xyColor: widget.xyColor,
           showBaseline: widget.showBaseline,
           dataList: widget.dataList,
           maxYValue: widget.maxYValue,
           yCount: widget.yCount,
           columnSpace: widget.columnSpace,
           paddingLeft: widget.paddingLeft,
           paddingTop: widget.paddingTop,
           paddingBottom: widget.paddingBottom,
           markLineLength: widget.markLineLength,
           polygonalLineColor: Colors.blue,
           xOffset: xOffset,
           xOffsetSet: (double xOffset) {
             this.xOffset = xOffset;
           },
         ),
       ),
     );
   }
 }
 
 void main() {
   runApp(
     MaterialApp(
       home: Test(),
     ),
   );
 }
 
 class Test extends StatelessWidget {
   @override
   Widget build(BuildContext context) {
     Size size = MediaQuery.of(context).size;
     return Scaffold(
       appBar: AppBar(
         title: Text("自定义折线图"),
       ),
       body: Container(
         child: LineChart(
           size.width,
           300,
 //          bgColor: Colors.red,
           xOffset: 10,
           showBaseline: true,
           maxYValue: 600,
           yCount: 6,
           dataList: [
             ChartData("Data A", 100),
             ChartData("Data B", 300),
             ChartData("Data C", 200),
             ChartData("Data D", 500),
             ChartData("Data E", 450),
             ChartData("Data F", 230),
             ChartData("Data G", 270),
             ChartData("Data H", 170),
           ],
         ),
       ),
     );
   }
 }

Pair类:

 class Pair<E, F> {
   E first;
   F last;
 
   Pair(this.first, this.last);
 
   String toString() => '($first, $last)';
 
   bool operator ==(other) {
     if (other is! Pair) return false;
     return other.first == first && other.last == last;
   }
 
   int get hashCode => first.hashCode ^ last.hashCode;
 }

欢迎加入Flutter开发群457664582,点击加入,大家一起学习讨论