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(),
),
相关推荐
火柴就是我2 小时前
flutter 之真手势冲突处理
android·flutter
Speed1232 小时前
`mockito` 的核心“打桩”规则
flutter·dart
法的空间3 小时前
Flutter JsonToDart 支持 JsonSchema
android·flutter·ios
恋猫de小郭3 小时前
Android 将强制应用使用主题图标,你怎么看?
android·前端·flutter
玲珑Felone4 小时前
从flutter源码看其渲染机制
android·flutter
ALLIN1 天前
Flutter 三种方式实现页面切换后保持原页面状态
flutter
Dabei1 天前
Flutter 国际化
flutter
Dabei1 天前
Flutter MQTT 通信文档
flutter
Dabei1 天前
Flutter 中实现 TCP 通信
flutter
孤鸿玉1 天前
ios flutter_echarts 不在当前屏幕 白屏修复
flutter