「Flutter Advent Calendar 2022」に参加させて頂きます!17日目です。
はじめに
ナイスなテーマの切り替えボタンが欲しい!ということで、自分で作ってみました。こんなのです。太陽になったときだけカウントします。
対象者
- 表と裏のコインやカードを反転させるようなアニメーションがしたい
- アニメーションが知りたい
- 子Widgetのイベントに応じて、親Widgetのメソッドを実施したい
- Flutter Advent Calendar 2022のFlutterカレンダーを読破したい
ちなみに、単に2つのWidgetを裏表で回転させたいだけなら「flip_card」のパッケージを使うのが早いです。そちらで不十分だったときがあり、そのときにこの方法で実施しました
インストール
画像はなんでも良いのですが、このプロジェクトではSVGファイルを使用しています。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
/// 太陽のアイコン
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,
);
},
),
);
}
}