CustomPainterでLineChartを描画しよう
この記事は、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回で紹介されています。
CustomPainterには、paint
とshouldRepaint
が用意されています。
shouldRepaint
は、ビルド時に呼ばれるメソッドで、bool型を返します。
引数にCustomPainter
型のoldDelegate
を持っているので、それを利用して値が変わっているかなどで判定し、falseを返してそのまま再利用するもしくは、trueを返して再描画します。
paint
メソッドは、引数にCanvasとSizeを持っていて、それらを使用して、実際の描画処理を記述します。
Canvasクラスの描画系のメソッドには、clip系と、draw系が用意されています。
clipXxx
clip系メソッドは、描画領域を狭めたり、くり抜いたりすることができます。
描画領域を指定の範囲の内側だけにしたい場合は、clipOp
にClipOp.intersect
を指定します。デフォルトの値はこちらです。
逆に描画領域を指定の範囲の外側にしたい場合は、ClipOp.difference
を使用します。
用意されているメソッドは、clipPath
, clipRect
(矩形), clipRRect
(角丸の矩形)の三種類です。
DartPadでサンプルを作成しておいたので、もし理解できなければ、いじってみてください。
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を描画するための範囲を指定します。
引数には、minWidth
とmaxWidth
を指定することができるので、指定する必要がある場合は指定します。
最後に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