【Flutter】Animationの基礎から応用まで ~③Intervalと複数のTickerProvider~
こちらの記事はアニメーションに関するシリーズ記事の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を付与したAnimationControllerでdriveメソッドを使って、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を供給出来るTickerProviderStateMixinをState WidgetにMixinします
Listenable.mergeを使う
またもう1つのポイントとしては、AnimatedBuilderのanimationプロパティに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,
              ),
            ),
          ],
        ),
      ),
    );
  }
}
サンプルコード
以上
以上5つの複雑なアニメーションのユースケースを見ていきました。これらを基本として掛け合わせる事で複雑なアニメーションの多くは実装出来るようになると思います。
ここまで見る限り、知らないクラスはあるかもしれませんが、意外とシンプルに感じるかと思います
しかし実際に実装し始めると非常に複雑だと感じる事が多いのもアニメーションです
次ではそんなアニメーションの分かりづらい所について整理していきます




Discussion