likes
comments
collection
share

Flutter自绘图表库技术方案(一):基础

作者站长头像
站长
· 阅读数 4

背景

flutter 三方图表库大多数属性无法自定义,难以满足产品千奇百怪的需求,那我们就自己画一个。

图表定义

定义图表组件与基础参数

class CustomChart {
    /// 图标组件的高度
    final double chartHeight;
    /// 期望的图表每个Item间隔宽度
    final double? preferDividerWidth;
    /// 期望的图表每个Item内容宽度
    final double? preferContentWidth;
    /// X轴列表,类型为List<T>
    final List<T> xAxisList;
    /// 图表系列列表,类型为List<BaseSeries<T>>
    final List<BaseSeries<T>> seriesList;
    /// 自定义X轴样式
    final BaseXAxis<T>? customXAxis;
    /// 自定义标题样式
    final BaseTitle? customTitle;
    /// 自定义左侧Y轴样式
    final BaseYAxis? customY1Axis;
    /// 自定义右侧Y轴样式
    final BaseYAxis? customY2Axis;
    /// 图表POP是否展示标题
    final bool showIndicatorTitle;
    /// 图表POP是否展示圆点
    final bool showIndicatorPoint;
    
    @override
    Widget build(BuildContext context) {
      return Container(
        decoration: BoxDecoration(color: backgroundColor),
        child: FocusableGestureDetector(
          isClickable: isClickable,
          builder: (offset) => LayoutBuilder(
            builder: (context, constraints) => CustomSingleChildLayout(
                  delegate: ChartBoxLayoutDelegate(size: Size(constraints.maxWidth, chartHeight)),
              child: CustomPaint(
                painter: ChartBoxPainter<T>(
                  offset: offset,
                  customContent: preferContentWidth,
                  xdcustomDivider: preferDividerWidth,
                  xAxisList: xAxisList,
                  seriesList: seriesList,
                  customXAxis: customXAxis,
                  customTitle: customTitle,
                  customY1Axis: customY1Axis,
                  customY2Axis: customY2Axis,
                  showIndicatorTitle: showIndicatorTitle,
                  showIndicatorPoint: showIndicatorPoint,
                  onRenderFinish: onRenderFinish,
                ),
              ),
            ),
          ),
        ),
      );
    }
}

图表样式(系列)

各种图表样式继承自BaseSeries类,如需实现新的图表样式,只需继承自该类并重新实现方法。

abstract class BaseSeries<T> {
  /// 数值参考的Y轴,分别为Y1轴(left)或Y2轴(right)
  final YAxisPosition yAxisPosition;

  /// 系列数据
  List<BaseSeriesItem<T>> get data;

  /// 如果需要弹出POP指示器,需传入此参数作为该系列的标题
  final ChartIndicatorTitle? indicatorTitle;

  /// POP指示器标题的兜底值,一般为'<empty>'
  final String name;

  /// 绘制该系列的画笔采用的颜色值
  final Color color;

  BaseSeries({
    this.yAxisPosition = YAxisPosition.left,
    required this.color,
    required this.name,
    this.indicatorTitle,
  });

  /// 通过X轴的某一项获取对应的数据项
  BaseSeriesItem<T>? itemAt(T title);

  /// 通过X轴的某一项获取对应的数据项列表(散点图可能会包含多个值,所以返回列表)
  List<BaseSeriesItem<T>> itemsAt(T title);

  /// 获取该系列的数据在Y轴的范围,可以帮助确定Y轴的刻度
  ValueRange bounds(List<T> xAxisList);

  /// 绘制该系列的图表内容
  draw(
    Canvas canvas,
    List<T> xAxisList,
    ChartScale scale,
    BaseYAxis yAxis,
    Offset? offset,
  );

  /// 点击该系列的图表内容时,绘制在图表上添加阴影或高亮的效果,每个系列会重写此方法来自定义效果
  void drawSelection(Canvas canvas, List<T> xAxisList, ChartScale scale, BaseYAxis yAxis, Offset? offset);
}

/// 图表系列数据项
abstract class BaseSeriesItem<T> {
  /// X轴的标题
  final T title;

  /// Y轴的值
  final double value;

  final List<ChartIndicator> indicators;
  final ChartIndicator? indicator;

  BaseSeriesItem({required this.title, required this.value, this.indicators = const [], this.indicator});
}

ChartBoxPainter

@override
void paint(Canvas canvas, Size size) {
  final startTime = DateTime.now().microsecondsSinceEpoch;
  final containerRect = Rect.fromLTWH(0, 0, size.width, size.height);
  final contentRect = Rect.fromLTRB(
    customY1Axis?.width ?? 10,
    customTitle?.height ?? 10,
    size.width - (customY2Axis?.width ?? 10),
    size.height - (customXAxis?.height ?? 0),
  );
  final xAxisScale = _getXAxisScale(containerRect, contentRect);

  // 绘制Y轴
  customY1Axis?.draw(canvas, xAxisScale, YAxisPosition.left);
  customY2Axis?.draw(canvas, xAxisScale, YAxisPosition.right);

  // 绘制标题
  customTitle?.draw(canvas, xAxisScale);

  // 绘制X轴
  if (customXAxis != null) {
    customXAxis?.drawBackground(canvas, xAxisList, xAxisScale);
    customXAxis?.drawXAxis(canvas, xAxisList, xAxisScale, offset);
  }

  for (var series in seriesList) {
    final yAxis = currentYAxis(series);
    if (yAxis == null) {
      continue;
    }

    // 绘制数据
    series.draw(
      canvas,
      xAxisList,
      xAxisScale,
      yAxis,
      offset,
    );
    
    // 绘制选中的部分的阴影
    series.drawSelection(
      canvas,
      xAxisList,
      xAxisScale,
      yAxis,
      offset,
    );
  }

  // 绘制选中的点的信息
  customXAxis?.drawIndicator(
    canvas,
    xAxisList,
    seriesList,
    xAxisScale,
    offset,
    showTitle: showIndicatorTitle,
    showPoint: showIndicatorPoint,
  );

  final endTime = DateTime.now().microsecondsSinceEpoch;
  if (offset == null) {
    onRenderFinish?.call(endTime - startTime);
  }
}

实战快速接入

画一个柱状图

  • 定义数据
final xAxisList = <String>["1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12"];
final data = <BarSeriesItem<String>>[
  BarSeriesItem(title: "1", value: 21),
  BarSeriesItem(title: "2", value: 25),
  BarSeriesItem(title: "3", value: 20),
  BarSeriesItem(title: "4", value: 14),
  BarSeriesItem(title: "5", value: 15),
  BarSeriesItem(title: "6", value: 16),
  BarSeriesItem(title: "7", value: 37),
  BarSeriesItem(title: "8", value: 18),
  BarSeriesItem(title: "9", value: 39),
  BarSeriesItem(title: "10", value: 10),
  BarSeriesItem(title: "11", value: 41),
  BarSeriesItem(title: "12", value: 12),
];
final barSeries = BarSeries<String>(data: data, color: const Color(0x7FE5E5EB), name: "柱状图");

return CustomChart<String>(
  maxYAxis: 100,
  xAxisList: xAxisList,
  seriesList: [
    barSeries,
  ],
  customXAxis: BaseXAxis(),
)
  • 绘制 根据绘制流程如下

  • Y轴

  // 绘制Y轴
  customY1Axis?.draw(canvas, xAxisScale, YAxisPosition.left);
  customY2Axis?.draw(canvas, xAxisScale, YAxisPosition.right);
  • 标题
  • X轴
if (customXAxis != null) {
    customXAxis?.drawBackground(canvas, xAxisList, xAxisScale);
    customXAxis?.drawXAxis(canvas, xAxisList, xAxisScale, offset);
}
  • 数据
    // 绘制数据
    series.draw(
      canvas,
      xAxisList,
      xAxisScale,
      yAxis,
      offset,
    );

那其实画一个柱状图的关键我们已经知道了,传入的Y轴/X轴的draw方法是如何重写的?图表数据点的y(x)是如何绘制的? 再来看 BaseXAxis 的 draw方法

/// X轴数据,可以根据数据绘制X轴
drawXAxis(Canvas canvas, List<T> xAxisList, ChartScale scale, Offset? offset) {
    final content = scale.xRect;
    if (xAxisList.isEmpty) {
      return;
    }
    canvas.drawLine(
      Offset(content.left, content.top),
      Offset(content.right, content.top),
      Paint()
        ..color = const Color(0xFFDADBDB)
        ..strokeWidth = 0.5
        ..style = PaintingStyle.stroke,
    );
    for (int i = 0; i < xAxisList.length; i++) {
      final xAxis = xAxisList[i];
      final double x = content.left + i * scale.single + scale.content / 2;
      if (xAxis is ui.Image) {
        final image = xAxis;
        Rect src = Rect.fromLTWH(0, 0, image.width.toDouble(), image.height.toDouble());
        Rect dst = Rect.fromLTWH(x - imageSize / 2, content.top + (content.height - imageSize) / 2, imageSize, imageSize);
        canvas.drawImageRect(image, src, dst, Paint());
      } else {
        final textPainter = TextPainter(
          text: TextSpan(text: ChartUtils.getAxisTitle(xAxis), style: const TextStyle(color: Color(0x80000022), fontSize: 12)),
          textDirection: TextDirection.ltr,
        )..layout();
        textPainter.paint(canvas, Offset(x - textPainter.width / 2, content.top + 6));
      }
    }
}

同理 基础的y轴也是画一条线 重点在于BarSeries的绘制方法

@override
draw(ui.Canvas canvas, List<T> xAxisList, ChartScale scale, BaseYAxis yAxis, ui.Offset? offset) {
    if (xAxisList.isEmpty) {
      return;
    }
    final points = ChartUtils.getPoints(xAxisList, data, defaultYAxis);
    if (points.isEmpty) {
      return;
    }
    final ui.Rect content = scale.contentRect;

    // 绘制柱状图
    for (var i = 0; i < points.length; i++) {
      final offset = ChartUtils.getOffset(scale, yAxis, i, points[i]);
      ui.RRect rect = ui.RRect.fromLTRBAndCorners(
        offset.dx - scale.content / 2,
        offset.dy,
        offset.dx + scale.content / 2,
        content.bottom,
        topLeft: barRadius.topLeft,
        topRight: barRadius.topRight,
        bottomLeft: barRadius.bottomLeft,
        bottomRight: barRadius.bottomRight,
      );
      canvas.drawRRect(
          rect,
          ui.Paint()
            ..color = data[i].color ?? color
            ..style = ui.PaintingStyle.fill);
    }

    if (isShowTrendLine) {
      const trendColor = ui.Color(0xFF2D76FF);
      final offsets = points.mapIndexed((index, value) => ChartUtils.getOffset(scale, yAxis, index, value)).toList();
      final linePath = getTrendLine(offsets, scale, yAxis);
      canvas.drawPath(
        getTrendLine(offsets, scale, yAxis),
        ui.Paint()
          ..color = trendColor
          ..strokeWidth = 2
          ..style = ui.PaintingStyle.stroke
          ..strokeCap = ui.StrokeCap.round,
      );
      final shader = ui.Gradient.linear(ui.Offset(content.topCenter.dx, offsets.map((e) => e.dy).reduce(min)), content.bottomCenter,
          [trendColor.withAlpha(0x26), trendColor.withAlpha(0x00)]);
      final areaPath = linePath
        ..lineTo(offsets.last.dx, content.bottom)
        ..lineTo(offsets.first.dx, content.bottom)
        ..close();
      canvas.drawPath(
        areaPath,
        ui.Paint()
          ..color = trendColor
          ..strokeWidth = 2
          ..strokeCap = ui.StrokeCap.round
          ..shader = shader,
      );
    }
}

Flutter自绘图表库技术方案(一):基础

曲线图

final xAxisList = <String>["1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12"];
final data = <LineSeriesItem<String>>[
  LineSeriesItem(title: "1", value: 21),
  LineSeriesItem(title: "2", value: 25),
  LineSeriesItem(title: "3", value: 20),
  LineSeriesItem(title: "4", value: 14),
  LineSeriesItem(title: "5", value: 15),
  LineSeriesItem(title: "6", value: 16),
  LineSeriesItem(title: "7", value: 37),
  LineSeriesItem(title: "8", value: 18),
  LineSeriesItem(title: "9", value: 39),
  LineSeriesItem(title: "10", value: 10),
  LineSeriesItem(title: "11", value: 41),
  LineSeriesItem(title: "12", value: 12),
];
final lineSeries = LineSeries<String>(data: data, color: const Color(0xFF2D76FF), defaultYAxis: null, name: "曲线图");

return WhaleChart<String>(
  maxYAxis: 100,
  xAxisList: xAxisList,
  seriesList: [
    lineSeries,
  ],
  customXAxis: AutoStepXAxis(),
);
// #LineSeries
@override
draw(Canvas canvas,
    List<T> xAxisList,
    ChartScale scale,
    BaseYAxis yAxis,
    Offset? offset,) {
  final pointsRange = ChartUtils.getPointsRange(xAxisList, data, defaultYAxis);
  if (pointsRange.isEmpty) {
    return;
  }
  final content = scale.contentRect;
  final points = pointsRange.points;
  final startIndex = pointsRange.startIndex;
  final endIndex = pointsRange.endIndex;
  List<Offset> results = [];
  for (var i = 0; i < points.length; i++) {
    results.add(ChartUtils.getOffset(scale, yAxis, i, points[i]));
  }
  final breakPoint = this.breakPoint;
  final breakColor = this.breakColor ?? color.withAlpha(0x5A);
  if (breakPoint != null && breakPoint >= startIndex && breakPoint <= endIndex) {
    final breakPath = LinearLine.initPath(results, startIndex, endIndex);
    final normalPath = LinearLine.initPath(results, startIndex, endIndex, breakIndex: breakPoint);
    if (isDashLine == true) {
      _drawDashedPath(
          canvas,
          breakPath,
          4,
          4,
          Paint()
            ..color = breakColor
            ..style = PaintingStyle.stroke
            ..strokeWidth = 2
            ..strokeCap = StrokeCap.butt);
      _drawDashedPath(
          canvas,
          normalPath,
          4,
          4,
          Paint()
            ..color = color
            ..style = PaintingStyle.stroke
            ..strokeWidth = 2
            ..strokeCap = StrokeCap.butt);
    } else {
      canvas.drawPath(
        breakPath,
        Paint()
          ..color = breakColor
          ..style = PaintingStyle.stroke
          ..strokeWidth = 2
          ..strokeCap = StrokeCap.round,
      );
      canvas.drawPath(
        normalPath,
        Paint()
          ..color = color
          ..style = PaintingStyle.stroke
          ..strokeWidth = 2
          ..strokeCap = StrokeCap.round,
      );
    }

    final shader = ui.Gradient.linear(
      Offset(content.topCenter.dx, results.map((e) => e.dy).reduce(min)),
      content.bottomCenter,
      [color.withAlpha(0x26), color.withAlpha(0x00)],
    );
    final areaBreakPath = breakPath
      ..lineTo(results[endIndex].dx, content.bottom)..lineTo(results[startIndex].dx, content.bottom)
      ..close();
    canvas.drawPath(
      areaBreakPath,
      Paint()
        ..color = color
        ..strokeWidth = 2
        ..strokeCap = StrokeCap.round
        ..shader = shader,
    );
  } else {
    final path = LinearLine.initPath(results, startIndex, endIndex);
    if (isDashLine == true) {
      _drawDashedPath(
          canvas,
          path,
          2,
          2,
          Paint()
            ..color = color
            ..style = PaintingStyle.stroke
            ..strokeWidth = 2
            ..strokeCap = StrokeCap.round);
    } else {
      canvas.drawPath(
        path,
        Paint()
          ..color = color
          ..style = PaintingStyle.stroke
          ..strokeWidth = 2
          ..strokeCap = StrokeCap.round,
      );
    }
    final shader = ui.Gradient.linear(
      Offset(content.topCenter.dx, results.map((e) => e.dy).reduce(min)),
      content.bottomCenter,
      [color.withAlpha(0x26), color.withAlpha(0x00)],
    );
    final areaPath = path
      ..lineTo(results[endIndex].dx, content.bottom)..lineTo(results[startIndex].dx, content.bottom)
      ..close();
    canvas.drawPath(
      areaPath,
      Paint()
        ..color = color
        ..strokeWidth = 2
        ..strokeCap = StrokeCap.round
        ..shader = shader,
    );
  }
  if (isShowCircleNode == true) {
    if (isShowTotalCircleNode == true) {
      var circleIndex = ChartUtils.getDrawCircleIndex(xAxisList, scale, yAxisIsHide);
      drawCircleNode(
        startIndex,
        endIndex,
        scale,
        yAxis,
        points,
        circleIndex,
        canvas,
        breakPoint,
        breakColor,
        offset,
      );
    }

    if (needMaxAndMinCircleNode) {
      var circleIndex = <int>[];
      int maxValueIndex = 0;
      int minValueIndex = 0;
      double maxValue = 0;
      double minValue = double.infinity;
      // 跳过开头为0的数据
      int startIndex = data.indexWhere((item) => item.value != 0);
      if (startIndex != -1) {
        for (int i = startIndex; i < data.length; i++) {
          double curValue = data[i].value;
          if (curValue > maxValue) {
            maxValue = curValue;
            maxValueIndex = i;
          }
          if (curValue < minValue) {
            minValue = curValue;
            minValueIndex = i;
          }
        }
        circleIndex.add(maxValueIndex);
        circleIndex.add(minValueIndex);

        drawCircleNode(
          startIndex,
          endIndex,
          scale,
          yAxis,
          points,
          circleIndex,
          canvas,
          breakPoint,
          breakColor,
          offset,
        );
      }
    }

    drawSelectedCircleNode(
      startIndex,
      endIndex,
      scale,
      yAxis,
      points,
      canvas,
      breakPoint,
      breakColor,
      offset,
    );
  }
  if (maxAndMinValue?.isNotEmpty == true) {
    drawMaxAndMinNode(canvas, scale, yAxis);
  }
}

void _drawDashedPath(Canvas canvas,
    Path path,
    double a,
    double b,
    Paint paint,) {
  final totalLength = path.computeMetrics().fold(0.0, (double prev, PathMetric metric) => prev + metric.length);
  final double dashLength = a + b;
  final double dashCount = (totalLength / dashLength).ceil().toDouble();

  var start = 0.0;
  for (var i = 0; i < dashCount; ++i) {
    final metrics = path.computeMetrics(forceClosed: false);
    final extract = metrics.elementAt(0).extractPath(start, start + a);
    canvas.drawPath(extract, paint);
    start += dashLength;
  }
}

/// 画圆形节点的方法
///
/// [startIndex] 和 [endIndex] 分别表示要绘制的圆形节点在数据集中的开始和结束索引。
/// [scale] 为图表刻度尺,提供坐标转换功能。
/// [yAxis] 为图表的Y轴。
/// [points] 是一个包含数据点的列表。
/// [circleIndex] 是一个包含需要绘制圆形节点的数据点索引的列表。
/// [canvas] 是用于绘制图形的画布对象。
/// [breakPoint] 是一个可选参数,表示颜色改变的临界点,默认为100。
/// [breakColor] 是一个可选参数,表示临界点之后使用的颜色。
/// [offset] 是一个可选参数,表示当前点击的坐标位置。
///
/// 该方法遍历数据集,并根据 [circleIndex] 绘制指定的圆形节点。
/// 如果提供了 [breakPoint],则根据索引值绘制不同颜色的圆形节点。
/// 如果提供了 [offset],则绘制当前点击位置对应的圆形节点。
void drawCircleNode(int startIndex,
    int endIndex,
    ChartScale scale,
    BaseYAxis yAxis,
    List<double> points,
    List<int> circleIndex,
    ui.Canvas canvas,
    int? breakPoint,
    ui.Color breakColor,
    ui.Offset? offset,) {
  for (var i = startIndex; i >= 0 && i <= endIndex; i++) {
    final offset = ChartUtils.getOffset(scale, yAxis, i, points[i]);
    if (circleIndex.contains(i) == true) {
      canvas.drawCircle(
          offset,
          2,
          Paint()
            ..color = Colors.white
            ..style = PaintingStyle.fill);
      canvas.drawCircle(
          offset,
          2,
          Paint()
            ..color = (breakPoint ?? 100) >= i ? color : breakColor
            ..style = PaintingStyle.stroke
            ..strokeWidth = 2
            ..strokeCap = StrokeCap.round);
    }
  }
}

/// 绘制选中状态下数据圆形节点
void drawSelectedCircleNode(int startIndex,
    int endIndex,
    ChartScale scale,
    BaseYAxis yAxis,
    List<double> points,
    ui.Canvas canvas,
    int? breakPoint,
    ui.Color breakColor,
    ui.Offset? offset,) {
  final currentIndex = scale.getIndex(offset: offset) ?? defaultIndex;
  if (currentIndex != null && currentIndex >= startIndex && currentIndex <= endIndex && currentIndex >= 0 && currentIndex < points.length) {
    final offset = ChartUtils.getOffset(scale, yAxis, currentIndex, points[currentIndex]);
    canvas.drawCircle(
        offset,
        3,
        Paint()
          ..color = Colors.white
          ..style = PaintingStyle.fill);
    canvas.drawCircle(
        offset,
        3,
        Paint()
          ..color = (breakPoint ?? 100) >= currentIndex ? color : breakColor
          ..style = PaintingStyle.stroke
          ..strokeWidth = 2
          ..strokeCap = StrokeCap.round);
  }
}

@override
void drawSelection(Canvas canvas,
    List<T> xAxisList,
    ChartScale scale,
    BaseYAxis yAxis,
    Offset? offset,) {}

void drawMaxAndMinNode(Canvas canvas,
    ChartScale scale,
    BaseYAxis yAxis,) {
  final maxAndMinValue = this.maxAndMinValue;
  if (maxAndMinValue != null) {
    for (int i = 0; i < maxAndMinValue.length; i++) {
      var item = maxAndMinValue[i];
      if (item != null) {
        var offset = ChartUtils.getOffset(scale, yAxis, i, item.item2);
        canvas.drawCircle(
            offset,
            3,
            Paint()
              ..color = item.item3 == true ? const Color(0XFF2D76FF) : const Color(0XFFA0BDF4)
              ..style = PaintingStyle.fill);
      }
    }
  }
}

Flutter自绘图表库技术方案(一):基础

散点图

final xAxisList = <String>["1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12"];
final data = <PointSeriesItem<String>>[
  PointSeriesItem(title: "1", value: 21),
  PointSeriesItem(title: "2", value: 25),
  PointSeriesItem(title: "3", value: 20),
  PointSeriesItem(title: "4", value: 14),
  PointSeriesItem(title: "5", value: 15),
  PointSeriesItem(title: "6", value: 16),
  PointSeriesItem(title: "7", value: 37),
  PointSeriesItem(title: "8", value: 18),
  PointSeriesItem(title: "9", value: 39),
  PointSeriesItem(title: "10", value: 10),
  PointSeriesItem(title: "11", value: 41),
  PointSeriesItem(title: "12", value: 12),
];
final pointSeries = PointSeries<String>(data: data, color: const Color(0x7F2D76FF), pointType: PointType.diamond, name: "散点图");

return WhaleChart<String>(
  maxYAxis: 100,
  xAxisList: xAxisList,
  seriesList: [
    pointSeries,
  ],
  customXAxis: AutoStepXAxis(),
),

Flutter自绘图表库技术方案(一):基础

Flutter自绘图表库技术方案(一):基础

ps :后续将更新进阶内容,自定义图表样式