home

Flutterのアニメーションのチュートリアル

2023-03-11T15:15:52+09:00

前提

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は指定した非線形のカーブを描くように生成します。

CurvedAnimationを生成する一例
animation = CurvedAnimation(parent: controller, curve: Curves.easeIn);

以下のサイトに各カーブがどのような曲線を描きながら数値が遷移するか説明されています。
視覚的に見ることができるのでおすすめです。

自分で曲線の動きを作成することもできます。
Curveクラスを継承したクラスを作成し、transformメソッドに動きを記載します。このインスタンスをCurvedAnimationを生成するときに引数に渡します。

ShakeCurve.dart
class ShakeCurve extends Curve {                
                                                
  const ShakeCurve();                           
                                                
  @override                                     
  double transform(double t) => sin(t * pi * 2);
}                                               

Animation­Controller

Animation­ControllerはAnimationを継承したクラスです。
ハードウェアが新しいフレームの準備が整ったときに新しい値を生成します。デフォルトでは0.0~1.0までの数値を線形に生成します。

以下のコードは、(私の理解では)2秒間かけて0.0~1.0までの値を生成するAnimation­Controllerを生成する方法です。

AnimationControllerを生成する一例
controller =
    AnimationController(duration: const Duration(seconds: 2), vsync: this);

Animation­ControllerはAnimationを継承したクラスなので、Animationオブジェクトが必要な場所ではどこでも使用することができます。
AnimationControllerは動きを制御するためのメソッドが存在します。例えば.forward()メソッドはアニメーションの動きを開始します。

Animation­Controllerを作成する際にvsyncという引数を渡します。これのおかげで不必要なリソースを消費することを防ぎます。渡すクラスの定義にSingleTickerProviderStateMixinを追加することでステートフルオブジェクトをTickerProviderとして使用することができます。

正直この部分に関してチュートリアルでは理解ができなかったので以下のサイトを参照しました。

ステートフルオブジェクトにwith SingleTickerProviderStateMixinをつけることでそのクラス自体がTickerProviderとなります。Tickerはフレーム更新の管理を担ってくれるクラスです。
AnimationControllerが一つの場合は単一のTickerを提供するSingleTickerProviderStateMixin、複数の場合はTickerProviderStateMixinを使用します。

Tween

AnimationControllerが生成する数値のデフォルトは0.0~1.0となっていますが、それを変更したい場合にTweenクラスを使用します。
以下の例では-200.0~0.0まで数値が変化します。

Tweenを生成する一例
tween = Tween<double>(begin: -200, end: 0);

Animationと同様、数値以外にもColorやSizeを渡すことができます。

Tweenオブジェクトはステートレスなので状態を保存しません。その代わり、evaluate(Animation<double> animation)メソッドを提供し、アニメーションの現在の値を受け取ることができます。

TweenオブジェクトからAnimationを生成するには以下のようにします。

TweenオブジェクトからAnimationを生成する一例
AnimationController controller = AnimationController(
    duration: const Duration(milliseconds: 500), vsync: this);
Animation<int> alpha = IntTween(begin: 0, end: 255).animate(controller);

CurvedAnimationを利用したい場合には以下のようにします。

TweenオブジェクトからAnimationを生成する一例(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はアニメーションの状態が開始・終了・進行・戻るなど、変化した場合に呼び出されます。

アニメーションのサンプル

以上の概要を踏まえてアニメーションのサンプルを作成します。

まずはただのロゴ表示

animate0
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を使用する

animate1
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だけで動くのでは?と思いましたがダメでした。

実行すると以下のようになります。
animate1.gif

Animated­Widgetを使用して簡略化する

ここで概要には登場しなかったAnimated­Widgetが急に出現します。
Animated­Widgetは実際に表示するWidgetをStateクラスから分離することができます。わざわざaddListner()メソッドを使用しないでも、Animated­Widgetにlistenしたいanimationを渡せば自動的に反映してくれます。

animate2
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にアニメーションを逆再生させ、アニメーションの値が初期値に戻った時に再度スタートさせる処理を書きます。

animate3
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:アニメーションの開始前になったらアニメーションの値を進行するという処理を渡します。

実行するとこのようになります。
animate3.gif

AnimatedBuilderを使用してリファクタリングする

animate3では、もしStateクラスのAnimationを変更した場合、AnimatedLogoの方もコードを修正しないといけなくなるという問題があります。
それを解決するため、責任を3つに分離する必要があります。

  • ロゴをレンダリングする
  • Animationを定義する
  • 値の遷移をレンダリングする

AnimatedBuilderを使用するとこれを実現できます。

animate4
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を通して受け取ります。

animate5
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とは異なり、サイズだけでなく透明度も変化しています。
animate5.gif

さいごに

Flutterのアニメーションについて学習してきました。画面表示側の処理には詳しくありませんが、アプリを起動した際のロゴ表示や、スクロールした際の表示などに応用できそうです。

自己紹介

サムネイル

y5347M

バックエンドエンジニアをしています。