Flutter 中的動畫

1. 簡介

動畫是改善應用程式使用者體驗、向使用者傳達重要資訊,以及讓應用程式更精緻、使用起來更愉快的好方法。

Flutter 動畫架構簡介

Flutter 會在每個影格上重建小工具樹的一部分,藉此顯示動畫效果。它提供預先建構的動畫效果和其他 API,可簡化動畫的建立和組合作業。

  • 隱含動畫是預先建構的動畫特效,可自動執行整個動畫。當動畫的目標值變更時,動畫會從目前值執行至目標值,並在兩者之間顯示每個值,讓小工具的動畫流暢播放。隱含動畫的範例包括 AnimatedSizeAnimatedScaleAnimatedPositioned
  • 明確動畫也是預先建構的動畫效果,但需要 Animation 物件才能運作。例如 SizeTransitionScaleTransitionPositionedTransition
  • Animation 是代表執行中或已停止的動畫的類別,由狀態組成,前者代表動畫執行的目標值,後者則代表動畫在任何特定時間點在螢幕上顯示的目前值。這是 Listenable 的子類別,會在動畫執行期間狀態變更時通知其監聽器。
  • AnimationController 是一種建立動畫並控制其狀態的方式。其方法 (例如 forward()reset()stop()repeat()) 可用於控制動畫,而不需要定義要顯示的動畫效果,例如縮放、大小或位置。
  • Tweens 可用於內插起始值和結束值之間的值,且可代表任何類型,例如雙精度、OffsetColor
  • 曲線可用於調整參數隨時間變化的速率。動畫執行時,通常會套用緩和曲線,讓動畫開始或結束時的變化速率變快或變慢。曲線會接收介於 0.0 和 1.0 之間的輸入值,並傳回介於 0.0 和 1.0 之間的輸出值。

建構項目

在本程式碼研究室中,您將建構一個多項選擇測驗遊戲,其中包含各種動畫效果和技巧。

3026390ad413769c.gif

您將瞭解如何...

  • 建構可設定大小和顏色動畫的小工具
  • 建構 3D 資訊卡翻轉效果
  • 使用動畫套件中的精美預先建構動畫效果
  • 新增最新版 Android 的預測返回手勢支援功能

課程內容

在本程式碼研究室中,您將學到:

  • 如何使用隱含動畫效果,在不需編寫大量程式碼的情況下,製作出精美的動畫。
  • 如何使用明確的動畫效果,透過預先建立的動畫小工具 (例如 AnimatedSwitcherAnimationController) 設定自己的效果。
  • 如何使用 AnimationController 定義可顯示 3D 效果的小工具。
  • 如何使用 animations 套件,在設定最少的情況下顯示精美的動畫效果。

軟硬體需求

  • Flutter SDK
  • IDE,例如 VSCode 或 Android Studio / IntelliJ

2. 設定 Flutter 開發環境

您需要兩個軟體才能完成這個實驗室活動,分別是 Flutter SDK編輯器

您可以使用下列任一裝置執行程式碼研究室:

  • 實體 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) 設計模式。QuestionScreen (檢視畫面) 會使用 QuizViewModel (檢視畫面模型) 類別,向使用者詢問 QuestionBank (模型) 類別中的多項選擇題。

  • home_screen.dart:顯示含有「New Game」按鈕的畫面
  • main.dart:設定 MaterialApp 以使用 Material 3 並顯示主畫面
  • 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 會顯示,而 scale 是由 AnimatedScale 小工具顯示。

84aec4776e70b870.gif

使用 Tween 在兩個值之間進行內插

請注意,isActive 欄位變更為 true 後,AnimatedStar 小工具的顏色會立即變更。

如要呈現動畫顏色效果,您可以嘗試使用 AnimatedContainer 小工具 (這是 ImplicitlyAnimatedWidget 的另一個子類別),因為它可以自動為所有屬性 (包括顏色) 設定動畫效果。很抱歉,我們的小工具必須顯示圖示,而非容器。

您也可以試試 AnimatedIcon,這個元素會在圖示形狀之間實作轉場效果。不過,AnimatedIcons 類別中並未提供星號圖示的預設實作方式。

我們會改用另一個 ImplicitlyAnimatedWidget 子類別,稱為 TweenAnimationBuilder,這個子類別會將 Tween 做為參數。轉場是一種類別,會採用兩個值 (beginend) 並計算中間值,以便動畫顯示這些值。在本例中,我們會使用 ColorTween,這個類別可滿足建構動畫效果所需的 Tween<Color> 介面。

選取 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 參數的值而變更。這是因為 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 的新欄位,並將其做為參數傳遞至 AnimatedScaleTweenAnimationBuilder 小工具。

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

使用開發人員工具啟用慢速動畫

如要偵錯任何動畫效果,Flutter 開發人員工具提供了一種方法,可讓您將應用程式中的所有動畫放慢,以便更清楚地查看動畫。

如要開啟 DevTools,請確認應用程式處於偵錯模式,然後在 VSCode 的「Debug」工具列中選取「Widget Inspector」,或在 IntelliJ / Android Studio 的「Debug」工具視窗中選取「Open Flutter DevTools」按鈕。

3ce33dc01d096b14.png

363ae0fbcd0c2395.png

開啟小工具檢查器後,按一下工具列中的「Slow animations」按鈕。

adea0a16d01127ad.png

5. 使用明確的動畫效果

與隱含動畫一樣,明確動畫也是預先建構的動畫效果,但它們不會採用目標值,而是採用 Animation 物件做為參數。因此,如果動畫已由導覽轉場 AnimatedSwitcherAnimationController 定義,這類函式就非常實用。

使用明確的動畫特效

如要開始使用明確的動畫效果,請使用 AnimatedSwitcher 包裝 Card 小工具。

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 物件。這正是使用明確動畫的絕佳時機。

在本程式碼研究室中,我們將使用第一個明確的動畫 SlideTransition,該動畫會使用 Animation<Offset> 定義輸入和輸出小工具之間的開始和結束偏移量。

轉場效果有輔助函式 animate(),可將任何 Animation 轉換為套用轉場效果的另一個 Animation。也就是說,您可以使用 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.animateCurve 套用至 Animation,然後將其從 Tween<double> (範圍為 0.0 到 1.0) 轉換為 Tween<Offset> (在 x 軸上從 -0.1 轉換至 0.0)。

或者,Animation 類別也有 drive() 函式,可接收任何 Tween (或 Animatable),並將其轉換為新的 Animation。這樣就能讓轉場效果「連結」,讓產生的程式碼更精簡:

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);
},

使用明確動畫的另一個優點是,您可以輕鬆將這些動畫組合在一起。新增另一個明確的動畫:透過包裝 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
  },

自訂版面配置建構工具

您可能會發現 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.topCenter 而非 Alignment.center

摘要

  • 明確動畫是採用 Animation 物件的動畫效果 (與 ImplicitlyAnimatedWidget 相反,後者會採用目標值和時間長度)
  • Animation 類別代表執行中的動畫,但不會定義特定效果。
  • 使用 Tween().animate 或 Animation.drive(),將 Tween 和曲線 (使用 CurveTween) 套用至動畫。
  • 使用 AnimatedSwitcher 的 layoutBuilder 參數,調整子項的版面配置方式。

6. 控制動畫狀態

到目前為止,每個動畫都會由架構自動執行。隱含動畫會自動執行,而明確的動畫效果則需要動畫才能正常運作。在本節中,您將瞭解如何使用 AnimationController 建立 Animation 物件,並使用 TweenSequence 將 Tween 組合在一起。

使用 AnimationController 執行動畫

如要使用 AnimationController 建立動畫,請按照下列步驟操作:

  1. 建立 StatefulWidget
  2. 在 State 類別中使用 SingleTickerProviderStateMixin 混合元件,為 AnimationController 提供 Ticker
  3. 在 initState 生命週期方法中初始化 AnimationController,為 vsync (TickerProvider) 參數提供目前的 State 物件。
  4. 請確認 AnimationController 通知其監聽器時,小工具會重新建構,方法是使用 AnimatedBuilder 或手動呼叫 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 小工具中。請務必為資訊卡小工具提供 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 類別,以便實作您自己的版本,通常是個不錯的做法。不過,由於這個類別需要在其狀態中儲存先前的 Widget,因此必須使用 StatefulWidget。如要進一步瞭解如何建立自己的明確動畫效果,請參閱 AnimatedWidget 的 API 說明文件。

使用 TweenSequence 新增延遲時間

在本節中,您將為 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();
}

然後將 delayAmount 新增至 AnswerCards 建構方法。

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 套用延遲時間。請注意,這並使用 dart:async 程式庫中的任何公用程式,例如 Future.delayed。這是因為延遲時間是動畫的一部分,並非小工具使用 AnimationController 時明確控制的項目。這樣一來,在開發人員工具中啟用慢速動畫時,動畫效果就會使用相同的 TickerProvider,因此更容易進行偵錯。

如要使用 TweenSequence,請建立兩個 TweenSequenceItem,其中一個包含 ConstantTween,可讓動畫在相對時間長度內保持 0,另一個則是從 0.01.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>([   // 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
  }

最後,在建構方法中將 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,
  );
}

現在請熱重載應用程式,並觀察卡片逐一翻轉。您可以嘗試挑戰變更 Transform 小工具提供的 3D 效果視角。

28b5291de9b3f55f.gif

7. 使用自訂導覽轉場效果

到目前為止,我們已瞭解如何在單一畫面上自訂效果,但動畫的另一種用途是用於在不同畫面之間轉場。在本節中,您將瞭解如何使用內建動畫效果和 pub.dev 上的官方動畫套件提供的精美預先建構動畫效果,將動畫效果套用至畫面轉場效果。

為導覽轉場加上動畫效果

PageRouteBuilder 類別是 Route,可讓您自訂轉場動畫。您可以覆寫其 transitionBuilder 回呼,該回呼會提供兩個 Animation 物件,代表 Navigator 執行的傳入和傳出動畫。

如要自訂轉場動畫,請將 MaterialPageRoute 替換為 PageRouteBuilder,並在使用者從 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 的新功能,可讓使用者在前往下一個路徑或應用程式前,先查看目前路徑或應用程式後方有哪些內容。Peek 動畫會根據使用者在螢幕上拖曳手指的位置而運作。

當 Flutter 沒有在導覽堆疊中彈出路線 (換句話說,返回會讓應用程式離開) 時,Flutter 會在系統層級啟用這項功能,以支援系統預測返回功能。這項動畫是由系統處理,而非由 Flutter 本身處理。

在 Flutter 應用程式中瀏覽路徑時,Flutter 也支援預測返回功能。名為 PredictiveBackPageTransitionsBuilder 的特殊 PageTransitionsBuilder 會監聽系統預測返回手勢,並根據手勢的進度驅動其頁面轉場。

預測返回功能僅適用於 Android U 以上版本,但 Flutter 會順利回復原始返回手勢行為和 ZoomPageTransitionBuilder。詳情請參閱我們的網誌文章,其中的專區說明如何在您自己的應用程式中設定這項功能。

在應用程式的 ThemeData 設定中,將 PageTransitionsTheme 設為在 Android 上使用 PredictiveBack,並在其他平台上使用動畫套件中的淡出/淡入轉場效果:

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 小工具只會在建構函式回呼中提供一個動畫。為解決這個問題,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 小工具提供容器轉換動畫效果,可在兩個小工具之間建立視覺連結。

系統會一開始顯示 closedBuilder 傳回的小工具,然後在輕觸容器或呼叫 openContainer 回呼時,將其展開為 openBuilder 傳回的小工具。

如要將 openContainer 回呼連結至檢視畫面模型,請將新的傳遞檢視畫面模型新增至 QuestionCard 小工具,並儲存用於顯示「Game Over」畫面的回呼:

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 和 OpenContainer 回呼新增兩個新欄位:

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

後續步驟

請查看以下程式碼研究室:

或者下載動畫範例應用程式,瞭解各種動畫技巧

其他資訊

您可以在 flutter.dev 上找到更多動畫資源:

或者,請參閱 Medium 上的以下文章:

參考文件