【Flutter】色のアニメーションを2つの方法で実装

対象者

  • Flutterで色をアニメーションで変えたい人

はじめに

ちょっとボタンを目立つようにしたいなぁと思い、アニメーションでボタンの色が変更するようにしようとしました。検索しましたが、意外とうまくいく例がありませんでした。色々やって自分が納得できるものが完成しましたので、以下の二通りで紹介します。

  • AnimationController + AnimatedBuilder
    複雑な処理ができる、はず。

  • TweenAnimationBuilder
    単純

実施するソース

AnimationController + AnimatedBuilder

  late final _controller = AnimationController(
    duration: const Duration(milliseconds: 1000),
    vsync: this,
  );

  late final Animation<Color?> _animatinColor = ColorTween(
    begin: Colors.blue,
    end: Colors.red,
  ).animate(_controller);

  bool _isForward = false;

AnimationControllerでアニメーションのコントローラーを定義する。アニメーションを再生させたり、逆再生させたりする。使用後、disposeする必要がある(StatefuleWidgetのdisposeで実行する)。
ColorTweenで値の変化を定義する。今回は、青色から赤色へ変化する一連の値が生成されている。
_isForwardでアニメーションの再生状態を記録しておく。アニメーションを再生すればtrueになり、逆再生するとfalseになる。アニメーションを開始するときに、forwardとrewardでどちらを実施するかに使う。

AnimatedBuilder(
 animation: _animatinColor,
 builder: (context, child) {
   	return FilledButton(
   	  onPressed: () {
   			if (_isForward) {
   			  _controller.reverse();
   			} else {
   			  _controller.forward();
   			}
   			setState(() {
   			  _isForward = !_isForward;
   			});
   	  },
   	  style: FilledButton.styleFrom(
   		  backgroundColor: _animatinColor.value),
   	  child: child,
   	);
 },
 child: Text(_isForward ? '青にする' : '赤にする')
),
  • AnimatedBuilder(animation: _animatinColor,
    AnimatinBuilderで実装する。使用する変化する値として、色の値(_animatinColor)を渡す。
  • FilledButton.onPress

    _isForwardでアニメーションの再生状態が記録されているので、状態によってアニメーションを再生・逆再生する。
    再生後に _isForwardをひっくり返して、再生状態を記録する。

  • FilledButton.style: FilledButton.styleFrom( backgroundColor: _animatinColor.value)

    ボタンの背景色の設定に色の値(_animatinColor)を設定する。こうすることで、アニメーション再生時に値が変化していくので、一緒に色が変化していく。

  • child
    さて、child がAnimatedBuilderとFilledButtonの二箇所にあります。さらに、FilledButtonには、builderの引数のchildを渡している。何でだろうと思いました。
    簡単にしてしまうと、FilledButtonの下のchildにそのまま子Widgetを書いてしまっても大丈夫です。
    ただ、AnimatedBuilderにchildを定義する優位性は、WidgetTreeを分けて、アニメーション部分だけ更新できることです。どういうことかと言いますと、ここの場合Textがあります。このTextですが、FilledButtonの下に書くと、アニメーションするごとにWidgetを再生します。しかしAnimatedBuilderのchildに定義することで、アニメーションによって再作成されることなく、アニメーションの最中も前後も同じTextを使い続けることができます。(この例だと_isForwardが変わった時点で、1度再生成されます。しかしアニメーション中は再生成されません)

TweenAnimationBuilder

TweenAnimationBuilderを使った実装です。AnimationControllerがなくて、簡単に書けます、一度書いたら(苦労しました)。

Color _bottomColor = Colors.blue;
  
TweenAnimationBuilder(
    tween: ColorTween(end: _bottomColor),
    duration: const Duration(seconds: 1),
    builder: (BuildContext context, Color? color, Widget? child) {
      return FilledButton(
          style: FilledButton.styleFrom(backgroundColor: color),
          onPressed: () {
            setState(() {
                _bottomColor = _bottomColor == Colors.blue
                   ? Colors.red: Colors.blue;
            });
          },
          child: child,
      );
    },
  child: Text(_bottomColor != Colors.blue ? '青にする' : '赤にする'),
),
  • tween: ColorTween(end: _bottomColor),
    Tweenなので最初begin も書いてましたが、トラップでした。なくても大丈夫です。end だけだと、最終何色になる、というのを設定すれば、時間通りに変化して色が変わる、と腑に落ちました。

  • setState(() { _bottomColor = _bottomColor == Colors.blue? Colors.red: Colors.blue; });
    状態管理のsetStateを使って、最終何色にしたいかを設定します。今の色が青なら赤、赤なら青になるように設定します。

  • child
    前の節と同じです。

まとめ

以上で色のアニメーション方法を解説しました。検索してみると、これぞ、というサンプルがなかったので自分で作ってみました。
(自分のところではアニメーションしなかったり、リスナーにsetStateを入れて実行したり、null-safety以前だったり)
いやぁ、分かってしまえば簡単ですが、結構苦労しました。あなたのお役に立てると幸いです(あと、数ヶ月後の自分)

参考

全ソース

import 'package:flutter/material.dart';

void main() => runApp(MyApp());

class MyApp extends StatefulWidget {
  MyAppState createState() => MyAppState();
}

class MyAppState extends State<MyApp> with TickerProviderStateMixin {
  late final _controller = AnimationController(
    duration: const Duration(milliseconds: 1000),
    vsync: this,
  );

  late final Animation<Color?> _animatinColor = ColorTween(
    begin: Colors.blue,
    end: Colors.red,
  ).animate(_controller);

  bool _isForward = false;

  Color _bottomColor = Colors.blue;

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }
  
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      home: Scaffold(
        appBar: AppBar(title: Text('Animation Color')),
        body: Padding(
          padding: const EdgeInsets.all(24),
          child: Column(
            children: <Widget>[
              AnimatedBuilder(
                  animation: _animatinColor,
                  builder: (context, child) {
                    return FilledButton(
                      onPressed: () {
                        if (_isForward) {
                          _controller.reverse();
                        } else {
                          _controller.forward();
                        }
                        setState(() {
                          _isForward = !_isForward;
                        });
                      },
                      style: FilledButton.styleFrom(
                          backgroundColor: _animatinColor.value),
                      child: child,
                    );
                  },
                  child: Text(_isForward ? '青にする' : '赤にする')),
              TweenAnimationBuilder(
                tween: ColorTween(end: _bottomColor),
                duration: const Duration(seconds: 1),
                builder: (BuildContext context, Color? color, Widget? child) {
                  return FilledButton(
                    style: FilledButton.styleFrom(backgroundColor: color),
                    onPressed: () {
                      setState(() {
                        _bottomColor = _bottomColor == Colors.blue
                            ? Colors.red
                            : Colors.blue;
                      });
                    },
                    child: child,
                  );
                },
                child: Text(_isForward ? '青にする' : '赤にする'),
              ),
            ],
          ),
        ),
      ),
    );
  }
}