[Flutter] キャンバス上で任意の図形の描画を行えるCustomPaintを使ってみる

リッチなUIの描画に使われることも多いCustomPaintについて扱いました。
2021.02.11

Flutter は動画コンテンツも充実していて週毎にウィジェットを紹介している Flutter Widget of the Week では短い動画でウィジェットの概要を掴むことができる動画を視聴できます。

Flutter Widget of the Week を見るだけでも勉強になるのですが、記憶力が良くないので、視聴によるインプットだけでなく手元でコードを動かしてそれをブログ記事にする所までやって後から思い出しやすいようにしてみるシリーズです。実はこのシリーズは動画リストの頭からを厳守しているわけではなく、普段の開発で実装が必要なったクラスの動画があれば題材にしたりしています。今回はカラーピッカーを作ろうとする時にCustomPaintの知識が必要になったのでCustomPaintについて扱います。

これまでの記事は以下のリンクから参照できます。

CustomPaint

CustomPaintを使用することでかなり自由度高く図形を描画することができます。ドキュメントによるとpaintというフェーズで描画可能なキャンパスを提供するウィジェットとあります。他のWidgetと毛色が異なり低レベルなAPIを使ったグラフィック操作を行うための方法が提供されています。

動画は以下です。

CustomPainterのコンストラクタは以下のようになっていて、paintフェーズで描画を行うのはコンストラクタ引数painterに渡したpainterです。型はCustomPainterです。

const CustomPaint({
    Key key,
    this.painter,
    this.foregroundPainter,
    this.size = Size.zero,
    this.isComplex = false,
    this.willChange = false,
    Widget child,
  }) : assert(size != null),
       assert(isComplex != null),
       assert(willChange != null),
       assert(painter != null || foregroundPainter != null || (!isComplex && !willChange)),
       super(key: key, child: child);

CustomPainterは抽象クラスなので実際は定義したor された具象クラスを引数に渡して何を描画するか決めます。

CustomPainterを継承して実装する時、実装が必須なのはpaintメソッドとshouldRepaintメソッドです。paintメソッドはそのまま、描画 を担当するメソッドです。引数にはCanvasとSizeのオブジェクトが与えられます。描画コマンドは与えられたサイズの範囲内で実行されるべきと動画やドキュメントに言及があります。

描画を行うメソッドは以下のようなものです。

  • drawLine(線)
  • drawRect(四角)
  • drawCircle(円)
  • drawArc(アーチ)
  • drawPath(パス)
  • drawImage(ビットマップ画像)
  • drawParagraph(テキスト)

実際にやってみる

CustomPaintについての記事でよく出てくる線、円、四角形の描画です。

import 'package:flutter/material.dart';

class ColorPickerPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: Theme.of(context).backgroundColor,
      appBar: AppBar(
        title: Text('Battery Optimizer'),
        centerTitle: false,
        elevation: 0,
      ),
      body: _Body(),
    );
  }
}

class _Body extends StatelessWidget {
  _Body();

  @override
  Widget build(BuildContext context) {
    return Container(
      width: 400,
      height: 400,
      child: CustomPaint(
        painter: _SamplePainter(),
      ),
    );
  }
}

class _SamplePainter extends CustomPainter {
  @override
  void paint(Canvas canvas, Size size) {
    final paint = Paint()..color = Colors.blue;
    canvas.drawRect(Rect.fromLTWH(0, 0, 50, 50), paint);
    paint.color = Colors.grey;
    paint.strokeWidth = 5;
    canvas.drawLine(Offset(140, 10), Offset(140, 60), paint);
    paint.color = Colors.red;
    canvas.drawCircle(Offset(100, 35), 25, paint);
  }

  @override
  bool shouldRepaint(covariant CustomPainter oldDelegate) {
    return true;
  }
}

実行結果

座標を指定してdraw系のメソッドを呼び出すだけです。

Canvasは左上を(0, 0)として扱います。

そのため、上下の中心に線を引きたかったら以下のようにします。

class _SamplePainter extends CustomPainter {
  @override
  void paint(Canvas canvas, Size size) {
    final paint = Paint()
      ..color = Colors.blue
      ..strokeWidth = 14;

    canvas.drawLine(
        Offset(size.width / 2, 0), Offset(size.width / 2, size.height), paint);
    canvas.drawLine(
        Offset(0, size.height / 2), Offset(size.width, size.height / 2), paint);
  }

  @override
  bool shouldRepaint(covariant CustomPainter oldDelegate) {
    return true;
  }
}

中心はpaintメソッドの引数のsizeから求めることができます。実行結果は以下のようになります。

全く同じものをdrawLineではなくPathというオブジェクトとdrawPathというメソッドで書くこともできます。

class _SamplePainter extends CustomPainter {
  @override
  void paint(Canvas canvas, Size size) {
    var paint = Paint()
      ..color = Colors.blue
      ..strokeWidth = 14
      ..style = PaintingStyle.stroke; // Pathを使う場合必須

    var horizontalPath = Path()
      ..moveTo(0, size.height / 2)
      ..lineTo(size.width, size.height / 2);
    canvas.drawPath(horizontalPath, paint);

    var verticalPath = Path()
      ..moveTo(size.width / 2, 0)
      ..lineTo(size.width / 2, size.height);
    canvas.drawPath(verticalPath, paint);
  }

  @override
  bool shouldRepaint(covariant CustomPainter oldDelegate) {
    return true;
  }
}

実行結果は同じです。コメントに記載の通り、このPathを使う場合はPaintクラスのstyleには値を指定する必要があります。指定しなかった場合は描画されません。

drawCircleと同じこともdrawPathでできます。

class _SamplePainter extends CustomPainter {
  @override
  void paint(Canvas canvas, Size size) {
    var paint = Paint()
      ..color = Colors.blue
      ..strokeWidth = 14;
    //..style = PaintingStyle.stroke;

    var circlePath = Path()
      ..addOval(Rect.fromCircle(
        center: Offset(size.width / 2, size.height / 2),
        radius: 100,
      ));
    canvas.drawPath(circlePath, paint);
  }

  @override
  bool shouldRepaint(covariant CustomPainter oldDelegate) {
    return true;
  }
}

多角形の描画

中学で習う円の公式、三平方の定理や三角関数を理解していることが前提になっています。

円上の点同士を線でつなげることで多角形の描画ができるようになります。座標平面上で半径がrの単位円があり、円状の点を一つとって原点と結びます。この時x軸から図った角度をθとすると円状の点は(cosθ, sinθ)という座標になります。これらの計算を行うメソッドはDartのmathパッケージから提供されているので、これを使って少ない計算で多角形が描画できます。

多角形における円の総角は2π、これを何分割するかで多角形を定義します。各部分の角度の値を使って円の線上の点の座標を求められるようになります。この多角形を回転させるアニメーションに組み込んだのが以下のコードです。

class _Body extends StatefulWidget {
  @override
  __BodyState createState() => __BodyState();
}

class __BodyState extends State<_Body> with TickerProviderStateMixin {
  var _sides = 5.0;
  var _radius = 100.0;
  var _radians = 0.0;

  Animation<double> animation;
  AnimationController controller;

  @override
  void initState() {
    super.initState();
    controller =
        AnimationController(vsync: this, duration: Duration(seconds: 5));

    Tween<double> _rotationTween = Tween(begin: -pi, end: pi);

    animation = _rotationTween.animate(controller)
      ..addListener(() {
        setState(() {});
      })
      ..addStatusListener((status) {
        if (status == AnimationStatus.completed) {
          controller.repeat();
        } else if (status == AnimationStatus.dismissed) {
          controller.forward();
        }
      });
    controller.forward();
  }

  @override
  void dispose() {
    controller.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return AnimatedBuilder(
        animation: animation,
        builder: (context, snapshot) {
          return Container(
            width: double.infinity,
            height: double.infinity,
            child: CustomPaint(
              painter: _SamplePainter(_sides, _radius, animation.value),
            ),
          );
        });
  }
}

class _SamplePainter extends CustomPainter {
  final double sides;
  final double radius;
  final double radians;

  _SamplePainter(this.sides, this.radius, this.radians);

  @override
  void paint(Canvas canvas, Size size) {
    var paint = Paint()
      ..color = Colors.green
      ..strokeWidth = 5
      ..style = PaintingStyle.stroke
      ..strokeCap = StrokeCap.round;

    var path = Path();

    var angle = (pi * 2) / sides;

    Offset center = Offset(size.width / 2, size.height / 2);
    Offset startPoint = Offset(radius * cos(radians), radius * sin(radians));

    path.moveTo(startPoint.dx + center.dx, startPoint.dy + center.dy);

    for (int i = 1; i <= sides; i++) {
      double x = radius * cos(radians + angle * i) + center.dx;
      double y = radius * sin(radians + angle * i) + center.dy;
      path.lineTo(x, y);
    }
    path.close();
    canvas.drawPath(path, paint);
  }

  @override
  bool shouldRepaint(covariant CustomPainter oldDelegate) {
    return true;
  }
}

実行結果は以下です。

まとめ

カラーピッカーを自作している記事を理解するためにCustomPaintについての理解が必須だったので触ってみました。Flutterのコード片でよく見るリッチなUIのコードにもCustomPaintがよく使われていて、それらのコードを見かけた時に少しだけ読むのに抵抗がなくなった気がします。

Flutterの学習・開発は始めたばかりで説明やその裏にある認識に誤りがあるかもしれません。なにかお気づきの際はコメントなどでお知らせください。

参考リンク