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

ソース
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にある公式動画 でざっくりとデキることが説明されています。
(英語力がヨワいので字幕が助かる)
はてなマーク(不動)は以下のように作ってます。
「棒線」 + 「円弧(3/4)」 + 「点」
// 描画基準位置
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]を使用しました。
- 全体の回転
- マークの変更 (? → !)
- 色の変更
まずはアニメーションの定義です。
_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
をべたで書くことになりました。きっといい方法があるはず…
Curves: ***
は、値の曲線移動を指定できます。
種類が豊富なので、公式サイト[2] をみてよさげなものを探してみてください。
アニメーションで変動させた値を使用して、先ほど用意したCustomPainter
を描画します。
AnimatedBuilder
を使用することで、
animation: ***
に指定した対象が変化するたびにbuilderが呼ばれ再描画してくれます。
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感)
今回はカンタンなアニメーションでしたが、
ガンバれば複雑なアニメーションも自作できそうでイイ感じでした。
もっといい方法だったり、
カンタンにカスタムできるパッケージなどがあれば教えて頂ければ助かります。