[Flutter]CustomPainterとアニメーションでローディング表示を作ってみる

# はじめに :beginner:

クイズアプリ作成中に、「ゲームっぽく、**ローディング表示をカスタム**したいな」と思ったので、
描画処理やアニメーション処理の学習がてらにインジゲーターを作ってみることにしました。



# つくったモノ

`CircularProgressIndicator`のように使えるイメージで作りました。

<img width="200" alt="loading.gif" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/510027/de54c24e-8adc-b7ff-66ff-75e3c7deee6c.gif">



# ソース

[全ソース](https://github.com/yakitama5/quiz_indicator)

```dart
import 'dart:math';

import 'package:flutter/material.dart';
import 'package:flutter_sequence_animation/flutter_sequence_animation.dart';

class QuizProgressIndicator extends StatefulWidget {
  const QuizProgressIndicator({Key key}) : super(key: key);

  @override
  State<StatefulWidget> createState() => _QuizProgressIndicatorState();
}

class _QuizProgressIndicatorState extends State<QuizProgressIndicator>
    with SingleTickerProviderStateMixin {
  AnimationController _animationController;
  SequenceAnimation _sequenceAnimation;
  bool _isReverse = false;

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

    _animationController = AnimationController(
      duration: const Duration(milliseconds: 1000),
      vsync: this,
    )..addStatusListener((status) {
        if (status == AnimationStatus.completed) {
          _isReverse = !_isReverse;
          _animationController.reset();
          _animationController.forward();
        }
      });
//      ..repeat();

    _sequenceAnimation = SequenceAnimationBuilder()
        // 回転
        .addAnimatable(
            animatable: Tween<double>(begin: 0.0, end: pi * 2),
            from: Duration.zero,
            to: const Duration(milliseconds: 1000),
            curve: Curves.easeInOutQuint,
            tag: "rotate")
        // フェードアウト
        .addAnimatable(
            animatable: Tween<double>(begin: 1.0, end: 0.0),
            from: Duration.zero,
            to: const Duration(milliseconds: 500),
            curve: Curves.easeInQuart,
            tag: "fade_out")
        // フェードイン
        .addAnimatable(
            animatable: Tween<double>(begin: 0.0, end: 1.0),
            from: const Duration(milliseconds: 500),
            to: const Duration(milliseconds: 1000),
            curve: Curves.easeOutQuart,
            tag: "fade_in")
        // 色変更
        .addAnimatable(
            animatable:
                ColorTween(begin: Colors.blueAccent, end: Colors.pinkAccent),
            from: Duration.zero,
            to: const Duration(milliseconds: 1000),
            curve: Curves.easeInOutQuint,
            tag: "color_change")
        // 色変更(reverse)
        .addAnimatable(
            animatable:
                ColorTween(begin: Colors.pinkAccent, end: Colors.blueAccent),
            from: Duration.zero,
            to: const Duration(milliseconds: 1000),
            curve: Curves.easeInOutQuint,
            tag: "color_reverse")
        .animate(_animationController);
  }

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

  @override
  Widget build(BuildContext context) {
    return AnimatedBuilder(
      animation: _animationController..forward(),
      builder: (context, child) {
        final double fadeOutVal = _sequenceAnimation["fade_out"].value;
        final double fadeInVal = _sequenceAnimation["fade_in"].value;
        final Color color = _isReverse
            ? _sequenceAnimation["color_reverse"].value
            : _sequenceAnimation["color_change"].value;

        return Transform.rotate(
          angle: _sequenceAnimation["rotate"].value,
          child: CustomPaint(
            foregroundPainter: _MarkPainter(
              questionAnimationValue: _isReverse ? fadeInVal : fadeOutVal,
              exclamationAnimationValue: _isReverse ? fadeOutVal : fadeInVal,
              color: color,
            ),
          ),
        );
      },
    );
  }
}

class _MarkPainter extends CustomPainter {
  final double _questionAnimationValue;
  final double _exclamationAnimationValue;
  final Color _color;

  _MarkPainter(
      {Key key,
      @required double questionAnimationValue,
      @required double exclamationAnimationValue,
      @required Color color})
      : assert(questionAnimationValue != null),
        assert(exclamationAnimationValue != null),
        assert(color != null),
        _questionAnimationValue = questionAnimationValue,
        _exclamationAnimationValue = exclamationAnimationValue,
        _color = color,
        super();

  @override
  void paint(Canvas canvas, Size size) {
    // はてなマークの描画
    _paintQuestionMark(canvas, size);

    // ビックリマークの描画
    _paintExclamation(canvas, size);

    //　中心点の描画
    final Offset center = Offset(size.width / 2, size.height / 2);
    final Paint circlePaint = Paint()..color = _color;
    canvas.drawCircle(center, 10, circlePaint);
  }

  void _paintExclamation(Canvas canvas, Size size) {
    final double coefficient = _exclamationAnimationValue;
    final double widthCenter = size.width / 2;
    final double heightCenter = size.height / 2;

    // 棒線
    final Offset lineFrom =
        Offset(widthCenter, heightCenter - (20 * coefficient));
    final Offset lineTo =
        Offset(widthCenter, heightCenter - (80 * coefficient));
    final linePaint = Paint()
      ..color = _color
      ..strokeCap = StrokeCap.round
      ..style = PaintingStyle.stroke
      ..strokeWidth = 15;
    canvas.drawLine(lineFrom, lineTo, linePaint);
  }

  void _paintQuestionMark(Canvas canvas, Size size) {
    final double coefficient = _questionAnimationValue;
    final double widthCenter = size.width / 2;
    final double heightCenter = size.height / 2;

    // 棒線
    final Offset lineFrom =
        Offset(widthCenter, heightCenter - (20 * coefficient));
    final Offset lineTo =
        Offset(widthCenter, heightCenter - (35 * coefficient));
    final linePaint = Paint()
      ..color = _color
      ..strokeCap = StrokeCap.round
      ..style = PaintingStyle.stroke
      ..strokeWidth = 15;
    canvas.drawLine(lineFrom, lineTo, linePaint);

    // 曲線
    final arcPaint = Paint()
      ..color = _color
      ..strokeCap = StrokeCap.round
      ..style = PaintingStyle.stroke
      ..strokeWidth = 15;
    final Rect arcRect = Rect.fromCircle(
        center: Offset(widthCenter, heightCenter - (60 * coefficient)),
        radius: 20 * coefficient);
    final arcFrom = -pi * coefficient;
    final arcTo = pi * 1.5 * coefficient;
    canvas.drawArc(arcRect, arcFrom, arcTo, false, arcPaint);
  }

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




# 説明

### おえかき

まずは、`CustomPainter`を使用して**びっくりマーク**と**はてなマーク**を描画します。
`CustomPainter`については、[YouTubeにある公式動画](https://www.youtube.com/watch?v=kp14Y4uHpHs&t=3s) でざっくりとデキることが説明されています。
(英語力がヨワいので字幕が助かる:sob:)





はてなマーク(**不動**)は以下のように作ってます。
「**棒線**」 + 「**円弧(3/4)**」 + 「**点**」

```dart
    // 描画基準位置
    final double widthCenter = size.width / 2;
    final double heightCenter = size.height / 2;

    // 棒線
    final Offset lineFrom =
        Offset(widthCenter, heightCenter - 20);
    final Offset lineTo =
        Offset(widthCenter, heightCenter - 35);
    final linePaint = Paint()
      ..color = Colors.blueAccent
      ..strokeCap = StrokeCap.round  // 円ブラシで描画
      ..style = PaintingStyle.stroke // 線のみ描画 (塗りつぶしなし)
      ..strokeWidth = 15;            // 線幅
    canvas.drawLine(lineFrom, lineTo, linePaint);

    // 曲線 = 円弧(3/4)
    final arcPaint = Paint()
      ..color = Colors.blueAccent
      ..strokeCap = StrokeCap.round
      ..style = PaintingStyle.stroke
      ..strokeWidth = 15;
    // `drawArc`は四角形を基準として、内側に曲線を描く
    final Rect arcRect = Rect.fromCircle(
        center: Offset(widthCenter, heightCenter - 60),
        radius: 20);
    final arcFrom = -pi;     // 曲線の開始位置 (左端から)
    final arcTo = pi * 1.5;  // 曲線の長さ (下端まで)
    canvas.drawArc(arcRect, arcFrom, arcTo, false, arcPaint);
```

※ びっくりマークは棒線部分を伸ばすだけでデキるので割愛します。



### うごかす

次は描画したマークを動かします。
以下3種類の動きを同時に行うため、`SequenceAnimation`[[1]](#参考)を使用しました。

- 全体の回転
- マークの変更 (? → !)
- 色の変更



まずはアニメーションの定義です。

```dart
    _animationController = AnimationController(
      duration: const Duration(milliseconds: 1000), // アニメーション全体の時間(1秒)
      vsync: this,
    )..addStatusListener((status) {
        if (status == AnimationStatus.completed) {
          _isReverse = !_isReverse;       // マーク,色を反転する
          _animationController.reset();   // もう一度流すためにリセット
          _animationController.forward(); // 再生
        }
      });

    _sequenceAnimation = SequenceAnimationBuilder()
        // 回転
        .addAnimatable(
            animatable: Tween<double>(begin: 0.0, end: pi * 2), // 変動値を指定
            from: Duration.zero, // 0秒(再生開始)から
            to: const Duration(milliseconds: 1000), // 1000ミリ秒(1秒)まで
            curve: Curves.easeInOutQuint, // 後述
            tag: "rotate")
        // フェードアウト
        .addAnimatable(
            animatable: Tween<double>(begin: 1.0, end: 0.0),
            from: Duration.zero,
            to: const Duration(milliseconds: 500),
            curve: Curves.easeInQuart,
            tag: "fade_out")
        // フェードイン
        .addAnimatable(
            animatable: Tween<double>(begin: 0.0, end: 1.0),
            from: const Duration(milliseconds: 500),
            to: const Duration(milliseconds: 1000),
            curve: Curves.easeOutQuart,
            tag: "fade_in")
        // 色変更
        .addAnimatable(
            animatable:
                ColorTween(begin: Colors.blueAccent, end: Colors.pinkAccent),
            from: Duration.zero,
            to: const Duration(milliseconds: 1000),
            curve: Curves.easeInOutQuint,
            tag: "color_change")
        // 色変更(reverse)
        .addAnimatable(
            animatable:
                ColorTween(begin: Colors.pinkAccent, end: Colors.blueAccent),
            from: Duration.zero,
            to: const Duration(milliseconds: 1000),
            curve: Curves.easeInOutQuint,
            tag: "color_reverse")
        .animate(_animationController);
```

`AnimationController`の`repeat`を使った場合はステータスが変更されないのか、
`reset` + `forward`をべたで書くことになりました。きっといい方法があるはず…:confused:

`Curves: ***`は、**値の曲線移動**を指定できます。
種類が豊富なので、公式サイト[[2]](#参考) をみてよさげなものを探してみてください。



アニメーションで変動させた値を使用して、先ほど用意した`CustomPainter`を描画します。
`AnimatedBuilder`を使用することで、
`animation: ***`に指定した対象が変化するたびに**builderが呼ばれ再描画**してくれます。

```dart
return AnimatedBuilder(
      animation: _animationController..forward(), // アニメーション開始
      builder: (context, child) {
        // 用意した`CustomPainter`に渡す値を設定
        final double fadeOutVal = _sequenceAnimation["fade_out"].value;
        final double fadeInVal = _sequenceAnimation["fade_in"].value;
        final Color color = _isReverse
            ? _sequenceAnimation["color_reverse"].value
            : _sequenceAnimation["color_change"].value;

        return Transform.rotate(
          angle: _sequenceAnimation["rotate"].value, // 回転
          child: CustomPaint(
            foregroundPainter: _MarkPainter(
              questionAnimationValue: _isReverse ? fadeInVal : fadeOutVal,    // はてなマークの係数
              exclamationAnimationValue: _isReverse ? fadeOutVal : fadeInVal, // びっくりマークの係数
              color: color, // 全体の色
            ),
          ),
        );
      },
    )
```



後は描画ウィジェット側で、与えられた係数と色に沿って描画を行うように変更すれば終わりです。





# まとめ



**たのしい！！！(KONAMI感):blush:**


今回はカンタンなアニメーションでしたが、
ガンバれば複雑なアニメーションも自作できそうでイイ感じでした。

もっといい方法だったり、
カンタンにカスタムできるパッケージなどがあれば教えて頂ければ助かります。



# 参考
1. [flutter_sequence_animation](https://pub.dev/packages/flutter_sequence_animation)
2. [Curves](https://api.flutter.dev/flutter/animation/Curves-class.html)
3. [Flutterのお手軽にアニメーションを扱えるAnimated系Widgetをすべて紹介](https://medium.com/flutter-jp/implicit-animation-b9d4b7358c28)
4. [Flutterでアニメーション](https://qiita.com/yoppie/items/4d12e1b85fc6ff2d7a4a)
5. [ペルソナ5風のチャットのふきだしをFlutterで作ってみた](https://qiita.com/kouT127/items/f533cb673b5f80221a37)