FlutterのCanvasを使ってみた

はじめに

こんにちは。今回はFlutterを用いてCanvas及びアニメーションを描画してみましたのでこちらを紹介しようと思います。
個人制作で歩数系のアプリを制作しており、円グラフの描画を入れてみたかったので勉強もふまえて作った次第であります。
動きのないグラフを描画だけでは物足りない感じがしたので、アニメーションも追加しました。こちらも併せて紹介いたします。

成果物について

円グラフの内側と外側に分かれており、数字の割合が上がるにつれて、グラデーションが変わるようなUIにしました。
外側の線と内側の分子に当たる数字が増えるようなアニメーションも追加しております。下動画では手打ちですけどデバイスのデータとユーザーが入力したデータから数字をとっていくようなイメージです。

コードの紹介

早速コードを見ていきましょう。
まず前提として定義しているWidgetは下記になります。

  • AnimationArc→アニメーション用。

  • PieChartPainter→Canvas描画用。

実行する順番としては以下のようになります。

  1. 親ウィジェット内でAnimationArcを実行。引数に分子・分母にあたる数値を入れる(ここでは分母にユーザーが入力した数値、分子に外部APIから取ってきた数値を想定してConsumer Widget内で実行してます)

  2. Animation Arc内でPieChartPainterを実行。引数にAnimationArcの引数に加えて、AnimationControllerで生成した値を入れる。

親ウィジェット

AnimationArcは以下で実行してます。第一引数に実際の数値と第二引数に目標となる数値を入れた状態で実行することで描画します。

child: Consumer(
            builder: (context, ref, _) {
              final healthData = ref.watch(healthDataProvider);
              final stepCount = ref.watch(healthDataProvider);
              final targetStepCount = userInputStepCount;
              return healthData.when(
                data: (data) {
                  return AnimationArc(
                    stepCount: stepCount,
                    targetStepCount: targetStepCount,
                  );
                },
                loading: () => CircularProgressIndicator(),
                error: (error, stackTrace) => Text('Error: $error'),
              );
            },
          ),

AnimationArc

AnimationArcをStatefulWidgetで定義します。
RiverPodでStatelessWidgetとして行うことも考えましたが、アニメーション系の記事を見るとどれも、Statefulで定義しており、これにはちゃんと理由があるそうです。
Statelessで実装する方法もあるそうですが、基本Statefulで定義して良いかと思います。

class AnimationArc extends StatefulWidget {
  final int stepCount;
  final int targetStepCount;

  const AnimationArc({required this.stepCount, required this.targetStepCount});

  @override
  _AnimationArc createState() => _AnimationArc();
}

続いてAanimationArc内の記述です。AnimationControllerを使うための準備としてSingleTickerProviderStateMixinをStateで使えるようにします(詳しくは下の方で説明します)。

class _AnimationArc extends State<AnimationArc>
  with SingleTickerProviderStateMixin {

   late AnimationController _animationController;
   late Animation<double> _animation;
   bool animationPlayed = false;
}

AnimationControllerにdurationとvsyncを設定します。

@override
  void initState() {
    super.initState();

    _animationController = AnimationController(
      duration: const Duration(milliseconds: 1000),
      vsync: this,
    );

   //////////////////////////
   //Curved Animationの記述↓//
   //////////////////////////
  }

durationは単純にアニメーションの長さを意味しますが、vsyncは少しややこしいです。
そもそもアニメーションを実現するにはデバイスのフレームレート(1秒間に何枚画像が表示されるか)に合わせて画面を更新する必要があります。フレームレートはデバイスによって異なり、60だったり120だったり90だったりするので、これを検知してくれるクラスが必要になります。それがSchedulerBindingクラスになります。
しかし画面端や画面裏にアニメーションが残っている状態だと毎回検知して通知が飛び、パフォーマンスに影響するため、これを制御するクラスが必要になります。それがTickerクラスになります。SchedulerBindingはTickerに一度だけ通知を送り、さらにTicker内でアニメーションを行うかの制御をするので、無駄にアニメーションすることを避けられれます。
vsyncはanimationContollerのパラメータとしていますが、実は上記のTickerに渡しています。
vsyncはTickerProvider型の値でcreateTicker メソッドを持つものを入れる必要がありますが、これを細かく設定する必要はないので、thisを使うことになります。
thisというのは先ほど出てきたSingleTickerProviderStateMixinそのものになります。これがTickerの設定をいい感じに管理してくれます。
逆に細かい制御が必要な場合はTickerProviderを細かく設定する必要があります。基本はmixinを使えば問題ありません。
加えてもしAnimationControllerが複数必要な場合はTickerProviderStateMixinを使う必要があります。
冒頭でStatefulの実装が多いと頭出ししましたが、これはSingleTickerProviderStateMixinを使う場合、名前の通りStateを使う必要があるためStatefulでの実装になってしまうということになります。

次にcurvedAnimationを設定していきます。

void initState() {

   //////////////////////////
   //  animationController //
   //////////////////////////

    final curvedAnimation =
        CurvedAnimation(parent: _animationController, curve: Curves.easeInOut);

    _animationController.forward(from: 0.0);
  }

CurvedAnimationを利用するには、引数のparentに先ほど定義したanimationContollerを指定します。curveはアニメーションの動きの設定する引数になります。動きには41種類ほどあるそうで、こちらの記事を参考にしました。はじめと終わりがゆっくりになるようなeaseInOutを今回は使ってみました。

もしアニメーションの開始位置や終了位置を変えたいなどありましたらTweenを使うといいでしょう。beginとendの引数に0〜1の数値を代入して使います。今回は一律して0→1でアニメーションさせるので使ってません。

 _animation = Tween<double>(begin: /*開始位置*/, end: /*終了位置*/).animate(curvedAnimation)
      ..addListener(() {
        setState(() {});
      });

またanimationControllerが終了した際にメモリ上に残らないように、animationController.dispose() を行う必要があるので忘れず行いましょう。

void dispose() {
    _animationController.dispose();
    super.dispose();
  }

次に実際にテキストやPieChartPainterで描画していきます。

Widget build(BuildContext context) {
    return Container(
      child: LayoutBuilder(
          builder: (BuildContext context, BoxConstraints constraints) {
        double parentWidth = constraints.maxWidth;
        double parentHeight = constraints.maxHeight;
        const completeText = 'Completed!';

        return Stack(children: [
          CustomPaint(
            child: Container(),
            painter: PieChartPainter(
              stepCount: widget.stepCount,
              targetStepCount: widget.targetStepCount,
              arcAnimation: _animationController.value,
            ),
          ),
          Positioned(
            top: parentHeight / 3.5,
            left: 0,
            right: 0,
            child: Center(
              child:widget.stepCount >= widget.targetStepCount ?Column(
                children: [
                  const Padding(
                    padding: EdgeInsets.only(bottom: 18),
                    child: Text(
                      completeText,
                      style: TextStyle(
                          color: Color.fromARGB(255, 255, 255, 255),
                          fontSize: 25,
                          fontWeight: FontWeight.w800),
                      textAlign: TextAlign.center,
                    ),
                  ),
                  Text(
                    "${(widget.stepCount * _animationController.value).round()}/${widget.targetStepCount.round()}",
                          style: const TextStyle(
                              color: Color.fromARGB(255, 255, 255, 255),
                              fontSize: 22,
                              fontWeight: FontWeight.w800),
                          textAlign: TextAlign.center,
                        ),
                ],
              ) : Padding(
                padding: const EdgeInsets.only(top: 15),
                child: Text(
                        "${(widget.stepCount * _animationController.value).round()}/${widget.targetStepCount.round()}",
                        style: const TextStyle(
                            color: Color.fromARGB(255, 255, 255, 255),
                            fontSize: 25,
                            fontWeight: FontWeight.w800),
                        textAlign: TextAlign.center,
                      ),
              ),
            ),
          ),
        ]);
      }),
    );
  }

長いですが、主にフォーカスして欲しいのはchildren下のWidgetになります。

  • CustomPaint‥PieChartPainter(円グラフ)のCanvas

  • Positioned‥数値のテキスト

Positionedを使うことで、Canvas描画しているWidgetの上に重なるようにテキストを配置することができます。
そしてPieChartPainterには分子・分母の値、そしてanimationControllerで設定した値を指定します。値は_animationController.valueで参照することができます。上記値はPositioned内のテキストもアニメーションさせたいので仕様してます。

PieChartPainter

animationの値を参照しつつCanvasで円グラフを描画していきます。
描画する必要なCanvasは3つに分かれており、以下になります。

  • drawCircle‥内側の円(グラデーションあり)

  • 前面のdrawArc‥アニメーションする外側のアーチの部分

  • 背面のdrawArc‥黒背景のアーチの部分

3種類のCanvasの組み合わせ

drawCircleは以下のコードで描画できます。

    //グラデーション付きの円グラフ
    final shaderRect = Rect.fromCircle(center: centerOffset, radius: radius / 1.4);
    final pie = Paint();
    pie.shader = LinearGradient(
            begin: Alignment.topRight,
            end: Alignment.bottomLeft,
            colors: gradientColor,
            stops: gradientStops)
        .createShader(shaderRect);

円形のCanvasを描画したい場合、設定はRect.fromCircleに引数をそれぞれ指定します。centerは真ん中の位置になります。centerOffsetは以下のような定義をしています。

final centerOffset = Offset(size.width / 2, size.height / 3);

ここで注意してほしいのはOffset型の値であるということです。
fromCircle以外のCanvasでcenterを指定する場合にAlignment型の値を入れることがありますので、使い分けが必要になります。今回はfromCircleしか仕様しないのでOffset型で定義します。
またsizeを指定する際にWidgetの横・縦幅が指定される必要があります。ここでは親Widgetの幅指になるように調整してます。

そしてPaintを定義して、細かい描画を設定します。
今回はLinerGradientを使って線形のグラデーションになるようにしました。
LinerGradientで指定するのはグラデーションの始まり位置(begin)、終わり位置(end)、色(colors)、グラデーションの境目の位置(stops)になります。
始まりは右上(topRight)から左下(bottomLeft)で終わるように設定します。
色と境目は配列で定義する必要があり下のように設定してます。

//色
gradientColor = <Color>[
        Colors.red,
        Colors.yellow,
      ];
//グラデーションの境目の位置
gradientStops = [0.1, 0.9];

注意すべき箇所は境目の位置です。
0〜1の割合で位置を特定しますが、始まりの位置である1番目のインデックスが0.5以上になると、境目が以下のようにくっきり写ってしまいます。

配列を[0.5, 0.5]にした場合

くっきり映さず、ぼやけたようなUIにする場合は最低でも0.3以下に納める方がいいと思います。今回は固定で[0.1, 0.9]で設定してます。

設定が完了して、描画するには以下のメソッドを実行します。
第一引に円の真ん中の位置、第二引数が半径の長さ、第三引数がPaint型の設定になります。

    canvas.drawCircle(shaderRect.center, size.width / 3.0, pie);

続いてdrawArcの部分は以下のよう設定します。

   //外側のアーチ
    final outerPaint = Paint()
      ..color = outerLineColor
      ..strokeCap = StrokeCap.round
      ..strokeWidth = 30.0
      ..style = PaintingStyle.stroke;

      canvas.drawArc(
          Rect.fromCircle(center: centerOffset, radius: radius / 1.5),
          -5 * pi / 4,
          6 * pi / 4,
          false,
          outerPaint);

     //内側のアーチ
     final innerPaint = Paint()
      ..color = lineColor
      ..strokeCap = StrokeCap.round
      ..strokeWidth = 25.0
      ..style = PaintingStyle.stroke;

      canvas.drawArc(
          Rect.fromCircle(center: centerOffset, radius: radius / 1.5),
          -5 * pi / 4,
          6 * pi / 4 * percentage * arcAnimation,
          false,
          innerPaint);

内側の円よりは大分シンプルですね。
設定する箇所としてはアーチの色(color),線の先の形(strokeCap),アーチの太さ(strokeWidth),塗りつぶすか線を引くか(style)になります。
線の先を丸めたい場合はstrokeCapにStrokeCap.roundに割り当てます
また外側のアーチは少し線の幅を広めに取ってます。

そして描画する際は円がdrawCircleだったのに対してアーチはdrawArcになります。drawArcの引数は以下のようになります。

  • 第一引数‥内接矩形

  • 第二引数‥アーチの開始位置

  • 第三引数‥アーチの終了位置

  • 第四引数‥中心に連結するか

  • 第五引数‥Paint

特筆すべきはアーチの終了位置になります。
内側のアーチのみ[6 * pi / 4 * percentage * arcAnimation]としています。
percentageは分子÷分母の割合になります。
対してarcAnimationは引数で渡ってきたanimationController.valueの値になります。つまりdurationで設定した秒数になるまで、この値はデバイスのフレームレート毎に変化し続ける値になります。こうしてアーチ部分をアニメーションさせることが出来るようになります。イメージできましたでしょうか?

またアニメーションのないcanvasだと再描画する必要がないですが、今回はアニメーションが動くたびにリビルドされるのでshouldRepaintをtrueに設定します。

bool shouldRepaint(PieChartPainter oldDelegate) => true;

残りの値の宣言や条件分岐部分も含めた全体のソースが下記になります。

class PieChartPainter extends CustomPainter {
  final int stepCount;
  final int targetStepCount;
  double arcAnimation;

  PieChartPainter(
      {required this.stepCount,
      required this.targetStepCount,
      required this.arcAnimation});

  @override
  void paint(Canvas canvas, Size size) {
    final percentage = stepCount / targetStepCount;
    final centerAligment = Alignment(size.width / 2, size.height / 2);
    final centerOffset = Offset(size.width / 2, size.height / 3);
    final radius = min(size.width / 2, size.height / 2);
    final outerLineColor = Color.fromARGB(255, 28, 33, 83);

    final lineColor;
    double arcAngle = 2 * pi * (percentage / 100);
    dynamic gradientColor = 0;
    dynamic gradientStops;
    final beginAlignment;
    final endAlignment;

    //色の条件分岐
    if (percentage <= 0.2) {
      lineColor = Colors.red;
      gradientColor = <Color>[
        Colors.red,
        Colors.yellow,
      ];
    } else if (percentage <= 0.4) {
      lineColor = Colors.yellow;
      gradientColor = <Color>[
        Colors.yellow,
        Colors.green,
      ];
    } else if (percentage <= 0.6) {
      lineColor = Colors.green;
      gradientColor = <Color>[
        Colors.green,
        Colors.cyanAccent,
      ];
    } else if (percentage <= 0.8) {
      lineColor = Colors.cyanAccent;
      gradientColor = <Color>[
        Colors.cyanAccent,
        Colors.blue,
      ];
    } else if (percentage < 1.0) {
      lineColor = Colors.blue;
      gradientColor = <Color>[
        Colors.blue,
        Colors.purple,
      ];
    } else {
      lineColor = Colors.transparent;
      gradientColor = <Color>[
        Colors.purple,
        Colors.purple,
      ];
    }

    //グラデーションの条件分岐
    if (percentage <= 0.5) {
      beginAlignment = Alignment.bottomLeft;
      endAlignment = Alignment.topRight;
      gradientStops = [0.1, 0.9];
    } else {
      gradientStops = [0.1, 0.9];
      beginAlignment = Alignment.topRight;
      endAlignment = Alignment.bottomLeft;
    }

    final shaderRect =
        Rect.fromCircle(center: centerOffset, radius: radius / 1.4);
    final pie = Paint();
    pie.shader = LinearGradient(
            begin: Alignment.topRight,
            end: Alignment.bottomLeft,
            colors: gradientColor,
            stops: gradientStops)
        .createShader(shaderRect);

    final outerPaint = Paint()
      ..color = outerLineColor
      ..strokeCap = StrokeCap.round
      ..strokeWidth = 30.0
      ..style = PaintingStyle.stroke;

    final innerPaint = Paint()
      ..color = lineColor
      ..strokeCap = StrokeCap.round
      ..strokeWidth = 25.0
      ..style = PaintingStyle.stroke;

    canvas.drawCircle(shaderRect.center, size.width / 3.0, pie);

    if (stepCount < targetStepCount) {
      canvas.drawArc(
          Rect.fromCircle(center: centerOffset, radius: radius / 1.5),
          -5 * pi / 4,
          6 * pi / 4,
          false,
          outerPaint);

      canvas.drawArc(
          Rect.fromCircle(center: centerOffset, radius: radius / 1.5),
          -5 * pi / 4,
          6 * pi / 4 * percentage * arcAnimation,
          false,
          innerPaint);
    }
  }

  @override
  bool shouldRepaint(PieChartPainter oldDelegate) => true;
}

最後に

今回は円グラフのCanvasにグラデーションやアニメーションを盛り込んでみました。
ぱっと見簡単そうですが、幾何学的な計算が必要だったりするので、初見だと戸惑うことが多いかなーって印象でした。
実際のアプリだと、これくらいリッチそうなUIはよく見かけると思いますので、もし作るなら既存のライブラリになどに頼りたいところですが、Flutterも歴史が浅いので要件やデザインにピンポイントで使えそうなものがあまりないのが現状かと思いますので、自力でゴリゴリ書くという選択肢をいつでも選べるようにしておくのがベストかなぁと思います。

この記事が気に入ったらサポートをしてみませんか?