Flutterのアニメーションを理解する(前編)

この記事はFlutter 全部俺 Advent Calendar 15日目の記事です。

このアドベントカレンダーについて

このアドベントカレンダーは @itome が全て書いています。

基本的にFlutterの公式ドキュメントとソースコードを参照しながら書いていきます。誤植や編集依頼はTwitterにお願いします。

Flutterのアニメーション

Flutterは、複雑なアニメーションもフレーム落ちを気にすることなく宣言的に実装できるように作られています。 しかしその代償として、初学者にとっては理解しづらい概念もいくつか必要になっており、基礎的な部分の理解が難しい領域でもあると思います。

AnimatedOpacityなどのAnimated系WidgetやSlideTransitionなどのTransition系Widgetも用意されているので、 細かいことを気にせずに実装することも可能ではありますが、せっかくなので基本的なところから一つ一つ理解していきましょう。

アニメーションのイメージ

具体的なクラスを見る前にアニメーションの大枠のイメージを掴んでおきましょう。 用語の詳しい説明は後にするので、ざっくりと理解できれば大丈夫です。

Flutterの一般的なアニメーションは以下の流れで考えるとわかりやすいです。

連続的な数値の変化

まずは、アニメーションの起点として連続的な値の変化が必要です。 最もわかりやすいのは時間経過に合わせて値を変化させていくパターンですが、 必ずしもそれだけに限定されません。例えばユーザーによる画面のドラッグやスクロール量の変化などもアニメーションの起点になります。 連続的に変化する値はAnimtionControllerクラスを使って実装します。詳しくは後述します。

今回は例として、Sliderの値をアニメーションの起点にしてみましょう。Sliderの下のTextにはAnimationControllerの 現在の数値を表示するようにします。

ここでの数値の変化幅は0.0~1.0にするのが一般的です。0.0~100.0など、任意の値にしても問題はないですが、 大きな値がほしい場合は次の手順で値の変換をするほうがきれいに実装することができます。

値をスライダーに合わせて変化させる

値を変換する

0.0から1.0までの値をそのままWidgetに渡して使うこともできますが、複雑に変化する値を扱うのには不向きです。 Tweenクラスを使うことで、数値を対応する別の値に変換することができます。 Tweenクラスを継承した自作クラスを使うこともできますが、デフォルトでもColorTweenCurveTweenが用意されています。`

ColorTweenを使って0.0~1.0の値を色に変換してみましょう。 Sliderの変化に合わせて色の情報が変わっているのがわかります。

ColorTweenを使って数値を色に変換する

変換された値をWidgetに適用する

最後に変換された値をWidgetに適用していきます。 ColorTweenで作ったAnimationの現在の値(色情報)をセットするだけですが、 そのままでは色情報が変わったときに再描画をすることができません。そこで、AnimatedBuilderWidgetを使って アニメーションに変更があったことをWidget側で検知できるようにします。

これでSliderの値に合わせて色が変わるアニメーションを実装することができました。

AnimatedBuilderを使って色情報をWidgetに適用する

ここまでのコードは以下

import 'package:flutter/material.dart';

void main() => runApp(MyApp());

class MyApp extends StatefulWidget {
  MyAppState createState() => MyAppState();
}

class MyAppState extends State<MyApp> with SingleTickerProviderStateMixin {
  AnimationController _controller;
  Animation<Color> _color;

  @override
  void initState() {
    super.initState();
    _controller = AnimationController(
      duration: const Duration(milliseconds: 300),
      vsync: this,
    );
    _color = ColorTween(
      begin: Colors.blue,
      end: Colors.red,
    ).animate(_controller);
  }

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      home: Scaffold(
        appBar: AppBar(title: Text('AnimationSample')),
        body: Padding(
          padding: const EdgeInsets.all(24),
          child: Column(
            children: <Widget>[
              AnimatedBuilder(
                animation: _controller,
                builder: (context, _) {
                  return Slider(
                    value: _controller.value,
                    onChanged: (value) => _controller.value = value,
                  );
                },
              ),
              AnimatedBuilder(
                animation: _controller,
                builder: (context, _) {
                  return Text(
                    _controller.value.toString(),
                    maxLines: 1,
                    style: const TextStyle(
                      fontSize: 32,
                      fontWeight: FontWeight.bold,
                    ),
                  );
                },
              ),
              Padding(
                padding: const EdgeInsets.symmetric(
                  vertical: 8,
                  horizontal: 24,
                ),
                child: const Text(
                  '↓',
                  style: TextStyle(
                    fontSize: 72,
                    fontWeight: FontWeight.bold,
                  ),
                ),
              ),
              AnimatedBuilder(
                animation: _color,
                builder: (context, child) {
                  return Text(
                    '#${_color.value.value.toRadixString(16).substring(2)}',
                    maxLines: 1,
                    style: const TextStyle(
                      fontSize: 56,
                      fontWeight: FontWeight.bold,
                    ),
                  );
                },
              ),
              Padding(
                padding: const EdgeInsets.symmetric(
                  vertical: 8,
                  horizontal: 24,
                ),
                child: const Text(
                  '↓',
                  style: TextStyle(
                    fontSize: 72,
                    fontWeight: FontWeight.bold,
                  ),
                ),
              ),
              AnimatedBuilder(
                animation: _color,
                builder: (context, child) {
                  return Container(
                    color: _color.value,
                    height: 120,
                  );
                },
              ),
            ],
          ),
        ),
      ),
    );
  }
}

アニメーションの応用

ここまでに紹介したパターンでさまざまな応用ができます。

もともとのアニメーション スライダーの値を0.5~0.1のScaleに変換 時間経過で値を変更(1秒間でアニメーション)
スライダーの値を色情報に変換 スライダーの値をScaleに変換 時間経過でアニメーション
スライダーの値を左からのOffsetに変換 Offsetに変換時にCurveTweenを使ってバウンス効果を追加 スライダーの値を色とOffsetのアニメーションに変換して同時に適用
スライダーの値をOffsetに変換 CurveTweenでバウンス 複数のアニメーションを同時に適用

Flutterのアニメーションに必要なクラスを理解する

アニメーションの実装の大まかなイメージはつかめたと思うので、アニメーションに必要なクラスや概念の説明をしていきます。

Animation class

Flutterのアニメーションの基本となるクラスです。現在の値とアニメーションの状態(終了したかアニメーション中かなど)だけを持ちます。 Listenableを継承しているので値の変更を監視することはできますが、このクラスは値を保持するだけなので、 時間経過に合わせて値を変更したり外から値を変更するためにはAnimationControllerを使う必要があります。

Animationは抽象クラスなので、インスタンス化する時は後述するAnimationControllerを使うか、Tweenクラスから生成することになります。

AnimationController class

自分の値を動的に変更できるAnimationのサブクラスです。外から現在の値を渡す方法と、 指定されたDurationで自動的にアニメーションを動かす方法の二種類でアニメーションを管理することができます。

自動でアニメーションする場合はforward0.0~1.0で値が変化し、reverse1.0~0.0で値が変化します。

...
AnimationController _controller = AnimationController(
  duration: const Duration(milliseconds: 300),
  vsync: this,
);
...

_controller.forward() // Value changes from 0.0 to 1.0 in 3000 millis

SchedulerBinding class

アニメーションはパラパラ漫画のようにWidgetを更新していくことで実現されていますが、どれくらいの頻度でWidgetを更新すればいいでしょうか。 デバイスの画面には固有のfps(frame per second)があるので、画面のフレームが再描画されるのと同じタイミングでWidgetの更新をするのが 効果的なはずです。しかしfpsはデバイスによってさまざまで、一般的な60fps以外にiPadのように120だったり90だったり、はたまた30だったりします。

これらのデバイスの違いを吸収するために、デバイスのフレームの変更を検知してくれるのがSchedulerBindingクラスです。

SchedulerBindingクラスはアプリ全体に一つだけのシングルトンクラスで、デバイスのフレーム更新時にscheduleFrameCallbackで 登録されたリスナーに変更を通知します。

Ticker

SchedulerBindingは常にデバイスのフレーム更新を通知するので、全てのアニメーションが毎回それに反応するとパフォーマンスの悪化につながります。 特にスクロールされて画面から外れたWidgetや、画面遷移で前の画面として残っているWidgetがアニメーションをすることは避けるべきです。 そのため、SchedulerBindingは一度Tickerに更新を通知し、実際にWidgetのアニメーションを行うかはTickerが決めることで、 無駄なアニメーションが走ることを避けています。

Tickerクラスはstart stop mutedメソッドを持っていて、 SchedulerBindingからフレームが更新された通知が送られてきたときにstartされていた 場合にのみWidgetに次のフレームのアニメーションを指示します。

実際には単体で使うことはなく、TickerProviderを介してAnimationControllerに渡すことがほとんどです。 AnimationControllerに渡すvsync: パラメーターは実はTickerProviderを渡すためのものです。

TickerProviderを自分で作ることもほとんどのケースでないと思います。

AnimationControllerを保持しているStateクラスにSingleTickerProviderStateMixinをmixinすると、 そのStateクラスがTickerProviderになるので、AnimationControllerにはthisを渡すだけで済みます。

var _controller = AnimationController(vsync: this, ...);

Tickerの管理はSingleTickerProviderStateMixinがよしなにやってくれるので、 画面外での無駄なアニメーションをこれだけで防ぐことができます。

ちなみにSingleTickerProviderStateMixinStateが複数ある場合に対応していないので、その場合はTickerProviderStateMixinを使いましょう。

‘Animatable’ class

Animationクラスと違いが分かりづらいですが、全く別のクラスです。 Animationクラスのdouble値を別の値に変換するためにつかいます。

Tween class

Animatableを継承したクラスで、ColorTweenのようにさらに継承して使います。 初期値と最後の値を決めて自動的に中間の値を補完してアニメーションしてくれます。

明日の記事では、 これらの基本の道具を使って応用的なアニメーションを作っていきます。

14日目: FlutterでAndroid/iOSのネイティブのAPIを使う :

https://itome.team/blog/2019/12/flutter-advent-calendar-day14

16日目: Flutterのアニメーションを理解する(後編) :

https://itome.team/blog/2019/12/flutter-advent-calendar-day16