🐬

【Flutter】図解とサンプルでわかる連続アニメーション ⭐️💨 TweenSequence

2021/09/15に公開

扉絵

Tweenにシークエンス(シーンの連続体)の概念を導入したTweenSequenceの解説です。TweenSequenceを活用してアニメーションを自在にコントロールしましょう!

TweenSuquenceによるアニメーション例
TweenSuquenceによるアニメーション例

⬇️ サンプルコード(実行可)
https://dartpad.dev/?null_safety=true&id=30b0db5779e216208fa3f73cfff83e98

Tweenとは

begin~end間の「補間」を表すオブジェクトです。

final tween = Tween<double>(begin: 0.0, end: 100.0);

in-between(間の、間で)を略して Tween という名称が付いています(英語圏のアニメーション業界では中割りのことをinbetweeningとかtweeningなどと言うそうです)。

beginとendプロパティが「点」なら、Tweenは「線」、と考えるとわかりやすいと思います。
Tweenにはさまざまな種類があります。

(例)

  • Tween<double>
  • Tween<Offset>
  • Tween<Color>

このように lerp(線形補間)メソッドが存在するクラスか、数字系クラスであればTweenを設定することが可能です。lerpメソッドはそのオブジェクトの何を、どのように補間するのかを定義しています。

たとえば Tween<double>(begin: 0.0, end: 100.0) をAnimationControllerとともに使用してアニメーションさせた場合は、Curveの要素を加えない限り、次のような値の変遷をたどります。

Tweenの値の変遷
Tweenの値の変遷

Tweenを使ってWidgetを動かす

Tweenの値に沿ってWidgetを動かすには Tweenを起点にする方法とAnimationControllerを起点にする方法があります。

※ あくまでもイメージです
final tween = Tween<double>(begin: 0.0, end: 100.0);
final controller = AnimationController();

// ①これか
tween.animate(controller);
// ②これ
controller.drive(tween);

①②ともに戻り値は Animation<T> です。このことからもわかるように、

Tween × AnimationController = Animation

ということが大雑把に言えると思います。

しかしこのTweenでは点Aから点Bへの単ベクトルなアニメーションしか実現できません。

たとえば、Widgetをジグザグに動かしたい場合(点A → 点B → 点C → 点D など)はどうしたらいいでしょうか。アニメーションの途中でWidgetを数秒だけ止めたい場合はどうしたらいいでしょうか。

そこで登場するのが TweenSequence です。

TweenSequenceとは

TweenSequence は複数のTweenを数珠つなぎにしたものを表すオブジェクトです。

Tweenと同じくAnimatableクラスを継承しているので、Tweenと同じように扱えます(ただしTweenを継承しているわけではないので厳密にTweenを要求されるTweenAnimationBuilderなどでは使えない)。

TweenSequenceは List<TweenSequenceItem> をプロパティに持ちます。

TweenSequenceItem は Tween と weight(double)をプロパティに持つ、TweenSequenceを構成するオブジェクトです。(TweenSequenceが映画でいうシークエンスだとしたら、TweenSequenceItemはシークエンスを構成するシーン一つ一つ)

インスタンスの例
final tween = TweenSequence<double>([
      TweenSequenceItem(
        tween: Tween(begin: 0.0, end: 25.0),
        weight: 1,
      ),
      TweenSequenceItem(
        tween: Tween(begin: 25.0, end: 100.0),
        weight: 1,
      ),
]);

weightはExpandedなどで使われるflexプロパティと同じく「比率」を表す数字です。何に対する比率かというと、アニメーション全体のDuration(継続時間)に対する比率です。

上記コード例で言えば、Durationを10秒に設定したアニメーションだとして、そのうち半分の5秒で一つ目のTweenが0から25に変化し、二つ目が25から100に変化するということになります。

TweenSequenceの例
TweenSequenceの例

以上の前提知識をもって冒頭のサンプルを解説したいと思います。

どのようにFlutterロゴを動かすか

サンプルではロゴの「位置」の変化をアニメーションしています。つまり今回使うべきTweenはWidgetの位置を表現できるTweenです。

位置情報に関するクラスといえば、AlignmentやOffsetが考えられますね。なので Tween<Offset> や Tween<Alignment> を扱えるアニメーション系のWidgetの使用を検討してみます。

AnimatedBuilderAnimatedWidgetを使用してスクラッチからアニメーションWidgetを作ってもいいのですが、この辺をうまくハンドルしてくれそうな便利なWidgetが ~Transition系 の中にあるのでそれを使います。

【候補】

①AlignTransition
            AlignTransition(
	    // ↓ Animation<AlignmentGeometry>をセット
              alignment: tween.animate(controller),
              child: FlutterLogo(),
            ),
②SlideTransition
            SlideTransition(
	    // ↓ Animation<Offset>をセット
              position: tween.animate(controller),
              child: FlutterLogo(),
            ),

さて、①AlignTransition②SlideTransitionのどちらを使うかですが、

②SlideTransitionのOffsetは、childのサイズとデフォルトの位置を基準にしてどれくらい離れているかという文脈でOffsetが使われており、冒頭の「画面の端で止まって方向を変えるアニメーション」には不向きだと分かります(画面の端であることを検知する仕組みを別途作らないといけない)。

一方①AlignTransitionはAlignmentが元々任意の四角の中央や端を簡便に扱うためのクラスなので、今回の簡易的なアニメーションを実現するのには使い勝手が良さそうです。

TweenSequenceを組み立てる

それではTweenSequenceを組み立てて、アニメーションの設計図を書いてみたいと思います。

final tween = TweenSequence<Alignment>([
      TweenSequenceItem( // ①
        tween: Tween(begin: const Alignment(0.0, 3.0), end: Alignment.center)
            .chain(CurveTween(curve: Curves.fastLinearToSlowEaseIn)),
        weight: 5,
      ),
-     TweenSequenceItem( // ②
-       tween: ConstantTween(Alignment.center),
-       weight: 4,
-     ),
      TweenSequenceItem( // ③
        tween: Tween(begin: Alignment.center, end: const Alignment(-1.0, 0.0))
            .chain(CurveTween(curve: curve)),
        weight: 1,
      ),
-     TweenSequenceItem( // ④
-       tween: ConstantTween(const Alignment(-1.0, 0.0)),
-       weight: 1,
-     ),
      TweenSequenceItem( // ⑤
        tween: Tween(
                begin: const Alignment(-1.0, 0.0),
                end: const Alignment(-1.0, -1.0))
            .chain(CurveTween(curve: curve)),
        weight: 4,
      ),
-     TweenSequenceItem( // ⑥
-       tween: ConstantTween(const Alignment(-1.0, -1.0)),
-       weight: 1,
-     ),
      TweenSequenceItem( // ⑦
        tween: Tween(
                begin: const Alignment(-1.0, -1.0),
                end: const Alignment(1.0, -1.0))
            .chain(CurveTween(curve: curve)),
        weight: 2,
      ),
    ]);

※「-」の行は本来のdiffの使い方と異なります。図解の赤丸に色を合わせてます。

上記インスタンスを図にすると以下のようになります。

シークエンスの図解
シークエンスの図解

ちなみにConstantTweenは値が変化しないTweenを表すオブジェクトで、Tweenクラスを継承しています。beginとendの値も、その間の値もずっと一定という意味でconstantですね(つまりアニメーションしない)。

アニメーションを一時的に止めたいときに使用します。冒頭のアニメーションでも上記コードの赤色の行で設定されたweight分、静止しているかと思います。

赤色の行以外で登場するchainメソッドはTweenに新たなTween/Animatableの要素を加えるもので、このコードではCurveTweenの要素を加えてTween線に変化を加えています。(Curveはbeginとendの値が変わらないことが原則なので、変わるのはその間の値)

Tween(begin: Alignment.center, end: const Alignment(-1.0, 0.0))
           .chain(CurveTween(curve: curve))

イメージを図解するとこのような感じです。それぞれの線分がTweenSequenceItemを表していると考えてください。

Curve要素を加える
Curve要素を加える / ※あくまでもイメージです😅

AnimationControllerをセットする

上記で設定したTween情報を使ったアニメーションの「エンジン」となるAnimationControllerを設定します。アニメーションのDurationはここで設定します。またアプリが立ち上がった時にアニメーションが開始するように controller.forward() をinitState内で実行しています。

class _MyHomePageState extends State<MyHomePage>
    with SingleTickerProviderStateMixin {
  late final AnimationController controller;

  
  void initState() {
    super.initState();
    controller = AnimationController(
      vsync: this,
      duration: const Duration(milliseconds: 2100),
    )
      ..forward();
  }

  
  void dispose() {
    controller.dispose();
    super.dispose();
  } // 以下省略

UIを準備する

enum LogoStatus { reversible, animating, forwadable }
enum Surface { icon, text }
// 中略
class _MyHomePageState extends State<MyHomePage>
    with SingleTickerProviderStateMixin {
    LogoStatus logoStatus = LogoStatus.animating;
// 中略
    return Scaffold(
      body: Center(
        child: Stack(
          children: [
            AlignTransition(
              alignment: tween.animate(controller),
              child: FlutterLogo(
                size: MediaQuery.of(context).size.width / 3,
                style: FlutterLogoStyle.stacked,
              ),
            ),
            Positioned(
              bottom: 120,
              left: 30,
              right: 30,
              child: ElevatedButton.icon(
+               onPressed:
+                   logoStatus == LogoStatus.animating ? null : onPressed,
                style: ElevatedButton.styleFrom(
                  primary: Colors.black54,
                  elevation: 0,
                ),
+               icon: getSurface(Surface.icon),
+               label: getSurface(Surface.text),
              ),
            )
          ],
        ),
      ),
    );
  }

  Widget getSurface(Surface surface) {
    if (logoStatus == LogoStatus.reversible) {
      return surface == Surface.icon
          ? const Icon(Icons.arrow_downward)
          : const Text('Reverse');
    } else if (logoStatus == LogoStatus.forwadable) {
      return surface == Surface.icon
          ? const Icon(Icons.arrow_upward)
          : const Text('Forward');
    } else {
      return surface == Surface.icon
          ? const Icon(Icons.stop)
          : const Text('Please wait');
    }
  }

FlutterLogoとアニメーションを起動するボタンのStackです。

まずアニメーションの肝であるAlignTransitionにAnimation<AlignmentGeometry>を渡してやります。

              alignment: tween.animate(controller),

これでアニメーションの仕組みの部分は完成です。そのchildにFlutterLogoを指定します。画面に合わせてロゴのサイズを変更するため、MediaQueryのsizeプロパティを利用。

またアニメーションの状態に合わせてボタン表面のアイコンとテキスト内容を変更する(緑色の行)ため、enum LogoStatusとデータ取得用のメソッドgetSurfaceを作成します。

UIをアニメーションの状態に合わせて更新する

状態の変化に合わせてUIに更新をかけるため、AnimationControllerにAnimationStatusListenerを追加します。

  
  void initState() {
    super.initState();
    controller = AnimationController(
      vsync: this,
      duration: const Duration(milliseconds: 2100),
    )
      ..addStatusListener(statusListener)
      ..forward();
  }

  void statusListener(AnimationStatus status) {
    if (status == AnimationStatus.completed) {
      setState(() => logoStatus = LogoStatus.reversible);
      curve = curve.flipped;
    } else if (status == AnimationStatus.dismissed) {
      setState(() => logoStatus = LogoStatus.forwadable);
      curve = curve.flipped;
    } else {
      setState(() => logoStatus = LogoStatus.animating);
    }
  }****

AnimationStatusListenerがlistenしているAnimationStatus enum には4つの状態があります。

  • completed … アニメーションが完了
  • dismissed … アニメーション開始前
  • forward … アニメーション再生中
  • reverse … アニメーション逆再生中

本サンプルではcompletedの状態のときにLogoStatus enumをreversible(逆再生の準備ができた状態)、dismissedの状態のときにLogoStatus enumをforwadable(再生の準備ができた状態)、それ以外はanimating(再生中なのでボタン操作を受け付けない)とします。

reversibleな状態
reversibleな状態
forwadableな状態
forwadableな状態
animatingな状態
animatingな状態


また順再生と逆再生でTweenSequenceに設定したCurveを反転させるため、状態の変化に合わせてCurve.flippedしています。

      curve = curve.flipped;

一部のCurveではそのまま逆再生すると違和感があるためです。反転させることで自然に見えます。

flippedしない場合
flippedしない場合

flippedした場合
flippedした場合

最後に

アニメーションはアプリのユーザー体験を向上し、他と差別化する上でも重要なファクターだと思います。Flutterは簡単にアニメーションを扱えるのでマスターしない手はないですね!

アニメーション関連の過去記事
https://zenn.dev/inari_sushio/articles/620b436122cd03
https://zenn.dev/inari_sushio/articles/9874164f04f89b
https://zenn.dev/inari_sushio/articles/4e228c29d792ab

Discussion