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

背景

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

图表定义

定义图表组件与基础参数

dart 复制代码
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类,如需实现新的图表样式,只需继承自该类并重新实现方法。

dart 复制代码
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

ini 复制代码
@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);
  }
}

实战快速接入

画一个柱状图

  • 定义数据
php 复制代码
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轴

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

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

ini 复制代码
/// 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的绘制方法

ini 复制代码
    @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,
      );
    }
}

曲线图

php 复制代码
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(),
);

散点图

php 复制代码
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(),
),
相关推荐
Forever不止如此几秒前
【CustomPainter】绘制圆环
flutter·custompainter·圆环
wills77734 分钟前
Flutter Error: Type ‘UnmodifiableUint8ListView‘ not found
flutter
AiFlutter1 天前
Flutter之Package教程
flutter
Mingyueyixi1 天前
Flutter Spacer引发的The ParentDataWidget Expanded(flex: 1) 惨案
前端·flutter
crasowas1 天前
Flutter问题记录 - 适配Xcode 16和iOS 18
flutter·ios·xcode
老田低代码2 天前
Dart自从引入null check后写Flutter App总有一种难受的感觉
前端·flutter
AiFlutter3 天前
Flutter Web首次加载时添加动画
前端·flutter
ZemanZhang4 天前
Flutter启动无法运行热重载
flutter
AiFlutter5 天前
Flutter-底部选择弹窗(showModalBottomSheet)
flutter