🐥

FlutterのAnimationを実装する時に開いておくzennを書く

2024/03/05に公開

https://zenn.dev/imajoriri/books/049cd72fdb45b2

モチベーション

FlutterのAnimation周りを実装する時になると、Animated系って...Transition系って...AnimationControllerってどうやって...となるのでまとまった記事が欲しくなりました。
随時更新していきますが、まとめていきます。

仕組みから説明している部分もありますが、なるべく実際に実装する時に開いて置ける記事を目指しています。

ImplicitlyAnimatedWidgetのサブクラス(Animated系)

最もシンプルにアニメーションを実現できますが、できることもシンプルです。
Implicitlyとは「暗黙的な」という意味があり、その名の通り明示的にアニメーションのスタートやストップを記述する必要がありません。
ImplicitlyAnimatedWidgetのプロパティを見るとわかりますが、アニメーションを調整できるのは以下の3つです

  • curve
  • duration(required)
  • onEnd

どんな種類があるかは公式を見ればわかります。
https://api.flutter.dev/flutter/widgets/ImplicitlyAnimatedWidget-class.html

ImplicitlyAnimatedWidgetのサブクラスを自作する

Flutterが用意してくれているAnimated系に仕様を満たすものがない場合は自作するという手もありますが、ある程度内部実装を理解していた方がスムーズに実装できます。

ImplicitlyAnimatedWidgetStatefulWidgetなのでState<T>のサブクラスも必要になりますが、その対象はImplicitlyAnimatedWidgetStateと決められています。

abstract class ImplicitlyAnimatedWidget extends StatefulWidget {
  ...

  
  ImplicitlyAnimatedWidgetState<ImplicitlyAnimatedWidget> createState();

  ...
}

ImplicitlyAnimatedWidgetStateの中身を見てみます。
特徴としてはAnimationControllerが使われているのとforEachTweenの実装が必要になっているということです。

abstract class ImplicitlyAnimatedWidgetState<T extends ImplicitlyAnimatedWidget> extends State<T> with SingleTickerProviderStateMixin<T> {

  
  AnimationController get controller => _controller;
  late final AnimationController _controller = AnimationController(
    duration: widget.duration,
    debugLabel: kDebugMode ? widget.toStringShort() : null,
    vsync: this,
  );

  
  void forEachTween(TweenVisitor<dynamic> visitor);

このforEachTweenをどう実装すればいいかですが、説明にわかりやすいAnimatedPositionedのStateである_AnimatedPositionedStateを見てみます。

https://github.com/flutter/flutter/blob/6e5134b0673eabe85fbd898b876185daf5a1b110/packages/flutter/lib/src/widgets/implicit_animations.dart#L1019

まずはforEachTween以外の部分で、buildメソッドですがどうやら単にPositionedを返しているだけで、そのプロパティはTweenevaluateからとってきています。
ちなみにevaluateの引数のanimationImplicitlyAnimatedWidgetStateが持っているAnimationContrllerです。

class _AnimatedPositionedState extends AnimatedWidgetBaseState<AnimatedPositioned> {
  Tween<double>? _left;
  Tween<double>? _top;
  Tween<double>? _right;
  Tween<double>? _bottom;
  Tween<double>? _width;
  Tween<double>? _height;

  
  Widget build(BuildContext context) {
    return Positioned(
      left: _left?.evaluate(animation),
      top: _top?.evaluate(animation),
      right: _right?.evaluate(animation),
      bottom: _bottom?.evaluate(animation),
      width: _width?.evaluate(animation),
      height: _height?.evaluate(animation),
      child: widget.child,
    );
  }

  ...
}

次にforEachTweenを見てみると、ここでそれぞれのTween<double>に値を代入しています。

  
  void forEachTween(TweenVisitor<dynamic> visitor) {
    _left = visitor(_left, widget.left, (dynamic value) => Tween<double>(begin: value as double)) as Tween<double>?;
    _top = visitor(_top, widget.top, (dynamic value) => Tween<double>(begin: value as double)) as Tween<double>?;
    _right = visitor(_right, widget.right, (dynamic value) => Tween<double>(begin: value as double)) as Tween<double>?;
    _bottom = visitor(_bottom, widget.bottom, (dynamic value) => Tween<double>(begin: value as double)) as Tween<double>?;
    _width = visitor(_width, widget.width, (dynamic value) => Tween<double>(begin: value as double)) as Tween<double>?;
    _height = visitor(_height, widget.height, (dynamic value) => Tween<double>(begin: value as double)) as Tween<double>?;
  }

visitorの引数には

  1. 代入するTween
  2. アニメーション後の値となっていて欲しい値
  3. 「Tweenのbeginを引数のvalueで生成したインスタンス」を返すメソッド

を渡しています。
こうして、AnimatedPositioned(left: 値)の値の部分が変化すれば自動でアニメーションしてくれるWidgetの完成です。

forEachTweenが呼ばれるのはinitStatedidUpdateWidgetの2箇所で呼ばれます。
アニメーションが起こるのはdidUpdateWidgetの方で、Widgetが更新されアニメーションの起動が必要であればforward()が呼ばれます。

ただし、AnimatedContainerを見るとわかりますが、ImplicitlyAnimatedWidgetStateではなく、さらにそのサブクラスのAnimatedWidgetBaseStateが使われています。

https://github.com/flutter/flutter/blob/6e5134b0673eabe85fbd898b876185daf5a1b110/packages/flutter/lib/src/widgets/implicit_animations.dart#L726

このAnimatedWidgetBaseStateAnimationControllerの値が変化したらsetStateするという違いだけです。

abstract class AnimatedWidgetBaseState<T extends ImplicitlyAnimatedWidget> extends ImplicitlyAnimatedWidgetState<T> {
  
  void initState() {
    super.initState();
    controller.addListener(_handleAnimationChanged);
  }

  void _handleAnimationChanged() {
    setState(() { /* The animation ticked. Rebuild with new animation value */ });
  }
}

AnimatedWidgetのサブクラス(Transition系)

ImplicitlyAnimatedWidgetのサブクラスであるAnimated系では実現できないものはこのTransition系か、後に記述するAnimatedBuilderで実現できないかを考えるのがよのかなと思います。
どんなTransition系があるかは公式に書いてあります。
https://api.flutter.dev/flutter/widgets/AnimatedWidget-class.html

例えばSizeTransitionは以下のようにして使います。

ポイントは

  • SingleTickerProviderStateMixinを継承したクラス
  • initStateでAnimationControllerを作成
  • 引数のAnimation(今回はsizeFactor)に、Animationを渡す。
class _MyAppState extends State<MyApp> with SingleTickerProviderStateMixin {
  AnimationController _controller;
  Animation<double> _animation;

  
  void initState() {
    super.initState();
    _controller = AnimationController(
      duration: const Duration(seconds: 2),
      vsync: this,
    );

    _animation = CurvedAnimation(
      parent: _controller,
      curve: Curves.fastOutSlowIn,
    );
  }

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

  
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        appBar: AppBar(
          title: Text('SizeTransition Example'),
        ),
        body: Center(
          child: SizeTransition(
            sizeFactor: _animation,
            axis: Axis.vertical, // 縦方向のサイズ変更を行います
            axisAlignment: -1.0,
            child: Container(
              width: 200,
              height: 200,
              color: Colors.blue,
            ),
          ),
        ),
        floatingActionButton: FloatingActionButton(
          onPressed: () {
            if (_controller.isCompleted) {
              _controller.reverse();
            } else {
              _controller.forward();
            }
          },
          child: Icon(Icons.play_arrow),
        ),
      ),
    );
  }
}

Transition系の自作

Transition系ももちろん自作ができます。そのために抽象クラスのAnimatedWidgetを見てみます。
AnimatedWidgetStatefulWidgetのサブクラスで、Listenableを引数に受け取ります。

abstract class AnimatedWidget extends StatefulWidget {
  const AnimatedWidget({
    super.key,
    required this.listenable,
  });

  /// The [Listenable] to which this widget is listening.
  ///
  /// Commonly an [Animation] or a [ChangeNotifier].
  final Listenable listenable;

  ...
}

AnimatedWidgetStateである_AnimatedStateinitStateを見ると、listenしており、その中ではsetStateをしているだけです。

class _AnimatedState extends State<AnimatedWidget> {
  
  void initState() {
    super.initState();
    widget.listenable.addListener(_handleChange);
  }

  void _handleChange() {
    setState(() {
      // The listenable's state is our build state, and it changed already.
    });
  }

  ...

つまりAnimatedWidget渡しているListenable(実際はAnimationを渡すことが多い)に変化があればリビルドしていることになります。

Animation(Controller)

Transition系やAnimatedBuilderを使おうと思うとAnimationクラスやそのサブクラスのAnimationControllerの理解が必要です。
Animationを直接作成することは少なく、AnimationControllerを先に作り、driveメソッドなどとTweenで必要なAnimationクラスのインスタンスを作成すると思います。

AnimationControllerはその名の通りアニメーションを操作するもので、forwardreverseメソッドでアニメーションを開始したりするほか、.valueに値を渡すことでもアニメーションを起動させることができます。
基本的に0.0から1.0の値を保持しており、forwardでアニメーションを開始するとdurationで指定した時間をかけて0.01.0へと値が変化していきます。

Transition系やAnimatedBuilderと使うケースが多いですが、addListnerの中でsetStateをすれば、これだけでアニメーションは実現できます。

late AnimationController controller;
late Animation<Alignment> animation;
final easeOut = CurveTween(curve: Curves.easeOut);
late Animation<Color?> _iconColor;


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

  controller = AnimationController(
    duration: Duration(seconds: 3),
    vsync: this
  )..addListener(() {
        setState(() {});
      });
  animation = controller.drive(easeOut);
  _iconColor = _controller.drive(_iconColorTween.chain(_easeInTween));
}

// widget
Widget build(BuildContext context) {
  return Align( 
    alignment: animation.value, 
    child: Text('Hello world!'), 
  );
}

Tween

AnimationController0.0から1.0の値を保持していると述べましたが、例えばColorを赤から青にゆっくり変化させたい時に数値では不可能です。
なので0.0から1.0の変化をからの変化に変えてくれるのがColorTween(Tweenのサブクラス)です。

Animation.driveメソッドなどと一緒に使うケースが多いかと思います。

final Animation animation = _animationController.drive(
  AlignmentTween(
    begin: Alignment.topLeft,
      end: Alignment.bottomRight,
    ),
  );

同じことをしていますが、Tween.animateメソッドからもAnimationを作ることができます。

final Animation<Alignment> animation = AlignmentTween(
  begin: Alignment.topLeft,
  end: Alignment.bottomRight,
).animate(_animationController);

evaluateメソッド

Tween.evaluateメソッドもたまに使うので書いておきます。
とは言っても、単純に値を返してくれるメソッドで、例えば以下の例がわかりやすいかと思います(ChatGPTに聞いた)

// Tweenの定義: 0から100までの数値を補間します。
final Tween<double> tween = Tween<double>(begin: 0.0, end: 100.0);

// AnimationControllerの定義: アニメーションの制御を行います。
final AnimationController controller = AnimationController(
  duration: const Duration(seconds: 2),
  vsync: this, // vsyncはTickerProviderのインスタンスを指します。
);

// Animationオブジェクトの生成: TweenとAnimationControllerを組み合わせます。
final Animation<double> animation = controller.drive(tween);

// アニメーションの進行に合わせてTweenの値を評価します。
// ここでは、controllerの値が0.5(中間点)の場合の例を示します。
final double value = tween.evaluate(animation);
// この場合、`value`は50.0になります(0.0から100.0の中間値)。

chainメソッド

2つのTweenを繋げることができます。

SizeTweenCurveTweenを組み合わせた例です。

_animation = SizeTween(
  begin: Size(100, 100), // 開始サイズ
  end: Size(200, 200),   // 終了サイズ
).chain(
  CurveTween(curve: Curves.easeInOut), // カーブを適用
).animate(_controller);

ちなみに、.chainを使わなくても.driveで書くこともできます。

_animation = _controller.drive(
  CurveTween(curve: Curves.easeInOut), // カーブを適用
).drive(
  SizeTween(
    begin: Size(100, 100), // 開始サイズ
    end: Size(200, 200),   // 終了サイズ
  ),
);

Interval

AnimationControllerは0.0→1.0の変化ですが、例えば途中から動き出して欲しいなどの使用の場合は0.5→1.0の値の変化のにアニメーションさせたい場合はIntervalを使います。

AnimationController controller = AnimationController(
  duration: const Duration(seconds: 2),
  vsync: this,
);

// 50%から始まり、アニメーション終了時に完了するアニメーション
Animation<double> animation = Tween(begin: 0.0, end: 1.0)
  .animate(
    CurvedAnimation(
      parent: controller,
      curve: Interval(
        0.5, 1.0, // ここで、アニメーションの時間区間を指定します
        curve: Curves.easeInOut, // この区間に適用するカーブ
      ),
    ),
  );

TweenSequence

一つのアニメーションの中に複数の変化を入れたい時、例えば

  • 最初はeaseinだけど途中からはease out
  • 一度のforwardで大きくなるが最終的には小さくなるContainer

などを実現できます。

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

final Animation<double> animation = TweenSequence<double>([
  TweenSequenceItem(
    tween: Tween<double>(begin: 0.0, end: 100.0)
      .chain(CurveTween(curve: Curves.easeIn)),
    weight: 50.0,
  ),
  TweenSequenceItem(
    tween: ConstantTween<double>(100.0),
    weight: 20.0,
  ),
  TweenSequenceItem(
    tween: Tween<double>(begin: 100.0, end: 0.0)
      .chain(CurveTween(curve: Curves.easeOut)),
    weight: 30.0,
  ),
]).animate(controller);

おわりに

ちょっと長くなってしまいましたが、疲れたのでここら辺で一旦終わりたいと思います。
SpringAnimation等、もうちょい書きたいことがあるので今度本として書くかもしれないです。
その時はまた読んでいただけると嬉しいです。

Discussion