📈

CustomPainterでLineChartを描画しよう

2023/12/11に公開

この記事は、FOLIO Advent Calendar 2023の8日目の記事です。

FOLIOでは、4RAPというtoB向けのSaaSで使用するアプリの開発をFlutterで行っています。
そのアプリでは、運用状況やシミュレーションなどをLineChartで表示しています。
実際の開発では、外部パッケージを使用して描画しているのですが、自前で実装するとしたらどんな感じなのか試してみたくなったので、簡単なLineChartを描画できるWidgetをCustomPainterを使用して自作してみることにしました。

目指すゴール

今回作成するLineChartで満たしたい仕様は4つです。

  • LineChartを描画できる
  • 枠線の表示/非表示を切り替えれる
  • GridとLabelを表示できる
  • タップした際にMakerを表示できる

(本格的なものを作成するのは大変なので、割と甘めの仕様にしています🙇‍♂️)

CustomPainterについて

CustomePainterは、Line, 図形, Text, etcをcanvas上に自由に描画することができるWidgetで、 CustomPaintもしくはRenderCustomPaintと使用します。
用意されているWidgetだけで表現することが難しいカスタマイズしたUIを描画したい場合などに役立つWidgetです。
Widget of the Weekの第18回で紹介されています。
https://youtu.be/kp14Y4uHpHs

CustomPainterには、paintshouldRepaintが用意されています。
shouldRepaintは、ビルド時に呼ばれるメソッドで、bool型を返します。
引数にCustomPainter型のoldDelegateを持っているので、それを利用して値が変わっているかなどで判定し、falseを返してそのまま再利用するもしくは、trueを返して再描画します。
paintメソッドは、引数にCanvasとSizeを持っていて、それらを使用して、実際の描画処理を記述します。
Canvasクラスの描画系のメソッドには、clip系と、draw系が用意されています。

clipXxx

clip系メソッドは、描画領域を狭めたり、くり抜いたりすることができます。
描画領域を指定の範囲の内側だけにしたい場合は、clipOpClipOp.intersectを指定します。デフォルトの値はこちらです。
逆に描画領域を指定の範囲の外側にしたい場合は、ClipOp.differenceを使用します。
用意されているメソッドは、clipPath, clipRect(矩形), clipRRect(角丸の矩形)の三種類です。

DartPadでサンプルを作成しておいたので、もし理解できなければ、いじってみてください。
https://dartpad.dev/?id=96af9d4a98480f9654ccab26a9ded4bf

drawXxx

draw系メソッドは、実際に描画するために使用します。
用意されているメソッドは、線を描画するdrawLineや矩形を描画するdrawRect, 円を描画するdrawCircleなど基本的なものから、Imageを直接描画するdrawImage, Shadowを描画するdrawShadowなど様々なものが用意されてます。

Path

LineChartを作成するためには、Pathを使用するので、事前に説明しておきます。
Pathは、現在点とSub-pathで構成されています。
moveToメソッドを使用して、描画したい点に移動し、lineToで指定した点へSub-pathを描画します。
例えば、moveTo(3, 5) -> lineTo(5, 5)とすると、Y軸が5の位置に、X軸の3から5へのLineが引かれます。
このあとに続けてLineを引こうとした場合は、現在点が(5, 5)に移動しているため、そこからのLineが引かれることになります。
Lineだけでなく、曲線なども描画できます。
また、relativeXxxを使用することで、座標を直接指定せずに、移動させたい分だけを指定することもできます。

LineChartの実装

CustomPainterについてざっくり理解したところで、早速LineChartの実装をしていこうと思います。

最大値最小値の計算

LineChartに描画する点の位置を計算するために、描画される値のX軸とY軸の最大値と最小値を取得する必要があります。
そのため、まずは与えられたLineChartに描画したいX軸とY軸の値のListからそれぞれの最大値と最小値を取得します。

(double maxX, double minX, double maxY, double minY) _getBounding(
  List<(double, double)> dataSet,
) {
  var maxX = dataSet[0].$1;
  var minX = dataSet[0].$1;
  var maxY = dataSet[0].$2;
  var minY = dataSet[0].$2;
  for (int i = 1; i < dataSet.length; i++) {
    final x = dataSet[i].$1;
    final y = dataSet[i].$2;
    maxX = max(maxX, x);
    minX = min(minX, x);
    maxY = max(maxY, y);
    minY = min(minY, y);
  }
  // ①
  if (maxX - minX == 0) {
    maxX++;
    minX--;
  }
  if (maxY - minY == 0) {
    maxY++;
    minY--;
  }
  return (maxX, minX, maxY, minY);
}

①では、もしX軸, Y軸で値の範囲が一つしかない場合、最大値と最小値を1ずつずらして、描画範囲の中央にLineが描画されるように値を調整しています。

Lineの描画

求めたX軸とY軸の最大値と最小値と、描画する点のListを使用して実際にLineを描画していきます。

double get _diffX => _maxX - _minX;
double get _diffY => _maxY - _minY;


void paint(Canvas canvas, Size size) {
  double calcXPoint(double x) => 
      size.width / 2 : (x - _minX) / _diffX * size.width;
  double calcYPoint(double y) =>
      // ①
      size.height - (y - _minY) / _diffY * size.height;
  
  final (startX, startY) = calcPoint(_dataSet.first);
  final linePath = Path()..moveTo(startX, startY);
  for (int i = 1; i < _data.length; i++) {
    final (x, y) = calcPoint(_dataSet[i]);
    linePath.lineTo(x, y);
  }
  canvas.drawPath(linePath, _linePaint);
}

↑の実装をすると↓のような描画がされます。

①では、Y方向の相対的な位置を求めているのですが、Canvasの最上部が0で、最下部がsize.heightの値になるので、X軸と同じ計算式を使うと、Y軸上下逆さまのチャートになってしまうため、size.heightから引いて反転させています。
あとは、Pathを使用してLineを描画していきます。

一旦はこれで、LineChartっぽいものを作成することができました🎉

枠線とGridLineの描画

Line自体は引くことができたのですが、枠線がないためどの範囲が描画範囲なのか分かりづらくなってしまっています。
そのため、まずは枠線を描画していきます。
枠線は、上下左右それぞれ表示/非表示したいユースケースがあると思うので、それをできるようにしてみます。
まずは、枠線に関するクラスを作成します。


class LineChartBorders {
  const LineChartBorders({
    this.left = const Border(),
    this.top = const Border(),
    this.right = const Border(),
    this.bottom = const Border(),
  });
  final Border left;
  final Border top;
  final Border right;
  final Border bottom;
}


class Border {
  const Border({
    this.isVisible = true,
    this.color = Colors.black,
    this.strokeWidth = 2,
  });
  final bool isVisible;
  final Color color;
  final double strokeWidth;
}

そして、そのクラスを使用して枠線を描画します。

final leftBorder = _borders.left;
if (leftBorder.isVisible) {
  canvas.drawLine(
    const Offset(0, 0),
    Offset(0, size.height),
    Paint()
      ..color = leftBorder.color
      ..strokeWidth = leftBorder.strokeWidth,
  );
}

isVisibleがtrueであれば、drawLineを使用して、枠線を描画します。簡単ですね。
Top, Right, Bottomについてはほとんど処理が同じなので割愛します🙏

GridLineに関しても、同じようにdrawLineを使用して描画します。


class AxisGridLine {
  const AxisGridLine({
    this.interval,
    this.gridLine = const LineChartBorder(color: Colors.black26),
  });
  final double? interval;
  final LineChartBorder gridLine;
}

void _drawGridLineIfNeeded(Canvas canvas, Size size) {
  if (_xAxisGridLine.gridLine.isVisible) {
    final xInterval =
        _xAxisGridLine.interval ?? (_minX.abs() + _maxX.abs()) / 10;
    var currentGridValue = _minX;
    while (currentGridValue <= _maxX) {
      final xPosition = _calcXPoint(currentGridValue, size);
      canvas.drawLine(
        Offset(xPosition, 0),
        Offset(xPosition, size.height),
        Paint()
          ..color = _xAxisGridLine.gridLine.color
          ..strokeWidth = _xAxisGridLine.gridLine.strokeWidth,
      );
      currentGridValue += xInterval;
    }
  }
}

(Y軸もX軸もやることはほぼ同じなので、X軸のGridLineの描画だけの実装です)
GridLineに関するクラスを作成して、そこに描画するGridLineのスタイルと、間隔(Interval)をもたせます。
そして、isVisibleがtrueであれば、drawLineを使用してLineをXの最大値を超えるまで描画していきます。

ラベルの表示

枠線とGridLineを表示させることができたので、次はGridLineに対応するラベルを表示させるようにして、よりわかりやすくしていきます。

final xLabelPainter = TextPainter(
  text: TextSpan(
    text: (currentGridValue).toStringAsFixed(2),
    style: const TextStyle(
      color: Colors.black,
      fontSize: 10,
    ),
  ),
  textDirection: TextDirection.ltr,
);
xLabelPainter.layout();
final xOffset = xPosition - xLabelPainter.size.width / 2;
if (xOffset < 0) {
  xLabelPainter.paint(canvas, Offset(0, size.height));
} else if (xOffset + xLabelPainter.size.width > size.width) {
  xLabelPainter.paint(canvas,
      Offset(size.width - xLabelPainter.size.width, size.height));
} else {
  xLabelPainter.paint(canvas,
      Offset(xPosition - xLabelPainter.size.width / 2, size.height));
}

CustomPainterでTextを描画するためには、TextPainterを使用します。
まずは、TextPainterインスタンスを生成して、表示するText(TextSpan)とTextDirectionを指定します。

その後に、layoutメソッドを呼んで、Textを描画するための範囲を指定します。
引数には、minWidthmaxWidthを指定することができるので、指定する必要がある場合は指定します。

最後にpaintメソッドを呼んでTextを描画します。
両端の値(= minX, maxX)のラベルを描画する際に、描画範囲をはみ出してしまうため、最小値、最大値が画面からはみ出していないかをチェックしています。

タップ処理

チャートをタップした際に、その位置の値を表示するMakerを表示させる処理を実装してみたいと思います。
CustomPainterでタップ位置の処理をするためには、GestureDetectorを使用します。

final tapOffset = useState<Offset?>(null);
return Container(
  constraints: const BoxConstraints.expand(),
  child: GestureDetector(
    onTapDown: (details) {
     tapOffset.value = details.localPosition;
    },
    child: CustomPaint(
      painter: _LineChartPainter(data, tapOffset.value),
    ),
  ),
);

GestureDetectorのonTapDownがタップされた際のPositionを持っているので、これを使用してタップした箇所のOffsetをStateに持たせるようにしています。

CustomPainter側では、Lineを描画しているforループ内で、渡されたタップ位置から一番近い点を求めて、保持しておきます。

var marker = (0.0, 0.0);

if (tapOffset != null &&
    (tapOffset!.dx - calcXPoint(marker.$1, 0)).abs() >
        (tapOffset!.dx - x).abs()) {
  marker = _dataSet[i];
}

そこで求めた値から選択ポイントを強調するためのLineを表示します。

final x = calcXPoint(marker.$1, 0);
canvas.drawLine(
  Offset(x, 0),
  Offset(x, size.height),
  Paint()
    ..color = Colors.blue
    ..strokeWidth = 2,
);

次にMakerとTextを描画します。

final markerPainter = TextPainter(
  text: TextSpan(
    text: 'x = ${marker.$1}, y = ${marker.$2}',
    style: const TextStyle(
      color: Colors.black,
    ),
  ),
  textDirection: TextDirection.ltr,
);
markerPainter.layout();
final textHeight = markerPainter.height + markerTextPadding * 2;
final textWidth = markerPainter.width + markerTextPadding * 2;
final markerY = markerPainter.height;
final double markerX;
final double markerTextX;
if (x - textWidth / 2 < 0) {
  markerX = markerMargin;
  markerTextX = markerMargin + markerTextPadding;
} else if (x + textWidth > size.width) {
  markerX = size.width - textWidth - markerMargin;
  markerTextX = size.width - textWidth;
} else {
  markerX = x - textWidth / 2;
  markerTextX = x - textWidth / 2 + markerTextPadding;
}
final rect = Rect.fromLTWH(
  markerX,
  markerY,
  textWidth,
  textHeight,
);
canvas.drawShadow(Path()..addRect(rect), Colors.grey, 5, false);
canvas.drawRect(
    rect,
    Paint()
      ..color = Colors.white
      ..strokeWidth = 1);
markerPainter.paint(
  canvas,
  Offset(markerTextX, textHeight / 2 + markerPainter.height / 2),
);

まず、TextPainterを作成して、そのSizeを使用して、MakerとTextを描画していきます。
そのままRectを描画すると境界線が分かりづらいので、今回はShadowも描画します。
ShadowはdrawShadowメソッドで描画できるのですが、引数にPathが必要なため、Maker用のRectを先に作成しておいて、Path.addRectで、Maker用のRectに合ったPathを作成し、Shadowを描画します。
両端をタップした際に、Makerがはみ出さないように、Labelでやったように調整をしておきます。

最終的には以下のようなLineChartが描画することができるようになりました🎉

まとめ

CustomPainterについてほぼ何も知らない状態から、簡単なLineChartを作成してみましたが、思ったより簡単に扱えました。
既存のWidgetをカスタマイズするだけでアプリを作成できた方が楽でありがたいのですが、どうしても既存のWidgetをカスタムするだけでは実装することができないこともあるかと思うので、その際はCustomPainterを使って実装するのも手段としてはありなのかもと思いました。(もちろんそんなこと無いのが一番ですが😇)

ではよいFlutterライフを〜👋

Discussion