Flutter のアニメーション

1. はじめに

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

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

Flutter は、フレームごとにウィジェット ツリーの一部を再構築することで、アニメーション効果を表示します。アニメーション効果やその他の API が組み込まれており、アニメーションの作成と構成を容易に行うことができます。

  • 暗黙的なアニメーションは、アニメーション全体を自動的に実行する事前構築済みのアニメーション効果です。アニメーションのターゲット値が変更されると、現在値からターゲット値までアニメーションを実行し、その間の各値を表示して、ウィジェットがスムーズにアニメーション化されるようにします。暗黙的なアニメーションの例としては、AnimatedSizeAnimatedScaleAnimatedPositioned などがあります。
  • 明示的アニメーションも事前構築されたアニメーション効果ですが、動作させるには Animation オブジェクトが必要です。たとえば、SizeTransitionScaleTransitionPositionedTransition などがあります。
  • Animation は、実行中または停止中のアニメーションを表すクラスで、アニメーションが実行されるターゲット値を表すと、アニメーションが特定の時点で画面に表示する現在の値を表すステータスで構成されます。Listenable のサブクラスであり、アニメーションの実行中にステータスが変化するとリスナーに通知します。
  • AnimationController は、アニメーションを作成してその状態を制御する方法です。forward()reset()stop()repeat() などのメソッドを使用すると、表示されるアニメーション効果(スケール、サイズ、位置など)を定義しなくてもアニメーションを制御できます。
  • トゥイーンは、開始値と終了値の間の値を補間するために使用され、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 が正しく構成され、上記のターゲット プラットフォームのいずれかがインストールされていることを確認するには、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 - Material 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
            ),
          ],
        ),
      ),
    );
  }
}

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

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

AnimatedScale ウィジェットを使用して、星がアクティブになったときに scale の金額を 0.5 から 1.0 に変更する AnimatedStar という新しいウィジェットを作成します。

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(isActive: score > i),                 // Edit this line.
        ],
      ),
    );
  }
}

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 クラスには星アイコンのデフォルト実装はありません。

代わりに、Tween をパラメータとして受け取る TweenAnimationBuilder という ImplicitlyAnimatedWidget の別のサブクラスを使用します。トゥイーンは、2 つの値(beginend)を取得して中間値を計算し、アニメーションで表示できるようにするクラスです。この例では、アニメーション効果の作成に必要な Tween インターフェースを満たす 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);     // And modify this line.
        },
      ),
    );
  }
}

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

8b0911f4af299a60.gif

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

カーブを適用する

どちらのアニメーション効果も一定の速度で実行されますが、アニメーションは速度が変化する方が視覚的に面白く、情報量も多くなることがよくあります。

Curve は、パラメータの時間経過に伴う変化率を定義するイージング関数を適用します。Flutter には、Curves クラスに easeIneaseOut などの事前構築済みのイージング曲線が用意されています。

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 の [Debug toolbar] で [Widget Inspector] を選択するか、IntelliJ / Android Studio の [Debug tool window] で [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> を受け取ります。

トゥイーンには、任意の Animation をトゥイーンが適用された別の Animation に変換するヘルパー関数 animate() があります。つまり、Tween を使用して、AnimatedSwitcher によって提供される AnimationAnimation に変換し、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 から x 軸上で -0.1 ~ 0.0 に移行する Tween に変換します。

また、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 オブジェクトを受け取るアニメーション効果です(ターゲット valueduration を受け取る ImplicitlyAnimatedWidgets とは対照的です)。
  • Animation クラスは実行中のアニメーションを表しますが、特定のエフェクトは定義しません。
  • Tween().animate または Animation.drive() を使用して、TweensCurvesCurveTween を使用)をアニメーションに適用します。
  • AnimatedSwitcherlayoutBuilder パラメータを使用して、子のレイアウト方法を調整します。

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

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

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

AnimationController を使用してアニメーションを作成するには、次の手順を行います。

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

新しいファイル 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 ウィジェットでラップします。Card ウィジェットに 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 を使用して遅延を追加する

このセクションでは、各カードが 1 つずつ反転するように、CardFlipEffect ウィジェットに遅延を追加します。まず、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 を使用して遅延を適用する新しい Animation を作成します。このコードでは、Future.delayed など、dart:async ライブラリのユーティリティは使用されていません。これは、遅延がアニメーションの一部であり、ウィジェットが AnimationController を使用するときに明示的に制御するものではないためです。同じ TickerProvider を使用するため、DevTools でアニメーションを遅くすると、アニメーション効果のデバッグが容易になります。

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

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>([              // Add from here...
      if (widget.delayAmount > 0)
        TweenSequenceItem(
          tween: ConstantTween<double>(0.0),
          weight: widget.delayAmount,
        ),
      TweenSequenceItem(tween: Tween(begin: 0.0, end: 1.0), weight: 1.0),
    ]).animate(_animationController);                          // To here.
  }

最後に、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 つの使用方法は、画面間の遷移に使用することです。このセクションでは、組み込みのアニメーション効果と、pub.dev の公式 animations パッケージで提供される高度な組み込みアニメーション効果を使用して、画面遷移にアニメーション効果を適用する方法を学びます。

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

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

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

lib/home_screen.dart

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

アニメーション パッケージは、FadeThroughTransition などの凝った組み込み済みアニメーション効果を提供します。アニメーション パッケージをインポートし、FadeTransitionFadeThroughTransition ウィジェットに置き換えます。

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(                     // Add from here...
                animation: animation,
                secondaryAnimation: secondaryAnimation,
                child: child,
              );                                                // To here.
            },
      ),
    );
  },
  child: Text('New Game'),
),

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

1c0558ffa3b76439.gif

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

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),
        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(                                        // Add from here...
        builder: (context) {
          return const QuestionScreen();
        },
      ),                                                        // To here.
    );
  },
  child: Text('New Game'),
),

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

AnimatedSwitcher ウィジェットは、ビルダー コールバックで 1 つの Animation のみを提供します。これに対処するため、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(                              // Add from here...
      layoutBuilder: (entries) {
        return Stack(alignment: Alignment.topCenter, children: entries);
      },
      transitionBuilder: (child, animation, secondaryAnimation) {
        return FadeThroughTransition(
          animation: animation,
          secondaryAnimation: secondaryAnimation,
          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,
          ),
        ),
      ),
    );
  }
}

OpenContainer を使用する

77358e5776eb104c.png

animations パッケージの OpenContainer ウィジェットは、2 つのウィジェット間に視覚的なつながりを作成するために拡大するコンテナ変換アニメーション効果を提供します。

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

openContainer コールバックをビューモデルに接続するには、新しいパスを追加して viewModelQuestionCard ウィジェットに渡し、「ゲームオーバー」画面を表示するために使用されるコールバックを保存します。

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 ウィジェットで、Cardanimations パッケージの 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 の使用方法
  • アニメーションに CurvesTweens を適用する方法
  • AnimatedSwitcherPageRouteBuilder などのビルド済みのトランジション ウィジェットの使用方法
  • animations パッケージの FadeThroughTransitionOpenContainer などの、構築済みの凝ったアニメーション効果の使用方法
  • デフォルトの遷移アニメーションをカスタマイズする方法(Android での予測型「戻る」のサポートの追加を含む)。

3026390ad413769c.gif

次のステップ

次の Codelab をご覧ください。

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

関連情報

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

または、Medium の以下の記事をご覧ください。

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