🐂

【Flutter】Animationの基礎から応用まで ~③Intervalと複数のTickerProvider~

2022/11/12に公開

こちらの記事はアニメーションに関するシリーズ記事のvol3となります。

Intervalと複数のTickerProvider

vol2では2つのユースケースを見ていきましたが、こちらでは残りのユースケースも見ていきましょう

  • 単体のWidgetに複数のアニメーション効果を同時に充てる
  • 単体のWidgetに同一のアニメーションを複数回充てる
  • 単体のWidgetに異なるアニメーションをそれぞれのタイミングで充てる
  • 複数のWidgetを連鎖的にアニメーションさせる
  • 単体 or 複数のWidgetを別々にアニメーションさせる

単体のWidgetに異なるアニメーションをそれぞれのタイミングで充てる -Interval-

vol2の記事はTweenSequenceを使って値を変えながら同一のアニメーションを複数回充ててみました

それでは同一のアニメーション効果ではなく、異なるアニメーションを複数回充てるにはどうしたら良いでしょうか?

この時に使えるのがIntervalです

Intervalクラス

通常、AnimationControllerとTweenでAnimationを生成した場合、AnimationControllerのDurationの時間を丸々使ってTweenで定義した開始値から終了値へ値が変化します

Intervalを使う事でAnimationControllerの進み具合(0から1)の間で指定した時間を切り取り、その時間の中でアニメーションを適用します

使い方

1. 開始タイミング(begin)と終了タイミング(end)を定義

Intervalでは第一引数と第二引数でAnimationControllerの進み具合の開始タイミングと終了タイミングを指定します

AnimationControllerの進み具合は0から1.0のdoubleなので、Intervalにもdouble値で開始タイミングと終了タイミングを指定します

例えばDurationが4秒の場合、0と0.5を渡せば、0秒から2秒までの間、紐づいたアニメーションを適用します

Interval(
    0, // 開始タイミング
    0.5, // 終了タイミング
    curve: Curves.ease,
)

また第三引数にcurveを受け取るので変化に効果を加える事もできます

2. CurvedAnimationに渡す

Intervalクラスを使う際はCurvedAnimationというクラスを使って、AnimationControllerに紐付けます

CurvedAnimationはアニメーションに対して、curveを付与する事が出来るクラスですが、主にAnimationControllerに対してcurveを付与すると言う意図で使います

Intervalを渡す事で、その開始タイミングと終了タイミングの間だけを動くAnimationControllerに変化します

CurvedAnimation(
    parent: controller,
    curve: Interval(
        0,
        0.5,
        curve: Curves.ease,
    ),
)

3. AnimationController.driveメソッドを使って、Animationを生成

このIntervalを付与したAnimationControllerdriveメソッドを使って、Animationを生成します

Animation = CurvedAnimation(
        parent: controller,
        curve: Interval(
            0,
            0.5,
            curve: Curves.ease,
        ),
    ).drive(Tween);

4. WidgetにAnimationを紐付ける

後は他のアニメーション同様にWidgetに紐づけるだけです

Intervalを付与して生成されたAnimationクラスは指定された開始時間と終了時間の間のみ、そのアニメーションをWidgetに適用します

と言うことは?

「単体のWidgetに異なるアニメーションをそれぞれのタイミングで充てる」は、Intervalを使って複数のアニメーションにそれぞれの動くタイミングを指定する事で実現する事が出来ます

class _ChainedAnimationState extends State<ChainedAnimation>
    with SingleTickerProviderStateMixin {
  late AnimationController controller;
  late Tween<Alignment> alignTween;
  late Tween<double> rotateTween;
  late Tween<double> opacityTween;
  late Animation<Alignment> alignAnimation;
  late Animation<double> rotateAnimation;
  late Animation<double> opacityAnimation;
  bool animateCompleted = false;

  
  void initState() {
    controller =
        AnimationController(duration: const Duration(seconds: 4), vsync: this);

    // align、rotation, opacityの3つのアニメーション効果を充てていきます
    alignTween = Tween(begin: Alignment.topCenter, end: Alignment.center);
    rotateTween = Tween(begin: 0, end: pi * 8);
    opacityTween = Tween(begin: 1, end: 0);

    alignAnimation = CurvedAnimation(
      parent: controller,
      // 1. Intervalクラスに開始タイミング、終了タイミングを指定
      curve: const Interval(0, 0.5, curve: Curves.ease),
    ).drive(alignTween);

    // 2. CurvedAnimationを使って、IntervalをAnimationControllerに付与
    rotateAnimation = CurvedAnimation(
      parent: controller,
      curve: const Interval(0.5, 0.7, curve: Curves.ease),
    ).drive(rotateTween);

    // 3. AnimationController x TweenでAnimationクラスを生成
    opacityAnimation = CurvedAnimation(
      parent: controller,
      curve: const Interval(0.7, 1, curve: Curves.ease),
    ).drive(opacityTween);

    super.initState();
  }

  
  void dispose() {
    controller.dispose();
    super.dispose();
  }

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Chained Animation'),
      ),
      drawer: const MainDrawer(),
      body: AnimatedBuilder(
        animation: controller,
        builder: (context, _) {
          return Opacity(
            opacity: opacityAnimation
                .value, // <<< 4. Intervalを付与して生成したAnimationをwidgetに紐づける
            child: Align(
              alignment: alignAnimation.value, // <<< 4.
              child: Transform.rotate(
                angle: rotateAnimation.value, // <<< 4.
                child: const Text('Hello world!'),
              ),
            ),
          );
        },
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () {
          if (!animateCompleted) {
            controller.forward().whenComplete(() {
              setState(() => animateCompleted = true);
            });
            return;
          }
          controller.reverse().whenComplete(
            () {
              setState(() => animateCompleted = false);
            },
          );
        },
        backgroundColor: Colors.yellow[700],
        child: const Icon(
          Icons.bolt,
          color: Colors.black,
        ),
      ),
    );
  }
}

複数のWidgetを連鎖的にアニメーションさせる

下記の様に複数のWidgetをタイミングをずらしながらアニメーションさせる事をStaggered Animationと呼びます

実はこのStaggered AnimationもIntervalを使うと実現できます

どうやって?

結論から言えば、それぞれWidgetが動くタイミングを定義したIntervalを作り、それを使って生成したAnimationクラスをそれぞれのWidgetに紐付けてあげれば良いのです

紐付けている対象Widgetが違うだけで、やっている事は前述したIntervalの使い方と一緒です

class _StaggeredAnimationState extends State<StaggeredAnimation>
    with SingleTickerProviderStateMixin {
  late AnimationController controller;
  late Tween<Offset> offsetTween;
  late Animation<Offset> offsetAnimation1;
  late Animation<Offset> offsetAnimation2;
  late Animation<Offset> offsetAnimation3;
  bool animateCompleted = false;

  
  void initState() {
    controller = AnimationController(
        duration: const Duration(milliseconds: 1500), vsync: this);

    // 3つのWidgetに対して同じ変化の値を付与していくのでTweenを1つ用意
    offsetTween = Tween(begin: const Offset(-1000, 0), end: Offset.zero);

    offsetAnimation1 = CurvedAnimation(
      parent: controller,
      // 1. Intervalクラスに開始タイミング、終了タイミングを指定
      curve: const Interval(0, 0.3, curve: Curves.ease),
    ).drive(offsetTween);

    // 2. CurvedAnimationを使って、IntervalをAnimationControllerに付与
    offsetAnimation2 = CurvedAnimation(
      parent: controller,
      curve: const Interval(0.3, 0.7, curve: Curves.ease),
    ).drive(offsetTween);

    // 3. AnimationController x TweenでAnimationクラスを生成
    offsetAnimation3 = CurvedAnimation(
      parent: controller,
      curve: const Interval(0.7, 1, curve: Curves.ease),
    ).drive(offsetTween);

    super.initState();
  }

  
  void dispose() {
    controller.dispose();
    super.dispose();
  }

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Staggered Animation'),
      ),
      drawer: const MainDrawer(),
      body: AnimatedBuilder(
        animation: controller,
        builder: (context, _) {
          return Container(
            width: double.infinity,
            padding: const EdgeInsets.symmetric(vertical: 200, horizontal: 60),
            child: Column(
              mainAxisAlignment: MainAxisAlignment.spaceAround,
              children: [
                Transform.translate(
                  offset: offsetAnimation1
                      .value, // 4.  Intervalを付与して生成したAnimationをwidgetに紐づける
                  child: const Text('Hello world!'),
                ),
                Transform.translate(
                  offset: offsetAnimation2.value, // 4.
                  child: const Text('My name is ...'),
                ),
                Transform.translate(
                  offset: offsetAnimation3.value, // 4.
                  child: const Text('heyhey1028!!'),
                ),
              ],
            ),
          );
        },
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () {
          if (!animateCompleted) {
            controller.forward().whenComplete(() {
              setState(() => animateCompleted = true);
            });
            return;
          }
          controller.reverse().whenComplete(
            () {
              setState(() => animateCompleted = false);
            },
          );
        },
        backgroundColor: Colors.yellow[700],
        child: const Icon(
          Icons.bolt,
          color: Colors.black,
        ),
      ),
    );
  }
}

Intervalに定義した開始タイミングと終了タイミングを調整する事でどのくらいの間隔で連鎖的に動くかを調整する事が出来ます

単体 or 複数のWidgetを別々にアニメーションさせる

今までの例では1つのAnimationControllerで複数のWidgetもしくはアニメーション効果を操作していました

なのでそのAnimationControllerを再生(forward)する事で、全てのアニメーションが同時に動いていましたが、では複数のアニメーションを別々に動かしたい場合はどうしたら良いでしょうか?

TickerProviderStateMixinを使う

こういった場合にはAnimationControllerを複数用意する必要があります

しかし今まで使ってきたSingleTickerProviderStateMixinでは実は複数のAnimationControllerを生成する事が出来ません

こういったケースでは複数のTickerProviderを供給出来るTickerProviderStateMixinState WidgetにMixinします

Listenable.mergeを使う

またもう1つのポイントとしては、AnimatedBuilderanimationプロパティにListenable.mergeを使って、2つのAnimationControllerを合体させたクラスを渡します

今回の様にAnimationControllerが複数ある場合、AnimatedBuilderがそれら全ての状態を監視し、子Widgetを再描画する必要がある為、Listenable.mergeを使ってAnimationControllerを1つのクラスにします

//  複数のTickerProviderを供給出来るTickerProviderStateMixinをmixin
class _MultipleTickerProviderState extends State<MultipleTickerProvider>
    with TickerProviderStateMixin {
  // 別々にアニメーションさせる為、複数のAnimationControllerを用意
  late AnimationController alignController;
  late AnimationController rotateController;
  late TweenSequence<Alignment> alignTween;
  late Tween<double> rotateTween;
  late Animation<Alignment> alignmAnimation;
  late Animation<double> rotateAnimation;
  bool animatingAlign = false;
  bool animatingRotation = false;

  
  void initState() {
    // AnimationControllerそれぞれにdurationとvsyncを定義
    rotateController = AnimationController(
        duration: const Duration(milliseconds: 1500), vsync: this);

    alignController =
        AnimationController(duration: const Duration(seconds: 3), vsync: this);

    // それぞれのアニメーションのTweenを用意
    rotateTween = Tween(begin: 0, end: 8 * pi);
    alignTween = TweenSequence<Alignment>(
      [
        TweenSequenceItem(
          tween: Tween(
            begin: Alignment.center,
            end: Alignment.topCenter,
          ),
          weight: 0.3,
        ),
        TweenSequenceItem(
          tween: Tween(
            begin: Alignment.topCenter,
            end: Alignment.bottomCenter,
          ),
          weight: 0.4,
        ),
        TweenSequenceItem(
          tween: Tween(
            begin: Alignment.bottomCenter,
            end: Alignment.center,
          ),
          weight: 0.3,
        ),
      ],
    );

    // それぞれ別のAnimationControllerを使ってAnimationを生成
    alignmAnimation = alignController.drive(alignTween);
    rotateAnimation = rotateController.drive(rotateTween);

    super.initState();
  }

  
  void dispose() {
    rotateController.dispose();
    alignController.dispose();
    super.dispose();
  }

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        backgroundColor: Colors.brown[300],
        title: const Text('Multiple Ticker Provider'),
      ),
      floatingActionButtonLocation: FloatingActionButtonLocation.centerDocked,
      drawer: const MainDrawer(),
      body: AnimatedBuilder(
        // 複数のAnimationControllerを監視する為、マージしたクラスへ変換
        animation: Listenable.merge([
          rotateController,
          alignController,
        ]),
        builder: (context, _) {
          return Stack(
            fit: StackFit.expand,
            children: [
              Align(
                alignment: alignmAnimation.value, // アニメーションをWidgetに適用
                child: Transform.rotate(
                  angle: rotateAnimation.value,
                  child: const Text('Hello world!!'),
                ),
              )
            ],
          );
        },
      ),
      // 複数のAnimationControllerを別々に発火する
      floatingActionButton: Padding(
        padding: const EdgeInsets.symmetric(horizontal: 20),
        child: Row(
          mainAxisAlignment: MainAxisAlignment.end,
          children: [
            FloatingActionButton(
              onPressed: () {
                if (!animatingAlign) {
                  alignController.repeat();
                  setState(() => animatingAlign = true);
                  return;
                }
                alignController.stop();
                setState(() => animatingAlign = false);
              },
              heroTag: 'align',
              backgroundColor: Colors.yellow[700],
              child: const Icon(
                Icons.double_arrow,
                color: Colors.black,
              ),
            ),
            const SizedBox(width: 20),
            FloatingActionButton(
              onPressed: () {
                if (!animatingRotation) {
                  rotateController.repeat();
                  setState(() => animatingRotation = true);
                  return;
                }
                rotateController.stop();
                setState(() => animatingRotation = false);
              },
              heroTag: 'rotate',
              backgroundColor: Colors.yellow[700],
              child: const Icon(
                Icons.cyclone,
                color: Colors.black,
              ),
            ),
          ],
        ),
      ),
    );
  }
}

サンプルコード

https://github.com/heyhey1028/flutter_samples/tree/main/samples/master_animation

以上

以上5つの複雑なアニメーションのユースケースを見ていきました。これらを基本として掛け合わせる事で複雑なアニメーションの多くは実装出来るようになると思います。

ここまで見る限り、知らないクラスはあるかもしれませんが、意外とシンプルに感じるかと思います

しかし実際に実装し始めると非常に複雑だと感じる事が多いのもアニメーションです

次ではそんなアニメーションの分かりづらい所について整理していきます

https://zenn.dev/heyhey1028/articles/10492c3884a45a

参考

Flutter大学

Discussion