[Flutter] コインをひっくり返して、アニメーションをマスターしよう

  • 2022年12月17日
  • 2022年12月15日
  • 小物

Flutter Advent Calendar 2022」に参加させて頂きます!17日目です。

はじめに

ナイスなテーマの切り替えボタンが欲しい!ということで、自分で作ってみました。こんなのです。太陽になったときだけカウントします。

対象者

  • 表と裏のコインやカードを反転させるようなアニメーションがしたい
  • アニメーションが知りたい
  • 子Widgetのイベントに応じて、親Widgetのメソッドを実施したい
  • Flutter Advent Calendar 2022のFlutterカレンダーを読破したい

ちなみに、単に2つのWidgetを裏表で回転させたいだけなら「flip_card」のパッケージを使うのが早いです。そちらで不十分だったときがあり、そのときにこの方法で実施しました

インストール

画像はなんでも良いのですが、このプロジェクトではSVGファイルを使用しています。SVGを使用することに興味があれば、以下のブログをご参照ください。

[Flutter] イケてるICOOON MONOのSVGアイコンを使用する

画像として、「assets/images」内にday_icon.svgとnight_icon.svgがあるとします。

コイン(?テーマの切り替えボタン)のソース

まず、全体ではこんなソースです。後で細かく見ていきます。


/// コインの状態
enum CoinStatus {
  /// 太陽
  sun,
  /// 月
  moon;
}
/// 太陽と月がそれぞれ表と裏に書かれているコインをイメージして作成しました。
class SunAndMoonCoin extends StatefulWidget {
  const SunAndMoonCoin({
    super.key,
    this.callback,
    this.duration = const Duration(milliseconds: 100),
    this.initStatus = CoinStatus.sun,
    this.size = 32,
    this.color = Colors.orangeAccent,
  });
  /// 太陽と月が入れ替わるときに実施されるコールバックを設定します
  /// 例:テーマの入れ替え
  final void Function(CoinStatus coinStatus)? callback;
  /// アニメーションの時間
  final Duration duration;
  ///初期状態
  final CoinStatus initStatus;
  /// サイズ
  final double size;
  ///アイコンの色
  final Color color;
  @override
  State createState() => _SunAndMoonCoinState();
}
class _SunAndMoonCoinState extends State<SunAndMoonCoin>
    with SingleTickerProviderStateMixin<SunAndMoonCoin> {
  /// 太陽のアイコン
  late final sunIcon = SvgPicture.asset(
    'assets/images/day_icon.svg',
    color: widget.color,
    height: widget.size,
    width: widget.size,
  );
  /// 月のアイコン
  late final moonIcon = SvgPicture.asset(
    'assets/images/night_icon.svg',
    color: widget.color,
    height: widget.size,
    width: widget.size,
  );
  /// アニメーションの値の初期値
  static const startValue = 0.0;
  /// アニメーションの値の終了値
  static const endValue = 1.0;
  /// コインの表裏の入れ替わり値
  static const breakValue = (startValue + endValue) / 2;
  /// 現在の状態
  CoinStatus _currentStatus = CoinStatus.sun;
  /// アニメーションのコントローラ
  late final AnimationController _controller = AnimationController(
    vsync: this,
    duration: widget.duration,
    value: startValue,
  );
  // アニメーションで変化する値
  late final Animation<double> _animationValue =
      Tween(begin: startValue, end: endValue).animate(
    CurvedAnimation(
      parent: _controller,
      curve: Curves.easeIn,
    ),
  )..addListener(
          () {
            // 太陽→月に変えるタイミング
            if (_currentStatus == CoinStatus.sun &&
                breakValue < _animationValue.value) {
              _currentStatus = CoinStatus.moon;
              _callback();
            }
            //  月→太陽 に変えるタイミング
            else if (_currentStatus == CoinStatus.moon &&
                _animationValue.value < breakValue) {
              _currentStatus = CoinStatus.sun;
              _callback();
            }
          },
        );
  /// 太陽と月が入れ替わるときにコースバックを実施する
  void _callback() {
    if (widget.callback != null) {
      widget.callback!(_currentStatus);
    }
  }
  @override
  void initState() {
    super.initState();
    // 初期値が月だった場合、月のアイコンにして、アニメーションを進めておく
    if (widget.initStatus == CoinStatus.moon) {
      _currentStatus = CoinStatus.moon;
      _controller.forward();
    }
  }
  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onTap: () {
        if (_animationValue.value == startValue) {
          _controller.forward();
        } else if (_animationValue.value == endValue) {
          _controller.reverse();
        }
      },
      child: AnimatedBuilder(
        animation: _controller,
        builder: (context, child) {
          return Transform(
            alignment: FractionalOffset.center,
            transform: Matrix4.identity()
              ..setEntry(3, 2, 0.0015)
              ..rotateY(math.pi * (1.0 - _animationValue.value)),
            child: _currentStatus == CoinStatus.sun ? sunIcon : moonIcon,
          );
        },
      ),
    );
  }
}

コインの状態の列挙型

別にboolean でも構わないのですが、せっかくなので現在の状態を表すための状態を作ります。sunであれば太陽、moonであれば月、が表示されている状態です。

/// コインの状態
enum CoinStatus {
  /// 太陽
  sun,

  /// 月
  moon;
}

SunAndMoonCoinクラス

const SunAndMoonCoin({
	super.key,
	this.callback,
	this.duration = const Duration(milliseconds: 100),
	this.initStatus = CoinStatus.sun,
	this.size = 32,
	this.color = Colors.orangeAccent,
});

汎用性も考えて、色々つけました。

  • callback:画像が切り替わったときに実施されるコールバックです。親クラスで定義したものを渡します
  • duration:アニメーションの長さです
  • initStatus:初期状態です。太陽を初期値で考えて設定してます。そのため月にした場合、太陽から月に変わるアニメーションを1回実行した状態にします。
  • size:アイコンの大きさ
  • color:アイコンの色

あとは、変数の定義ですね。ほとんど問題ないと思いますが、引っかかりそうなのはcallbackでしょうか。

final void Function(CoinStatus coinStatus)? callback;

プログラムを初めたばかりだだと戸惑う表現ですよね。私も初めての時、苦労しました。
callbackの実際のメソッドは、SunAndMoonCoinを呼び出すクラスに定義します。この場合、_MyHomePageStateになります。その中で以下のように定義しています。

 floatingActionButton: SunAndMoonCoin(
  callback: (CoinStatus status) {
    if (status == CoinStatus.sun) {
      incrementCounter();
    }
  },
),

このようにして子クラス内ではメソッドを呼び出して、親クラスでコールバックを実施します。そのコールバックする関数(Function)の形が引数がCoinStatusで、戻り値がvoidというわけです。finalが付いているからコンストラクタでのみ値を設定できる。?が付いているのでnullでも大丈夫、つまり、設定しなくても良いことが分かります。呼び出し元のクラスも引数が(CoinStatus status)となっておりreturnがないからvoidとなりますね。
ちなみにvoidでなく、intにすると、親クラスから子クラスにも値を渡すことができます。Functionを以下のようにintを戻り値として定義します。

final int Function(CoinStatus coinStatus)? callback;

そうすると親クラスのコールバック関数の定義で値が返せるようになります。

callback: (CoinStatus status) {
  if (status == CoinStatus.sun) {
    _incrementCounter();
  }
  return _counter;
}

Stateクラス

class _SunAndMoonCoinState extends State<SunAndMoonCoin>
  with SingleTickerProviderStateMixin<SunAndMoonCoin> {

アニメーションを使用するので、StatefulWidgetにしてます。さらに、 with SingleTickerProviderStateMixin もつけます。同時に動くアニメーションが1つしかないので、こちらを使います。アニメーションを複数動かしたいときはTickerProviderStateMixinをお使いください。

/// 太陽のアイコン
late final sunIcon = SvgPicture.asset(
  'assets/images/day_icon.svg',
  color: widget.color,
  height: widget.size,
  width: widget.size,
);

/// 月のアイコン
late final moonIcon = SvgPicture.asset(
  'assets/images/night_icon.svg',
  color: widget.color,
  height: widget.size,
  width: widget.size,
);

ここでは太陽と月のSVG画像の設定を行っています。別画像を使いたい人は、そちらをお使いください。Widgetなら何でも代替可能です。
Stateクラスなので、widgetで相棒のStatefulWidgetにアクセスできます。その中の値は初期化時にはアクセスできないので、lateで作成を遅らせて初期化後に値を取得することで、ここで定義するようにします。(ininStateで定義するとfinalが使えない)

/// アニメーションの値の初期値
static const startValue = 0.0;

/// アニメーションの値の終了値
static const endValue = 1.0;

/// コインの表裏の入れ替わり値
static const breakValue = (startValue + endValue) / 2;

/// 現在の状態
CoinStatus _currentStatus = CoinStatus.sun;

いよいよアニメーションが見えてきましたが、ここでは初期値を設定しているだけです。完全に太陽が出ているときは値が0、月が出ているときは1にします。回転しているときは0から1の途中の数となります。
太陽と月が入れ替わる時にイベントを発生させたいので、ちょうど中間地点の値を計算してます。あと、基本初期状態は太陽です。

/// アニメーションのコントローラ
late final AnimationController _controller = AnimationController(
  vsync: this,
  duration: widget.duration,
  value: startValue,
);

// アニメーションで変化する値
late final Animation<double> _animationValue =
  Tween(begin: startValue, end: endValue).animate(
  CurvedAnimation(
    parent: _controller,
    curve: Curves.easeIn,
  ),
)

さて、いよいよアニメーションの複雑なところが出てきました。最初は私も戸惑いましたし、毎回確認しながら(いや、コピペしているだけだけど)作成してます。
AnimationControllerは名前の通り、コントローラーです。アニメーションを再生したり、逆再生を指示します。
Animationは現在の値です。「アニメーション」といっても、実際はただの数字やオフセットの値です。一つのアニメーションの中でどのように値を変化させるか、そして現在の値はなにか、を管理してます。

AnimationControllerの引数

vsyncはどのTickerに同期するか、です。StateクラスにSingleTickerProviderStateMixinをつけてますので、そちらに同期させます。surationはアニメーションの始まりから終わりまでの時間を設定します。

Animationの設定

Tweenでアニメーションの開始時と終了時の値を設定します。しかし0から1まで等間隔にあげていくだけではありません。CurvedAnimationを使って、定義されている色々な上がり方を使用することができます。公式のCurves Classのドキュメントでご確認ください。
また、どのコントローラーに紐付くか定義してますね。

..addListener(
  () {
    // 太陽→月に変えるタイミング
    if (_currentStatus == CoinStatus.sun &&
        breakValue < _animationValue.value) {
      _currentStatus = CoinStatus.moon;
      _callback();
    }
    //  月→太陽 に変えるタイミング
    else if (_currentStatus == CoinStatus.moon &&
        _animationValue.value < breakValue) {
      _currentStatus = CoinStatus.sun;
      _callback();
    }
  },
);
/// 太陽と月が入れ替わるときにコースバックを実施する
void _callback() {
  if (widget.callback != null) {
    widget.callback!(_currentStatus);
  }
}

さて、アニメーションで値を変えるだけでなく、どうすれば値によって動作を変えられるでしょうか。そのときは、addLisnerでアニメーション実行時に行われるリスナを定義します。アニメーションで値が変わる毎に実施されるので、printで値を表示すると何十行も表示されます。
そこに現在の状態と現在の値を比べて、次の状態になったタイミングですでに話したコールバックを(あれば)呼ぶようにします。

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

  // 初期値が月だった場合、月のアイコンにして、アニメーションを進めておく
  if (widget.initStatus == CoinStatus.moon) {
    _currentStatus = CoinStatus.moon;
    _controller.forward();
  }
}

Stateの初期化です。初期値が「月」だった場合は、状態を「月」にして、アニメーションを再生しておきます。一度再生しておくことで、初期値が「太陽」で一度再生して「月」の状態になった時と同じにします。そうすることで、その後の処理で初期値について考えなくてすみます。

@override
Widget build(BuildContext context) {
  return GestureDetector(
    onTap: () {
      if (_animationValue.value == startValue) {
        _controller.forward();
      } else if (_animationValue.value == endValue) {
        _controller.reverse();
      }
    },

実際に表示される項目を定義しますが、最初にタップ時の処理を記載します。まず、アニメーションの再生中は、値がstartValueでもendValueでもないので、なにもしません。アニメーション中はタップしても反応しません。
そしてアニメーションの値がstartValueであれば「太陽」の状態なので、「月」の状態になるように再生します。アニメーションの値がendValueであれば「月」の状態なので逆再生します。そうすると、アニメーションの値がアニメーション中に更新されます。

child: AnimatedBuilder(
  animation: _controller,
    builder: (context, child) {

実際に表示されるWidgetを定義します。アニメーション中で更新する必要があることを知らせるためにAnimationBuilderを使います。引数のanimationにはコントローラーを使います。

return Transform(
        alignment: FractionalOffset.center,
        transform: Matrix4.identity()
          ..setEntry(3, 2, 0.0015)
          ..rotateY(math.pi * (1.0 - _animationValue.value)),
        child: _currentStatus == CoinStatus.sun ? sunIcon : moonIcon,
      );
    },
  ),

Widgetとしては、太陽か月のアイコンを表示しますが、それを現在の状態でどちらを出すか決めます。そしてTransformを使用して変形する値をアニメーションさせてます。変形式については、参考からパクってきたのでよく分かりません。(すいません)

まとめ

ということで、コインの表裏が入れ替わるかのようなアニメーションの方法を記載しました。基本的なアニメーションも学べたかと思います。

参考

2 Ways to Create Flipping Card Animation in Flutter

全ソース

import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: const MyHomePage(title: 'Flutter Demo Home Page'),
    );
  }
}

class MyHomePage extends StatefulWidget {
  const MyHomePage({Key? key, required this.title}) : super(key: key);

  final String title;

  @override
  State<MyHomePage> createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  int _counter = 0;

  void _incrementCounter() {
    setState(() {
      _counter++;
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(widget.title),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            const Text(
              'You have pushed the button this many times:',
            ),
            Text(
              '$_counter',
              style: Theme.of(context).textTheme.headline4,
            ),
          ],
        ),
      ),
      floatingActionButton: SunAndMoonCoin(
        size: 64,
        callback: (CoinStatus status) {
          if (status == CoinStatus.sun) {
            _incrementCounter();
          }
        },
      ),
    );
  }
}

/// コインの状態
enum CoinStatus {
  /// 太陽
  sun,

  /// 月
  moon;
}

/// 太陽と月がそれぞれ表と裏に書かれているコインをイメージして作成しました。
class SunAndMoonCoin extends StatefulWidget {
  const SunAndMoonCoin({
    super.key,
    this.callback,
    this.duration = const Duration(milliseconds: 100),
    this.initStatus = CoinStatus.sun,
    this.size = 32,
    this.color = Colors.orangeAccent,
  });

  /// 太陽と月が入れ替わるときに実施されるコールバックを設定します
  /// 例:テーマの入れ替え
  final void Function(CoinStatus coinStatus)? callback;

  /// アニメーションの時間
  final Duration duration;

  ///初期状態
  final CoinStatus initStatus;

  /// サイズ
  final double size;

  ///アイコンの色
  final Color color;

  @override
  State<SunAndMoonCoin> createState() => _SunAndMoonCoinState();
}

class _SunAndMoonCoinState extends State<SunAndMoonCoin>
    with SingleTickerProviderStateMixin<SunAndMoonCoin> {
  /// 太陽のアイコン
  late final sunIcon = SvgPicture.asset(
    'assets/images/day_icon.svg',
    color: widget.color,
    height: widget.size,
    width: widget.size,
  );

  /// 月のアイコン
  late final moonIcon = SvgPicture.asset(
    'assets/images/night_icon.svg',
    color: widget.color,
    height: widget.size,
    width: widget.size,
  );

  /// アニメーションの値の初期値
  static const startValue = 0.0;

  /// アニメーションの値の終了値
  static const endValue = 1.0;

  /// コインの表裏の入れ替わり値
  static const breakValue = (startValue + endValue) / 2;

  /// 現在の状態
  CoinStatus _currentStatus = CoinStatus.sun;

  /// アニメーションのコントローラ
  late final AnimationController _controller = AnimationController(
    vsync: this,
    duration: widget.duration,
  );

  // アニメーションで変化する値
  late final Animation<double> _animationValue =
      Tween(begin: startValue, end: endValue).animate(
    CurvedAnimation(
      parent: _controller,
      curve: Curves.easeIn,
    ),
  )..addListener(
          () {
            // 太陽→月に変えるタイミング
            if (_currentStatus == CoinStatus.sun &&
                breakValue < _animationValue.value) {
              _currentStatus = CoinStatus.moon;
              _callback();
            }
            //  月→太陽 に変えるタイミング
            else if (_currentStatus == CoinStatus.moon &&
                _animationValue.value < breakValue) {
              _currentStatus = CoinStatus.sun;
              _callback();
            }
          },
        );

  /// 太陽と月が入れ替わるときにコースバックを実施する
  void _callback() {
    if (widget.callback != null) {
      widget.callback!(_currentStatus);
    }
  }

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

    // 初期値が月だった場合、月のアイコンにして、アニメーションを進めておく
    if (widget.initStatus == CoinStatus.moon) {
      _currentStatus = CoinStatus.moon;
      _controller.forward();
    }
  }

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onTap: () {
        if (_animationValue.value == startValue) {
          _controller.forward();
        } else if (_animationValue.value == endValue) {
          _controller.reverse();
        }
      },
      child: AnimatedBuilder(
        animation: _controller,
        builder: (context, child) {
          return Transform(
            alignment: FractionalOffset.center,
            transform: Matrix4.identity()
              ..setEntry(3, 2, 0.0015)
              ..rotateY(math.pi * (1.0 - _animationValue.value)),
            child: _currentStatus == CoinStatus.sun ? sunIcon : moonIcon,
          );
        },
      ),
    );
  }
}