Flutter のアニメーション

1. はじめに

アニメーションは、アプリのユーザー エクスペリエンスを向上させ、重要な情報をユーザーに伝え、アプリをより洗練されたものにし、使いやすくするための優れた方法です。

Flutter のアニメーション フレームワークの概要

Flutter は、各フレームでウィジェット ツリーの一部を再ビルドすることでアニメーション エフェクトを表示します。事前構築済みのアニメーション効果やその他の API が用意されており、アニメーションの作成と合成が容易になります。

  • 暗黙的アニメーションは、アニメーション全体を自動的に実行する事前ビルドされたアニメーション エフェクトです。アニメーションのターゲット値が変更されると、現在の値からターゲット値へのアニメーションが実行され、その間の各値が表示されるため、ウィジェットがスムーズにアニメーション化されます。暗黙的なアニメーションの例としては、AnimatedSizeAnimatedScaleAnimatedPositioned などがあります。
  • 明示的アニメーションも事前ビルドされたアニメーション エフェクトですが、動作させるには Animation オブジェクトが必要です。例: SizeTransitionScaleTransitionPositionedTransition など。
  • Animation は、実行中または停止中のアニメーションを表すクラスです。アニメーションが実行するターゲット値を表すと、アニメーションが特定の時点で画面に表示する現在の値を表すステータスで構成されています。これは Listenable のサブクラスであり、アニメーションの実行中にステータスが変更されたときにリスナーに通知します。
  • AnimationController は、アニメーションを作成してその状態を制御する方法です。forward()reset()stop()repeat() などのメソッドを使用して、表示されるアニメーション効果(スケール、サイズ、位置など)を定義しなくてもアニメーションを制御できます。
  • Tween は、開始値と終了値の間の値を補間するために使用され、double、OffsetColor など、任意の型を表すことができます。
  • カーブは、パラメータの時間変化率を調整するために使用します。アニメーションを実行する際に、イージング カーブを適用して、アニメーションの開始時や終了時の変化率を速めたり遅くしたりするのが一般的です。カーブは 0.0 ~ 1.0 の入力値を受け取り、0.0 ~ 1.0 の出力値を返します。

作成するアプリの概要

この Codelab では、さまざまなアニメーション エフェクトと手法を取り入れた多肢選択式のクイズゲームを作成します。

3026390ad413769c.gif

以下について学びます。

  • サイズと色をアニメーション化するウィジェットを作成する
  • 3D カードフリップ エフェクトを作成する
  • アニメーション パッケージの便利なビルド済みアニメーション エフェクトを使用する
  • Android の最新バージョンで利用可能な予測型「戻る」ジェスチャーのサポートを追加する

学習内容

この Codelab では、以下について学びます。

  • 暗黙的にアニメーション化されたエフェクトを使用して、多くのコードを必要とせずに美しいアニメーションを実現する方法。
  • 明示的にアニメーション化された効果を使用して、AnimatedSwitcherAnimationController などの事前ビルドされたアニメーション ウィジェットを使用して独自の効果を設定する方法を学びます。
  • AnimationController を使用して、3D 効果を表示する独自のウィジェットを定義する方法。
  • animations パッケージを使用して、最小限の設定で凝ったアニメーション効果を表示する方法。

必要なもの

  • Flutter SDK
  • IDE(VSCode、Android Studio / IntelliJ など)

2. Flutter の開発環境をセットアップする

このラボを完了するには、Flutter SDKエディタの 2 つのソフトウェアが必要です。

この Codelab は、次のいずれかのデバイスを使って実行できます。

  • パソコンに接続され、デベロッパー モードに設定された物理デバイス(Androidステップ 7 の予測型「戻る」の実装に推奨)または iOS
  • iOS シミュレータ(Xcode ツールのインストールが必要)
  • Android Emulator(Android Studio でセットアップが必要)
  • ブラウザ(デバッグには Chrome が必要)
  • WindowsLinuxmacOS のデスクトップ パソコン。開発はデプロイする予定のプラットフォームで行う必要があります。たとえば、Windows のデスクトップ アプリを開発する場合は、適切なビルドチェーンにアクセスできるように Windows で開発する必要があります。オペレーティング システム固有の要件については、docs.flutter.dev/desktop に詳しい説明があります。

インストールを確認する

Flutter SDK が正しく構成され、上記のターゲット プラットフォームの 1 つ以上がインストールされていることを確認するには、Flutter Doctor ツールを使用します。

$ flutter doctor

Doctor summary (to see all details, run flutter doctor -v):
[✓] Flutter (Channel stable, 3.24.2, on macOS 14.6.1 23G93 darwin-arm64, locale
    en)
[✓] Android toolchain - develop for Android devices
[✓] Xcode - develop for iOS and macOS
[✓] Chrome - develop for the web
[✓] Android Studio
[✓] IntelliJ IDEA Ultimate Edition
[✓] VS Code
[✓] Connected device (4 available)
[✓] Network resources

• No issues found!

3. スターター アプリを実行する

スターター アプリをダウンロードする

git を使用して、GitHub の flutter/samples リポジトリから開始アプリのクローンを作成します。

$ git clone https://github.com/flutter/codelabs.git
$ cd codelabs/animations/step_01/

または、ソースコードを .zip ファイルとしてダウンロードすることもできます。

アプリを実行する

アプリを実行するには、flutter run コマンドを使用して、ターゲット デバイス(androidioschrome など)を指定します。サポートされているプラットフォームの一覧については、サポートされているプラットフォームのページをご覧ください。

$ flutter run -d android

任意の IDE を使用してアプリを実行してデバッグすることもできます。詳しくは、公式の Flutter ドキュメントをご覧ください。

コードについて

スターターアプリは、モデルビュービューモデル(MVVM)設計パターンに沿って 2 つの画面で構成される、多肢選択式のクイズゲームです。QuestionScreen(ビュー)は、QuizViewModel(ビューモデル)クラスを使用して、QuestionBank(モデル)クラスの多肢選択式の質問をユーザーに提示します。

  • home_screen.dart - [New Game] ボタンのある画面を表示します。
  • main.dart - マテリアル 3 を使用してホーム画面を表示するように MaterialApp を構成します。
  • model.dart - アプリ全体で使用されるコアクラスを定義します。
  • question_screen.dart - クイズゲームの UI を表示します。
  • view_model.dart - QuestionScreen によって表示されるクイズゲームの状態とロジックを格納します。

fbb1e1f7b6c91e21.png

アプリは、ユーザーが [New Game] ボタンを押した際に Flutter の Navigator クラスによって表示されるデフォルトのビュー遷移を除き、アニメーション エフェクトをまだサポートしていません。

4. 暗黙的なアニメーション効果を使用する

暗黙的なアニメーションは、特別な構成を必要としないため、多くの状況で適しています。このセクションでは、アニメーション化されたスコアボードを表示するように StatusBar ウィジェットを更新します。一般的な暗黙的なアニメーション エフェクトについては、ImplicitlyAnimatedWidget API のドキュメントをご覧ください。

206dd8d9c1fae95.gif

アニメーションなしのスコアボード ウィジェットを作成する

次のコードを使用して新しいファイル lib/scoreboard.dart を作成します。

lib/scoreboard.dart

import 'package:flutter/material.dart';

class Scoreboard extends StatelessWidget {
  final int score;
  final int totalQuestions;

  const Scoreboard({
    super.key,
    required this.score,
    required this.totalQuestions,
  });

  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.all(8.0),
      child: Row(
        mainAxisAlignment: MainAxisAlignment.center,
        children: [
          for (var i = 0; i < totalQuestions; i++)
            Icon(
              Icons.star,
              size: 50,
              color:
                  score < i + 1 ? Colors.grey.shade400 : Colors.yellow.shade700,
            )
        ],
      ),
    );
  }
}

次に、StatusBar ウィジェットの子に Scoreboard ウィジェットを追加し、以前はスコアと合計問題数を表示していた Text ウィジェットを置き換えます。エディタによって、必要な import "scoreboard.dart" がファイルの上部に自動的に追加されます。

lib/question_screen.dart

class StatusBar extends StatelessWidget {
  final QuizViewModel viewModel;

  const StatusBar({required this.viewModel, super.key});

  @override
  Widget build(BuildContext context) {
    return Card(
      elevation: 4,
      child: Padding(
        padding: EdgeInsets.all(8.0),
        child: Row(
          mainAxisAlignment: MainAxisAlignment.spaceAround,
          children: [
            Scoreboard(                                        // NEW
              score: viewModel.score,                          // NEW
              totalQuestions: viewModel.totalQuestions,        // NEW
            ),
          ],
        ),
      ),
    );
  }
}

このウィジェットには、質問ごとに星形アイコンが表示されます。質問に正解すると、アニメーションなしですぐに別の星が光ります。次のステップでは、スコアのサイズと色をアニメーション化して、スコアが変更されたことをユーザーに通知します。

暗黙的なアニメーション効果を使用する

AnimatedStar という新しいウィジェットを作成します。このウィジェットは、AnimatedScale ウィジェットを使用して、星が有効になったときに scale の値を 0.5 から 1.0 に変更します。

lib/scoreboard.dart

​​import 'package:flutter/material.dart';

class Scoreboard extends StatelessWidget {
  final int score;
  final int totalQuestions;

  const Scoreboard({
    super.key,
    required this.score,
    required this.totalQuestions,
  });

  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.all(8.0),
      child: Row(
        mainAxisAlignment: MainAxisAlignment.center,
        children: [
          for (var i = 0; i < totalQuestions; i++)
            AnimatedStar(                                      // NEW
              isActive: score > i,                             // NEW
            )                                                  // NEW
        ],
      ),
    );
  }
}

class AnimatedStar extends StatelessWidget {                   // Add from here...
  final bool isActive;
  final Duration _duration = const Duration(milliseconds: 1000);
  final Color _deactivatedColor = Colors.grey.shade400;
  final Color _activatedColor = Colors.yellow.shade700;

  AnimatedStar({super.key, required this.isActive});

  @override
  Widget build(BuildContext context) {
    return AnimatedScale(
      scale: isActive ? 1.0 : 0.5,
      duration: _duration,
      child: Icon(
        Icons.star,
        size: 50,
        color: isActive ? _activatedColor : _deactivatedColor,
      ),
    );
  }
}                                                              // To here.

これで、ユーザーが質問に正しく回答すると、AnimatedStar ウィジェットは暗黙的なアニメーションを使用してサイズを更新します。ここでは、Iconcolor はアニメーション化されず、scale のみがアニメーション化されます。これは AnimatedScale ウィジェットによって行われます。

84aec4776e70b870.gif

Tween を使用して 2 つの値を補間する

isActive フィールドが true に変更された直後に、AnimatedStar ウィジェットの色が変更されていることに注目してください。

アニメーション化された色効果を実現するには、AnimatedContainer ウィジェット(ImplicitlyAnimatedWidget の別のサブクラス)を使用できます。これは、色を含むすべての属性を自動的にアニメーション化できるためです。申し訳ございませんが、ウィジェットにはコンテナではなくアイコンを表示する必要があります。

AnimatedIcon を試すこともできます。これは、アイコンの形状間の遷移効果を実装します。ただし、AnimatedIcons クラスには星形アイコンのデフォルト実装はありません。

代わりに、ImplicitlyAnimatedWidget の別のサブクラスである TweenAnimationBuilder を使用します。これは、Tween をパラメータとして受け取ります。トゥイーンとは、2 つの値(beginend)を受け取り、アニメーションで表示できるように中間値を計算するクラスです。この例では、アニメーション エフェクトの作成に必要な Tween<Color> インターフェースを満たす ColorTween を使用します。

Icon ウィジェットを選択し、IDE で [Wrap with Builder] クイック アクションを使用して、名前を TweenAnimationBuilder に変更します。次に、時間と ColorTween を指定します。

lib/scoreboard.dart

class AnimatedStar extends StatelessWidget {
  final bool isActive;
  final Duration _duration = const Duration(milliseconds: 1000);
  final Color _deactivatedColor = Colors.grey.shade400;
  final Color _activatedColor = Colors.yellow.shade700;

  AnimatedStar({super.key, required this.isActive});

  @override
  Widget build(BuildContext context) {
    return AnimatedScale(
      scale: isActive ? 1.0 : 0.5,
      duration: _duration,
      child: TweenAnimationBuilder(                            // Add from here...
        duration: _duration,
        tween: ColorTween(
          begin: _deactivatedColor,
          end: isActive ? _activatedColor : _deactivatedColor,
        ),
        builder: (context, value, child) {                     // To here.
          return Icon(
            Icons.star,
            size: 50,
            color: value,                                      // Modify from here...
          );
        },                                                     // To here.
      ),
    );
  }
}

アプリをホットリロードして、新しいアニメーションを確認します。

8b0911f4af299a60.gif

ColorTweenend 値は、isActive パラメータの値に基づいて変化します。これは、TweenAnimationBuilderTween.end 値が変更されるたびにアニメーションを再実行するためです。この場合、新しいアニメーションは現在のアニメーション値から新しい終了値まで実行されます。これにより、(アニメーションの実行中でも)いつでも色を変更し、正しい中間値でスムーズなアニメーション効果を表示できます。

曲線を適用する

どちらのアニメーション効果も一定の速度で実行されますが、アニメーションは速度を上げたり下げたりすることで、視覚的に興味深く、有益なものになることがあります。

Curve は、時間の経過に伴うパラメータの変化率を定義する減衰関数を適用します。Flutter には、easeIneaseOut など、Curves クラスに事前ビルドされたイージング カーブのコレクションが付属しています。

5dabe68d1210b8a1.gif

3a9e7490c594279a.gif

これらの図(Curves API のドキュメント ページで入手可能)は、カーブの仕組みを理解するうえで役立ちます。カーブは、0.0 ~ 1.0 の入力値(X 軸に表示)を 0.0 ~ 1.0 の出力値(Y 軸に表示)に変換します。また、これらの図には、イージング カーブを使用した場合のさまざまなアニメーション効果のプレビューも示されています。

AnimatedStar に _curve という新しいフィールドを作成し、それをパラメータとして AnimatedScale ウィジェットと TweenAnimationBuilder ウィジェットに渡します。

lib/scoreboard.dart

class AnimatedStar extends StatelessWidget {
  final bool isActive;
  final Duration _duration = const Duration(milliseconds: 1000);
  final Color _deactivatedColor = Colors.grey.shade400;
  final Color _activatedColor = Colors.yellow.shade700;
  final Curve _curve = Curves.elasticOut;                       // NEW

  AnimatedStar({super.key, required this.isActive});

  @override
  Widget build(BuildContext context) {
    return AnimatedScale(
      scale: isActive ? 1.0 : 0.5,
      curve: _curve,                                           // NEW
      duration: _duration,
      child: TweenAnimationBuilder(
        curve: _curve,                                         // NEW
        duration: _duration,
        tween: ColorTween(
          begin: _deactivatedColor,
          end: isActive ? _activatedColor : _deactivatedColor,
        ),
        builder: (context, value, child) {
          return Icon(
            Icons.star,
            size: 50,
            color: value,
          );
        },
      ),
    );
  }
}

この例では、elasticOut 曲線により、ばねの動きから始まり、最後に向かってバランスが取れる、誇張されたばね効果が得られます。

8f84142bff312373.gif

アプリをホットリロードすると、このカーブが AnimatedSizeTweenAnimationBuilder に適用されます。

206dd8d9c1fae95.gif

DevTools を使用してアニメーションを遅くする

アニメーション効果をデバッグするために、Flutter DevTools にはアプリ内のすべてのアニメーションを遅くしてアニメーションをより鮮明に表示する方法が用意されています。

DevTools を開くには、アプリがデバッグモードで実行されていることを確認します。次に、VSCode のデバッグ ツールバーで [Widget Inspector] を選択するか、IntelliJ / Android Studio のデバッグ ツール ウィンドウで [Open Flutter DevTools] ボタンを選択して、Widget Inspector を開きます。

3ce33dc01d096b14.png

363ae0fbcd0c2395.png

ウィジェット インスペクタが開いたら、ツールバーの [アニメーションを遅くする] ボタンをクリックします。

adea0a16d01127ad.png

5. 明示的なアニメーション エフェクトを使用する

明示的アニメーションは、暗黙的アニメーションと同様に事前ビルドされたアニメーション効果ですが、ターゲット値ではなく Animation オブジェクトをパラメータとして受け取ります。そのため、アニメーションがナビゲーション遷移(AnimatedSwitcherAnimationController など)によってすでに定義されている場合に便利です。

明示的なアニメーション効果を使用する

明示的なアニメーション効果を開始するには、Card ウィジェットを AnimatedSwitcher でラップします。

lib/question_screen.dart

class QuestionCard extends StatelessWidget {
  final String? question;

  const QuestionCard({
    required this.question,
    super.key,
  });

  @override
  Widget build(BuildContext context) {
    return AnimatedSwitcher(                                 // NEW
      duration: const Duration(milliseconds: 300),           // NEW
      child: Card(
        key: ValueKey(question),
        elevation: 4,
        child: Padding(
          padding: const EdgeInsets.all(16.0),
          child: Text(
            question ?? '',
            style: Theme.of(context).textTheme.displaySmall,
          ),
        ),
      ),                                                     // NEW
    );
  }
}

AnimatedSwitcher はデフォルトでクロスフェード エフェクトを使用しますが、transitionBuilder パラメータを使用してオーバーライドできます。遷移ビルダーは、AnimatedSwitcher に渡された子ウィジェットと Animation オブジェクトを提供します。これは、明示的なアニメーションを使用する絶好の機会です。

この Codelab で最初に使用する明示的なアニメーションは SlideTransition です。これは、受信側と送信側のウィジェットが移動する開始オフセットと終了オフセットを定義する Animation<Offset> を受け取ります。

Tween には、任意の Animation を Tween が適用された別の Animation に変換するヘルパー関数 animate() があります。つまり、Tween<Offset> を使用して、AnimatedSwitcher から提供された Animation<double>Animation<Offset> に変換し、SlideTransition ウィジェットに提供できます。

lib/question_screen.dart

class QuestionCard extends StatelessWidget {
  final String? question;

  const QuestionCard({
    required this.question,
    super.key,
  });

  @override
  Widget build(BuildContext context) {
    return AnimatedSwitcher(
      transitionBuilder: (child, animation) {               // Add from here...
        final curveAnimation =
            CurveTween(curve: Curves.easeInCubic).animate(animation);
        final offsetAnimation =
            Tween<Offset>(begin: Offset(-0.1, 0.0), end: Offset.zero)
                .animate(curveAnimation);
        return SlideTransition(position: offsetAnimation, child: child);
      },                                                    // To here.
      duration: const Duration(milliseconds: 300),
      child: Card(
        key: ValueKey(question),
        elevation: 4,
        child: Padding(
          padding: const EdgeInsets.all(16.0),
          child: Text(
            question ?? '',
            style: Theme.of(context).textTheme.displaySmall,
          ),
        ),
      ),
    );
  }
}

ここでは、Tween.animate を使用して CurveAnimation に適用し、0.0 ~ 1.0 の範囲の Tween<double> を、x 軸で -0.1 ~ 0.0 に遷移する Tween<Offset> に変換します。

または、Animation クラスには、任意の Tween(または Animatable)を受け取って新しい Animation に変換する drive() 関数があります。これにより、トゥイーンを「連結」して、コードを簡潔にできます。

lib/question_screen.dart

transitionBuilder: (child, animation) {
  var offsetAnimation = animation
      .drive(CurveTween(curve: Curves.easeInCubic))
      .drive(Tween<Offset>(begin: Offset(-0.1, 0.0), end: Offset.zero));
  return SlideTransition(position: offsetAnimation, child: child);
},

明示的なアニメーションを使用するもう 1 つの利点は、アニメーションを簡単に組み合わせることができることです。SlideTransition ウィジェットをラップして、同じカーブ アニメーションを使用する別の明示的なアニメーション FadeTransition を追加します。

lib/question_screen.dart

return AnimatedSwitcher(
  transitionBuilder: (child, animation) {
    final curveAnimation =
        CurveTween(curve: Curves.easeInCubic).animate(animation);
    final offsetAnimation =
        Tween<Offset>(begin: Offset(-0.1, 0.0), end: Offset.zero)
            .animate(curveAnimation);
    final fadeInAnimation = curveAnimation;                            // NEW
    return FadeTransition(                                             // NEW
      opacity: fadeInAnimation,                                        // NEW
      child: SlideTransition(position: offsetAnimation, child: child), // NEW
    );                                                                 // NEW
  },

layoutBuilder をカスタマイズする

AnimationSwitcher で小さな問題が発生することがあります。QuestionCard が新しい質問に切り替わると、アニメーションの実行中は使用可能なスペースの中央に配置されますが、アニメーションが停止すると、ウィジェットは画面上部にスナップされます。これにより、質問カードの最終的な位置がアニメーションの実行中の位置と一致しないため、アニメーションがぎくしゃくします。

d77de181bdde58f7.gif

この問題を解決するため、AnimatedSwitcher にはレイアウトを定義するために使用できる layoutBuilder パラメータもあります。この関数を使用すると、カードを画面の上部に配置するようにレイアウト ビルダーを構成できます。

lib/question_screen.dart

@override
Widget build(BuildContext context) {
  return AnimatedSwitcher(
    layoutBuilder: (currentChild, previousChildren) {
      return Stack(
        alignment: Alignment.topCenter,
        children: <Widget>[
          ...previousChildren,
          if (currentChild != null) currentChild,
        ],
      );
    },

このコードは、AnimatedSwitcher クラスの defaultLayoutBuilder を変更したものですが、Alignment.center ではなく Alignment.topCenter を使用しています。

概要

  • 明示的なアニメーションは、Animation オブジェクトを受け取るアニメーション効果です(目標値と時間の長さを受け取る ImplicitlyAnimatedWidgets とは対照的です)。
  • Animation クラスは実行中のアニメーションを表しますが、特定の効果を定義しません。
  • Tween().animate または Animation.drive() を使用して、トゥイーンとカーブ(CurveTween を使用)をアニメーションに適用します。
  • AnimatedSwitcher の layoutBuilder パラメータを使用して、子のレイアウト方法を調整します。

6. アニメーションの状態を制御する

これまで、すべてのアニメーションはフレームワークによって自動的に実行されていました。暗黙的なアニメーションは自動的に実行されますが、明示的なアニメーション エフェクトが正しく動作するには、アニメーションが必要です。このセクションでは、AnimationController を使用して独自の Animation オブジェクトを作成し、TweenSequence を使用して Tween を組み合わせる方法について説明します。

AnimationController を使用してアニメーションを実行する

AnimationController を使用してアニメーションを作成する手順は次のとおりです。

  1. StatefulWidget を作成する
  2. State クラスで SingleTickerProviderStateMixin ミキシンを使用して、AnimationController に Ticker を提供する
  3. initState ライフサイクル メソッドで AnimationController を初期化し、現在の State オブジェクトを vsync(TickerProvider)パラメータに渡します。
  4. AnimatedBuilder を使用するか、listen() と setState を手動で呼び出して、AnimationController がリスナーに通知するたびにウィジェットが再ビルドされるようにします。

新しいファイル flip_effect.dart を作成し、次のコードをコピーして貼り付けます。

lib/flip_effect.dart

import 'dart:math' as math;

import 'package:flutter/widgets.dart';

class CardFlipEffect extends StatefulWidget {
  final Widget child;
  final Duration duration;

  const CardFlipEffect({
    super.key,
    required this.child,
    required this.duration,
  });

  @override
  State<CardFlipEffect> createState() => _CardFlipEffectState();
}

class _CardFlipEffectState extends State<CardFlipEffect>
    with SingleTickerProviderStateMixin {
  late final AnimationController _animationController;
  Widget? _previousChild;

  @override
  void initState() {
    super.initState();

    _animationController =
        AnimationController(vsync: this, duration: widget.duration);

    _animationController.addListener(() {
      if (_animationController.value == 1) {
        _animationController.reset();
      }
    });
  }

  @override
  void didUpdateWidget(covariant CardFlipEffect oldWidget) {
    super.didUpdateWidget(oldWidget);

    if (widget.child.key != oldWidget.child.key) {
      _handleChildChanged(widget.child, oldWidget.child);
    }
  }

  void _handleChildChanged(Widget newChild, Widget previousChild) {
    _previousChild = previousChild;
    _animationController.forward();
  }

  @override
  Widget build(BuildContext context) {
    return AnimatedBuilder(
      animation: _animationController,
      builder: (context, child) {
        return Transform(
          alignment: Alignment.center,
          transform: Matrix4.identity()
            ..rotateX(_animationController.value * math.pi),
          child: _animationController.isAnimating
              ? _animationController.value < 0.5
                  ? _previousChild
                  : Transform.flip(flipY: true, child: child)
              : child,
        );
      },
      child: widget.child,
    );
  }
}

このクラスは、AnimationController を設定し、フレームワークが didUpdateWidget を呼び出して、ウィジェットの構成が変更され、新しい子ウィジェットがある可能性があることを通知するたびにアニメーションを再実行します。

AnimatedBuilder により、AnimationController がリスナーに通知するたびにウィジェット ツリーが再ビルドされます。Transform ウィジェットを使用して 3D 回転効果を適用し、カードの裏返しをシミュレートします。

このウィジェットを使用するには、各回答カードを CardFlipEffect ウィジェットでラップします。カード ウィジェットに key を指定してください。

lib/question_screen.dart

@override
Widget build(BuildContext context) {
  return GridView.count(
    shrinkWrap: true,
    crossAxisCount: 2,
    childAspectRatio: 5 / 2,
    children: List.generate(answers.length, (index) {
      var color = Theme.of(context).colorScheme.primaryContainer;
      if (correctAnswer == index) {
        color = Theme.of(context).colorScheme.tertiaryContainer;
      }
      return CardFlipEffect(                                    // NEW
        duration: const Duration(milliseconds: 300),            // NEW
        child: Card.filled(                                     // NEW
          key: ValueKey(answers[index]),                        // NEW
          color: color,
          elevation: 2,
          margin: EdgeInsets.all(8),
          clipBehavior: Clip.hardEdge,
          child: InkWell(
            onTap: () => onTapped(index),
            child: Padding(
              padding: EdgeInsets.all(16.0),
              child: Center(
                child: Text(
                  answers.length > index ? answers[index] : '',
                  style: Theme.of(context).textTheme.titleMedium,
                  overflow: TextOverflow.clip,
                ),
              ),
            ),
          ),
        ),                                                      // NEW
      );
    }),
  );
}

アプリをホットリロードすると、CardFlipEffect ウィジェットを使用して回答カードがめくれます。

5455def725b866f6.gif

このクラスは、明示的なアニメーション効果によく似ています。実際、多くの場合、AnimatedWidget クラスを直接拡張して独自のバージョンを実装することをおすすめします。残念ながら、このクラスは以前のウィジェットを State に保存する必要があるため、StatefulWidget を使用する必要があります。独自の明示的なアニメーション エフェクトを作成する方法については、AnimatedWidget の API ドキュメントをご覧ください。

TweenSequence を使用して遅延を追加する

このセクションでは、CardFlipEffect ウィジェットに遅延を追加して、各カードが 1 枚ずつめくられるようにします。まず、delayAmount という新しいフィールドを追加します。

lib/flip_effect.dart

class CardFlipEffect extends StatefulWidget {
  final Widget child;
  final Duration duration;
  final double delayAmount;                      // NEW

  const CardFlipEffect({
    super.key,
    required this.child,
    required this.duration,
    required this.delayAmount,                   // NEW
  });

  @override
  State<CardFlipEffect> createState() => _CardFlipEffectState();
}

次に、delayAmountAnswerCards ビルドメソッドに追加します。

lib/question_screen.dart

@override
Widget build(BuildContext context) {
  return GridView.count(
    shrinkWrap: true,
    crossAxisCount: 2,
    childAspectRatio: 5 / 2,
    children: List.generate(answers.length, (index) {
      var color = Theme.of(context).colorScheme.primaryContainer;
      if (correctAnswer == index) {
        color = Theme.of(context).colorScheme.tertiaryContainer;
      }
      return CardFlipEffect(
        delayAmount: index.toDouble() / 2,                     // NEW
        duration: const Duration(milliseconds: 300),
        child: Card.filled(
          key: ValueKey(answers[index]),

次に、_CardFlipEffectState で、TweenSequence を使用して遅延を適用する新しいアニメーションを作成します。Future.delayed など、dart:async ライブラリのユーティリティは使用されません。これは、遅延がアニメーションの一部であり、ウィジェットが AnimationController を使用するときに明示的に制御するものではないためです。これにより、同じ TickerProvider を使用するため、DevTools で遅いアニメーションを有効にしたときにアニメーション効果を簡単にデバッグできるようになります。

TweenSequence を使用するには、2 つの TweenSequenceItem を作成します。1 つは、アニメーションを相対的な時間だけ 0 に保つ ConstantTween と、0.0 から 1.0 に移動する通常の Tween を含む TweenSequenceItem です。

lib/flip_effect.dart

class _CardFlipEffectState extends State<CardFlipEffect>
    with SingleTickerProviderStateMixin {
  late final AnimationController _animationController;
  Widget? _previousChild;
  late final Animation<double> _animationWithDelay; // NEW

  @override
  void initState() {
    super.initState();

    _animationController = AnimationController(
        vsync: this, duration: widget.duration * (widget.delayAmount + 1));

    _animationController.addListener(() {
      if (_animationController.value == 1) {
        _animationController.reset();
      }
    });

    _animationWithDelay = TweenSequence<double>([   // NEW
      if (widget.delayAmount > 0)                   // NEW
        TweenSequenceItem(                          // NEW
          tween: ConstantTween<double>(0.0),        // NEW
          weight: widget.delayAmount,               // NEW
        ),                                          // NEW
      TweenSequenceItem(                            // NEW
        tween: Tween(begin: 0.0, end: 1.0),         // NEW
        weight: 1.0,                                // NEW
      ),                                            // NEW
    ]).animate(_animationController);               // NEW
  }

最後に、build メソッドで AnimationController のアニメーションを新しい遅延アニメーションに置き換えます。

lib/flip_effect.dart

@override
Widget build(BuildContext context) {
  return AnimatedBuilder(
    animation: _animationWithDelay,                            // Modify this line
    builder: (context, child) {
      return Transform(
        alignment: Alignment.center,
        transform: Matrix4.identity()
          ..rotateX(_animationWithDelay.value * math.pi),      // And this line
        child: _animationController.isAnimating
            ? _animationWithDelay.value < 0.5                  // And this one.
                ? _previousChild
                : Transform.flip(flipY: true, child: child)
            : child,
      );
    },
    child: widget.child,
  );
}

アプリをホットリロードして、カードが 1 枚ずつめくられるのを確認します。チャレンジとして、Transform ウィジェットによって提供される 3D 効果の視点の変更を試してみてください。

28b5291de9b3f55f.gif

7. カスタム ナビゲーション遷移を使用する

ここまでは、1 つの画面でエフェクトをカスタマイズする方法について説明してきましたが、アニメーションを使用するもう 1 つの方法として、画面間の遷移にアニメーションを使用する方法があります。このセクションでは、組み込みのアニメーション エフェクトと、pub.dev の公式の アニメーション パッケージで提供される魅力的なビルド済みアニメーション エフェクトを使用して、画面遷移にアニメーション エフェクトを適用する方法について説明します。

ナビゲーション遷移をアニメーション化する

PageRouteBuilder クラスは、遷移アニメーションをカスタマイズできる Route です。これにより、transitionBuilder コールバックをオーバーライドできます。このコールバックは、ナビゲータによって実行される受信アニメーションと送信アニメーションを表す 2 つの Animation オブジェクトを提供します。

遷移アニメーションをカスタマイズするには、MaterialPageRoutePageRouteBuilder に置き換えます。ユーザーが HomeScreen から QuestionScreen に移動する際の遷移アニメーションをカスタマイズするには、FadeTransition(明示的にアニメーション化されたウィジェット)を使用して、新しい画面を前の画面の上にフェードインします。

lib/home_screen.dart

ElevatedButton(
  onPressed: () {
    // Show the question screen to start the game
    Navigator.push(
      context,
      PageRouteBuilder(                                         // NEW
        pageBuilder: (context, animation, secondaryAnimation) { // NEW
          return QuestionScreen();                              // NEW
        },                                                      // NEW
        transitionsBuilder:                                     // NEW
            (context, animation, secondaryAnimation, child) {   // NEW
          return FadeTransition(                                // NEW
            opacity: animation,                                 // NEW
            child: child,                                       // NEW
          );                                                    // NEW
        },                                                      // NEW
      ),                                                        // NEW
    );
  },
  child: Text('New Game'),
),

アニメーション パッケージには、FadeThroughTransition などの魅力的なビルド済みのアニメーション効果が用意されています。アニメーション パッケージをインポートし、FadeTransition を FadeThroughTransition ウィジェットに置き換えます。

lib/home_screen.dart

import 'package;animations/animations.dart';

ElevatedButton(
  onPressed: () {
    // Show the question screen to start the game
    Navigator.push(
      context,
      PageRouteBuilder(
        pageBuilder: (context, animation, secondaryAnimation) {
          return const QuestionScreen();
        },
        transitionsBuilder:
            (context, animation, secondaryAnimation, child) {
          return FadeThroughTransition(                          // NEW
            animation: animation,                                // NEW
            secondaryAnimation: secondaryAnimation,              // NEW
            child: child,                                        // NEW
          );                                                     // NEW
        },
      ),
    );
  },
  child: Text('New Game'),
),

予測型「戻る」アニメーションをカスタマイズする

1c0558ffa3b76439.gif

予測型「戻る」は、現在のルートやアプリの背後を覗き込んで、ナビゲートする前に背後にあるものを確認できる Android の新しい機能です。ピーク アニメーションは、ユーザーが画面をドラッグして戻す際の指の位置によって決まります。

Flutter は、Flutter のナビゲーション スタックにポップするルートがないとき(つまり、戻る操作でアプリが終了する場合)に、システムレベルでこの機能を有効にして、システムの予測型「戻る」をサポートします。このアニメーションは、Flutter 自体ではなく、システムによって処理されます。

Flutter は、Flutter アプリ内のルートを移動する際の予測型「戻る」もサポートしています。PredictiveBackPageTransitionsBuilder という特別な PageTransitionsBuilder が、システムの予測型「戻る」ジェスチャーをリッスンし、ジェスチャーの進行状況に応じてページ遷移を開始します。

予測型「戻る」は Android U 以降でのみサポートされていますが、Flutter は元の「戻る」ジェスチャーの動作と ZoomPageTransitionBuilder に正常にフォールバックします。詳しくは、ブログ投稿をご覧ください。独自のアプリで設定する方法に関するセクションも掲載されています。

アプリの ThemeData 構成で、Android では PredictiveBack を使用し、他のプラットフォームではアニメーション パッケージのフェード スルー遷移エフェクトを使用するように PageTransitionsTheme を構成します。

lib/main.dart

import 'package:animations/animations.dart';                                 // NEW
import 'package:flutter/material.dart';

import 'home_screen.dart';

void main() {
  runApp(MainApp());
}

class MainApp extends StatelessWidget {
  const MainApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue),
        useMaterial3: true,
        pageTransitionsTheme: PageTransitionsTheme(
          builders: {
            TargetPlatform.android: PredictiveBackPageTransitionsBuilder(),  // NEW
            TargetPlatform.iOS: FadeThroughPageTransitionsBuilder(),         // NEW
            TargetPlatform.macOS: FadeThroughPageTransitionsBuilder(),       // NEW
            TargetPlatform.windows: FadeThroughPageTransitionsBuilder(),     // NEW
            TargetPlatform.linux: FadeThroughPageTransitionsBuilder(),       // NEW
          },
        ),
      ),
      home: HomeScreen(),
    );
  }
}

これで、Navigator.push() のコールバックを MaterialPageRoute に変更できます。

lib/home_screen.dart

ElevatedButton(
  onPressed: () {
    // Show the question screen to start the game
    Navigator.push(
      context,
      MaterialPageRoute(builder: (context) {       // NEW
        return const QuestionScreen();             // NEW
      }),                                          // NEW
    );
  },
  child: Text('New Game'),
),

FadeThroughTransition を使用して現在の質問を変更する

AnimatedSwitcher ウィジェットは、ビルダー コールバックで 1 つのアニメーションのみを提供します。この問題に対処するため、animations パッケージには PageTransitionSwitcher が用意されています。

lib/question_screen.dart

class QuestionCard extends StatelessWidget {
  final String? question;

  const QuestionCard({
    required this.question,
    super.key,
  });

  @override
  Widget build(BuildContext context) {
    return PageTransitionSwitcher(                                          // NEW
      layoutBuilder: (entries) {                                            // NEW
        return Stack(                                                       // NEW
          alignment: Alignment.topCenter,                                   // NEW
          children: entries,                                                // NEW
        );                                                                  // NEW
      },                                                                    // NEW
      transitionBuilder: (child, animation, secondaryAnimation) {           // NEW
        return FadeThroughTransition(                                       // NEW
          animation: animation,                                             // NEW
          secondaryAnimation: secondaryAnimation,                           // NEW
          child: child,                                                     // NEW
        );                                                                  // NEW
      },                                                                    // NEW
      duration: const Duration(milliseconds: 300),
      child: Card(
        key: ValueKey(question),
        elevation: 4,
        child: Padding(
          padding: const EdgeInsets.all(16.0),
          child: Text(
            question ?? '',
            style: Theme.of(context).textTheme.displaySmall,
          ),
        ),
      ),
    );
  }
}

OpenContainer を使用する

77358e5776eb104c.png

animations パッケージの OpenContainer ウィジェットは、2 つのウィジェットを視覚的に接続するために展開するコンテナ変換アニメーション効果を提供します。

最初に closedBuilder によって返されたウィジェットが表示され、コンテナがタップされたとき、または openContainer コールバックが呼び出されたときに、openBuilder によって返されたウィジェットに拡大されます。

openContainer コールバックをビューモデルに接続するには、新しいパスを追加して viewModel を QuestionCard ウィジェットに渡し、[ゲームオーバー] 画面の表示に使用するコールバックを保存します。

lib/question_screen.dart

class QuestionScreen extends StatefulWidget {
  const QuestionScreen({super.key});

  @override
  State<QuestionScreen> createState() => _QuestionScreenState();
}

class _QuestionScreenState extends State<QuestionScreen> {
  late final QuizViewModel viewModel =
      QuizViewModel(onGameOver: _handleGameOver);
  VoidCallback? _showGameOverScreen;                                    // NEW

  @override
  Widget build(BuildContext context) {
    return ListenableBuilder(
      listenable: viewModel,
      builder: (context, child) {
        return Scaffold(
          appBar: AppBar(
            actions: [
              TextButton(
                onPressed:
                    viewModel.hasNextQuestion && viewModel.didAnswerQuestion
                        ? () {
                            viewModel.getNextQuestion();
                          }
                        : null,
                child: const Text('Next'),
              )
            ],
          ),
          body: Center(
            child: Column(
              children: [
                QuestionCard(                                           // NEW
                  onChangeOpenContainer: _handleChangeOpenContainer,    // NEW
                  question: viewModel.currentQuestion?.question,        // NEW
                  viewModel: viewModel,                                 // NEW
                ),                                                      // NEW
                Spacer(),
                AnswerCards(
                  onTapped: (index) {
                    viewModel.checkAnswer(index);
                  },
                  answers: viewModel.currentQuestion?.possibleAnswers ?? [],
                  correctAnswer: viewModel.didAnswerQuestion
                      ? viewModel.currentQuestion?.correctAnswer
                      : null,
                ),
                StatusBar(viewModel: viewModel),
              ],
            ),
          ),
        );
      },
    );
  }

  void _handleChangeOpenContainer(VoidCallback openContainer) {        // NEW
    _showGameOverScreen = openContainer;                               // NEW
  }                                                                    // NEW

  void _handleGameOver() {                                             // NEW
    if (_showGameOverScreen != null) {                                 // NEW
      _showGameOverScreen!();                                          // NEW
    }                                                                  // NEW
  }                                                                    // NEW
}

新しいウィジェット GameOverScreen を追加します。

lib/question_screen.dart

class GameOverScreen extends StatelessWidget {
  final QuizViewModel viewModel;
  const GameOverScreen({required this.viewModel, super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        automaticallyImplyLeading: false,
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Scoreboard(
              score: viewModel.score,
              totalQuestions: viewModel.totalQuestions,
            ),
            Text(
              'You Win!',
              style: Theme.of(context).textTheme.displayLarge,
            ),
            Text(
              'Score: ${viewModel.score} / ${viewModel.totalQuestions}',
              style: Theme.of(context).textTheme.displaySmall,
            ),
            ElevatedButton(
              child: Text('OK'),
              onPressed: () {
                Navigator.popUntil(context, (route) => route.isFirst);
              },
            ),
          ],
        ),
      ),
    );
  }
}

QuestionCard ウィジェットで、Card をアニメーション パッケージの OpenContainer ウィジェットに置き換え、viewModel とオープン コンテナ コールバックの 2 つの新しいフィールドを追加します。

lib/question_screen.dart

class QuestionCard extends StatelessWidget {
  final String? question;

  const QuestionCard({
    required this.onChangeOpenContainer,
    required this.question,
    required this.viewModel,
    super.key,
  });

  final ValueChanged<VoidCallback> onChangeOpenContainer;
  final QuizViewModel viewModel;

  static const _backgroundColor = Color(0xfff2f3fa);

  @override
  Widget build(BuildContext context) {
    return PageTransitionSwitcher(
      duration: const Duration(milliseconds: 200),
      transitionBuilder: (child, animation, secondaryAnimation) {
        return FadeThroughTransition(
          animation: animation,
          secondaryAnimation: secondaryAnimation,
          child: child,
        );
      },
      child: OpenContainer(                                         // NEW
        key: ValueKey(question),                                    // NEW
        tappable: false,                                            // NEW
        closedColor: _backgroundColor,                              // NEW
        closedShape: const RoundedRectangleBorder(                  // NEW
          borderRadius: BorderRadius.all(Radius.circular(12.0)),    // NEW
        ),                                                          // NEW
        closedElevation: 4,                                         // NEW
        closedBuilder: (context, openContainer) {                   // NEW
          onChangeOpenContainer(openContainer);                     // NEW
          return ColoredBox(                                        // NEW
            color: _backgroundColor,                                // NEW
            child: Padding(                                         // NEW
              padding: const EdgeInsets.all(16.0),                  // NEW
              child: Text(
                question ?? '',
                style: Theme.of(context).textTheme.displaySmall,
              ),
            ),
          );
        },
        openBuilder: (context, closeContainer) {                    // NEW
          return GameOverScreen(viewModel: viewModel);              // NEW
        },                                                          // NEW
      ),
    );
  }
}

4120f9395857d218.gif

8. 完了

これで、Flutter アプリにアニメーション効果を追加し、Flutter のアニメーション システムのコア コンポーネントについて学習しました。具体的には、以下の内容について学びました。

  • ImplicitlyAnimatedWidget を使用する方法
  • ExplicitlyAnimatedWidget の使用方法
  • アニメーションにカーブとトゥイーンを適用する方法
  • AnimatedSwitcher や PageRouteBuilder などの事前構築済みの遷移ウィジェットの使用方法
  • animations パッケージの FadeThroughTransition や OpenContainer などの、事前ビルドされた凝ったアニメーション効果を使用する方法
  • Android の予測型「戻る」のサポートを追加するなど、デフォルトの遷移アニメーションをカスタマイズする方法。

3026390ad413769c.gif

次のステップ

以下の Codelab をご覧ください。

または、さまざまなアニメーション手法を紹介するアニメーション サンプルアプリをダウンロードしてください。

参考資料

アニメーションに関するその他のリソースについては、flutter.dev をご覧ください。

Medium の記事もご覧ください。

リファレンス ドキュメント