前提
Flutterが気になっていて学習していますが、なかなかアニメーションについて理解できない、できてもすぐ忘れてしまいます。そのため備忘録として記載します。
今回学習したのはFlutter公式サイトにあるAnimationのチュートリアルです。
コードサンプルはこちらです。
環境
- macOS: 12.6
- Android Studio Dolphin: 2021.3.1 Patch 1
- flutter: 3.6.0-0.1.pre
- Dart: 2.19.0
各クラスの概要
Animation
Animationオブジェクトは画面上になにが描画されているかは把握しません。抽象クラスで、現在の値とその状態を保持しています。
Animtaionオブジェクトは2つの値にある数値を一定の期間に渡って生成します。生成の仕方は線形、曲線、ステップ関数など自由に選択できます。制御の仕方によっては逆回転させたり、方向転換させたりできます。
Animation<double>
が一般的な使用方法ですが、Animation<Color>
やAnimation<Size>
のように、数値以外の型の間も補完することができます。
CurvedAnimation
CurvedAnimationオブジェクトはAnimationを継承するクラスです。
Animationオブジェクトが一定の間隔で数値を生成し続けるのに対し、CurvedAnimationは指定した非線形のカーブを描くように生成します。
animation = CurvedAnimation(parent: controller, curve: Curves.easeIn);
以下のサイトに各カーブがどのような曲線を描きながら数値が遷移するか説明されています。
視覚的に見ることができるのでおすすめです。
自分で曲線の動きを作成することもできます。
Curveクラスを継承したクラスを作成し、transformメソッドに動きを記載します。このインスタンスをCurvedAnimationを生成するときに引数に渡します。
class ShakeCurve extends Curve {
const ShakeCurve();
@override
double transform(double t) => sin(t * pi * 2);
}
AnimationController
AnimationControllerはAnimationを継承したクラスです。
ハードウェアが新しいフレームの準備が整ったときに新しい値を生成します。デフォルトでは0.0~1.0までの数値を線形に生成します。
以下のコードは、(私の理解では)2秒間かけて0.0~1.0までの値を生成するAnimationControllerを生成する方法です。
controller =
AnimationController(duration: const Duration(seconds: 2), vsync: this);
AnimationControllerはAnimationを継承したクラスなので、Animationオブジェクトが必要な場所ではどこでも使用することができます。
AnimationControllerは動きを制御するためのメソッドが存在します。例えば.forward()
メソッドはアニメーションの動きを開始します。
AnimationControllerを作成する際にvsyncという引数を渡します。これのおかげで不必要なリソースを消費することを防ぎます。渡すクラスの定義にSingleTickerProviderStateMixin
を追加することでステートフルオブジェクトをTickerProviderとして使用することができます。
正直この部分に関してチュートリアルでは理解ができなかったので以下のサイトを参照しました。
ステートフルオブジェクトにwith SingleTickerProviderStateMixin
をつけることでそのクラス自体がTickerProvider
となります。Ticker
はフレーム更新の管理を担ってくれるクラスです。
AnimationControllerが一つの場合は単一のTickerを提供するSingleTickerProviderStateMixin
、複数の場合はTickerProviderStateMixin
を使用します。
Tween
AnimationControllerが生成する数値のデフォルトは0.0~1.0となっていますが、それを変更したい場合にTweenクラスを使用します。
以下の例では-200.0~0.0まで数値が変化します。
tween = Tween<double>(begin: -200, end: 0);
Animationと同様、数値以外にもColorやSizeを渡すことができます。
Tweenオブジェクトはステートレスなので状態を保存しません。その代わり、evaluate(Animation<double> animation)
メソッドを提供し、アニメーションの現在の値を受け取ることができます。
TweenオブジェクトからAnimationを生成するには以下のようにします。
AnimationController controller = AnimationController(
duration: const Duration(milliseconds: 500), vsync: this);
Animation<int> alpha = IntTween(begin: 0, end: 255).animate(controller);
CurvedAnimationを利用したい場合には以下のようにします。
AnimationController controller = AnimationController(
duration: const Duration(milliseconds: 500), vsync: this);
final Animation<double> curve =
CurvedAnimation(parent: controller, curve: Curves.easeOut);
Animation<int> alpha = IntTween(begin: 0, end: 255).animate(curve);
アニメーションの通知
AnimationオブジェクトはaddListener()
メソッドとaddStatusListener()
メソッドで定義されたListenerとStatusListenerを持つことができます。
Listenerは値が変化するたびに呼び出されます。値が変化するたびに画面表示を変えたい場合、setState()を使用します。
StatusListenerはアニメーションの状態が開始・終了・進行・戻るなど、変化した場合に呼び出されます。
アニメーションのサンプル
以上の概要を踏まえてアニメーションのサンプルを作成します。
まずはただのロゴ表示
import 'package:flutter/material.dart';
void main() => runApp(const LogoApp());
class LogoApp extends StatefulWidget {
const LogoApp({super.key});
@override
State<LogoApp> createState() => _LogoAppState();
}
class _LogoAppState extends State<LogoApp> {
@override
Widget build(BuildContext context) {
return Center(
child: Container(
margin: const EdgeInsets.symmetric(vertical: 10),
height: 300,
width: 300,
child: const FlutterLogo(),
),
);
}
}
何も動かないFlutterロゴが表示されます。
AnimationControllerを使用する
import 'package:flutter/material.dart';
void main() => runApp(const LogoApp());
class LogoApp extends StatefulWidget {
const LogoApp({super.key});
@override
State<LogoApp> createState() => _LogoAppState();
}
class _LogoAppState extends State<LogoApp> with SingleTickerProviderStateMixin {
late Animation<double> animation;
late AnimationController controller;
@override
void initState() {
super.initState();
controller =
AnimationController(duration: const Duration(seconds: 2), vsync: this);
animation = Tween<double>(begin: 0, end: 300).animate(controller)
..addListener(() {
setState(() {
// The state that has changed here is the animation object’s value.
});
});
controller.forward();
}
@override
Widget build(BuildContext context) {
return Center(
child: Container(
margin: const EdgeInsets.symmetric(vertical: 10),
height: animation.value,
width: animation.value,
child: const FlutterLogo(),
),
);
}
@override
void dispose() {
controller.dispose();
super.dispose();
}
}
変更点は以下の通りです。
- Stateクラスに
with SingleTickerProviderStateMixin
をつける。 - Stateクラスに
Animation<double>
とAnimationController
を保持する。 - Stateクラスに
initState()
メソッドを実装する。ステートフルウィジェットクラスが生成されたときに最初に動作する。 -
initState()
メソッドでは- AnimationControllerのインスタンス生成する。
- Tweenと作成したAnimationControllerからanimationを生成する。
- animationの
addListner()
メソッドにsetState()
メソッドを渡す->値の変更があるたびに画面に反映される。 -
controller.forward()
メソッドでアニメーションを開始する。
-
dispose()
メソッドを記載する。アニメーションは大量のリソースを消費するので、ウィジェットが終了したときにメモリリークが起きないように処理する。
AnimationControllerクラスはAnimationを継承しているのでAnimationCotrollerだけで動くのでは?と思いましたがダメでした。
AnimatedWidgetを使用して簡略化する
ここで概要には登場しなかったAnimatedWidgetが急に出現します。
AnimatedWidgetは実際に表示するWidgetをStateクラスから分離することができます。わざわざaddListner()
メソッドを使用しないでも、AnimatedWidgetにlistenしたいanimationを渡せば自動的に反映してくれます。
import 'package:flutter/material.dart';
void main() => runApp(const LogoApp());
class AnimatedLogo extends AnimatedWidget {
const AnimatedLogo({super.key, required Animation<double> animation})
: super(listenable: animation);
@override
Widget build(BuildContext context) {
final animation = listenable as Animation<double>;
return Center(
child: Container(
margin: const EdgeInsets.symmetric(vertical: 10),
height: animation.value,
width: animation.value,
child: const FlutterLogo(),
),
);
}
}
class LogoApp extends StatefulWidget {
const LogoApp({super.key});
@override
State<LogoApp> createState() => _LogoAppState();
}
class _LogoAppState extends State<LogoApp> with SingleTickerProviderStateMixin {
late Animation<double> animation;
late AnimationController controller;
@override
void initState() {
super.initState();
controller =
AnimationController(duration: const Duration(seconds: 2), vsync: this);
animation = Tween<double>(begin: 0, end: 300).animate(controller);
controller.forward();
}
@override
Widget build(BuildContext context) => AnimatedLogo(animation: animation);
@override
void dispose() {
controller.dispose();
super.dispose();
}
}
変更点は以下の通りです。
- AnimatedWidgetを継承したAnimatedLogoクラスを作成する。buildメソッドを実装し、その中にアニメーションとして表示したいオブジェクトを記載します。
-
addListner()
メソッドを使用しなくて良くなったので、StateクラスのinitState()
メソッドからaddListener()
に関する処理を削除する。 - StateクラスのbuildメソッドではAnimatedLogoクラスを生成する。引数としてanimationを渡す。
animate1と実行するときの動きの違いはありません。
アニメーションの状態変化をモニタリングする
addStatusListener()
メソッドを使用するとアニメーションの状態変化を受け取ることができると学習しました。
アニメーションの値の遷移が終了するときAnimationStatus.completed
にアニメーションを逆再生させ、アニメーションの値が初期値に戻った時に再度スタートさせる処理を書きます。
import 'package:flutter/material.dart';
void main() => runApp(const LogoApp());
class AnimatedLogo extends AnimatedWidget {
const AnimatedLogo({super.key, required Animation<double> animation})
: super(listenable: animation);
@override
Widget build(BuildContext context) {
final animation = listenable as Animation<double>;
return Center(
child: Container(
margin: const EdgeInsets.symmetric(vertical: 10),
height: animation.value,
width: animation.value,
child: const FlutterLogo(),
),
);
}
}
class LogoApp extends StatefulWidget {
const LogoApp({super.key});
@override
State<LogoApp> createState() => _LogoAppState();
}
class _LogoAppState extends State<LogoApp> with SingleTickerProviderStateMixin {
late Animation<double> animation;
late AnimationController controller;
@override
void initState() {
super.initState();
controller =
AnimationController(duration: const Duration(seconds: 2), vsync: this);
animation = Tween<double>(begin: 0, end: 300).animate(controller)
..addStatusListener((status) {
if (status == AnimationStatus.completed) {
controller.reverse();
} else if (status == AnimationStatus.dismissed) {
controller.forward();
}
})
..addStatusListener((status) => print('$status'));
controller.forward();
}
@override
Widget build(BuildContext context) => AnimatedLogo(animation: animation);
@override
void dispose() {
controller.dispose();
super.dispose();
}
}
変更点は以下の通りです。
- Stateクラスのanimationの
addStatusListener()
メソッドを記載する。listnerとしてstatusがAnimationStatus.completed:アニメーションの終了
になったらanimationの値の動きを逆転させ、さらにstatusがAnimationStatus.dismissed:アニメーションの開始前
になったらアニメーションの値を進行するという処理を渡します。
AnimatedBuilderを使用してリファクタリングする
animate3では、もしStateクラスのAnimationを変更した場合、AnimatedLogoの方もコードを修正しないといけなくなるという問題があります。
それを解決するため、責任を3つに分離する必要があります。
- ロゴをレンダリングする
- Animationを定義する
- 値の遷移をレンダリングする
AnimatedBuilderを使用するとこれを実現できます。
import 'package:flutter/material.dart';
void main() => runApp(const LogoApp());
class LogoWidget extends StatelessWidget {
const LogoWidget({super.key});
// Leave out the height and width so it fills the animating parent
@override
Widget build(BuildContext context) {
return Container(
margin: const EdgeInsets.symmetric(vertical: 10),
child: const FlutterLogo(),
);
}
}
class GrowTransition extends StatelessWidget {
const GrowTransition(
{required this.child, required this.animation, super.key});
final Widget child;
final Animation<double> animation;
@override
Widget build(BuildContext context) {
return Center(
child: AnimatedBuilder(
animation: animation,
builder: (context, child) {
return SizedBox(
height: animation.value,
width: animation.value,
child: child,
);
},
child: child,
),
);
}
}
class LogoApp extends StatefulWidget {
const LogoApp({super.key});
@override
State<LogoApp> createState() => _LogoAppState();
}
class _LogoAppState extends State<LogoApp> with SingleTickerProviderStateMixin {
late Animation<double> animation;
late AnimationController controller;
@override
void initState() {
super.initState();
controller =
AnimationController(duration: const Duration(seconds: 2), vsync: this);
animation = Tween<double>(begin: 0, end: 300).animate(controller)
..addStatusListener((status) {
if (status == AnimationStatus.completed) {
controller.reverse();
} else if (status == AnimationStatus.dismissed) {
controller.forward();
}
})
..addStatusListener((status) => print('$status'));
controller.forward();
}
@override
Widget build(BuildContext context) {
return GrowTransition(
animation: animation,
child: const LogoWidget(),
);
}
@override
void dispose() {
controller.dispose();
super.dispose();
}
}
変更点は以下の通りです。
- LogoWidgetクラスを定義します。アニメーションとして表示したいウィジェットのみを返します。
- GrowTransitionクラスを定義します。このクラスが生成されるときにコンストラクタで表示するウィジェットとanimationを受け取ります。
builder()
メソッド内でAnimatedBuilderを使用してアニメーションを生成します。 - Stateクラスの
builder()
メソッド内ではGrowTransitionクラスにanimationとロゴを渡して生成します。
実行結果はanimate3と同じです。
同時進行のアニメーション
透明度とサイズのように、一度に複数のアニメーション(Tween)を生成したい場合があります。
controller =
AnimationController(duration: const Duration(seconds: 2), vsync: this);
sizeAnimation = Tween<double>(begin: 0, end: 300).animate(controller);
opacityAnimation = Tween<double>(begin: 0.1, end: 1).animate(controller);
しかしAnimatedWidgetは1つのアニメーションしか受け取れません。そのため、上記のように2つのアニメーションをStateクラスで生成しても行き場がありません。
そのためアニメーションはTweenから生成していないものをAnimatedWidgetの引数に渡し、渡した先でそれぞれTweenを生成するようにします。
このとき、animation.value
ではTweenから生成していない値を受け取ることになるため、Tweenのevaluateメソッドを使用します。_sizeTween.evaluate(animation)
のようにしてanimationの現在の値をTweenを通して受け取ります。
import 'package:flutter/material.dart';
void main() => runApp(const LogoApp());
class AnimatedLogo extends AnimatedWidget {
const AnimatedLogo({super.key, required Animation<double> animation})
: super(listenable: animation);
// Make the Tweens static because they don't change.
static final _opacityTween = Tween<double>(begin: 0.1, end: 1);
static final _sizeTween = Tween<double>(begin: 0, end: 300);
@override
Widget build(BuildContext context) {
final animation = listenable as Animation<double>;
return Center(
child: Opacity(
opacity: _opacityTween.evaluate(animation),
child: Container(
margin: const EdgeInsets.symmetric(vertical: 10),
height: _sizeTween.evaluate(animation),
width: _sizeTween.evaluate(animation),
child: const FlutterLogo(),
),
),
);
}
}
class LogoApp extends StatefulWidget {
const LogoApp({super.key});
@override
State<LogoApp> createState() => _LogoAppState();
}
class _LogoAppState extends State<LogoApp> with SingleTickerProviderStateMixin {
late Animation<double> animation;
late AnimationController controller;
@override
void initState() {
super.initState();
controller =
AnimationController(duration: const Duration(seconds: 2), vsync: this);
animation = CurvedAnimation(parent: controller, curve: Curves.easeIn)
..addStatusListener((status) {
if (status == AnimationStatus.completed) {
controller.reverse();
} else if (status == AnimationStatus.dismissed) {
controller.forward();
}
});
controller.forward();
}
@override
Widget build(BuildContext context) => AnimatedLogo(animation: animation);
@override
void dispose() {
controller.dispose();
super.dispose();
}
}
実行すると以下の通りとなります。animate3やanimate4とは異なり、サイズだけでなく透明度も変化しています。
さいごに
Flutterのアニメーションについて学習してきました。画面表示側の処理には詳しくありませんが、アプリを起動した際のロゴ表示や、スクロールした際の表示などに応用できそうです。