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(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 會顯示,而 scale 是由 AnimatedScale 小工具顯示。

84aec4776e70b870.gif

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

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

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

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

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

選取 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 的新欄位,並將其做為參數傳遞至 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。也就是說,您可以使用 TweenAnimatedSwitcher 提供的 Animation 轉換為 Animation,再提供給 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,然後將其從 0.0 到 1.0 的 Tween 轉換為在 x 軸上從 -0.1 到 0.0 轉換的 Tween

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

使用明確動畫的另一個優點是,這些動畫可以組合在一起。新增另一個明確的動畫 FadeTransition,透過包裝 SlideTransition 小工具,使用相同的弧形動畫。

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.topCenter 而非 Alignment.center

摘要

  • 明確動畫是指採用 Animation 物件的動畫效果 (與採用目標 valuedurationImplicitlyAnimatedWidgets 相反)
  • Animation 類別代表執行中的動畫,但不會定義特定效果。
  • 使用 Tween().animateAnimation.drive(),將 TweensCurves (使用 CurveTween) 套用至動畫。
  • 請使用 AnimatedSwitcherlayoutBuilder 參數調整其子項的版面配置方式。

6. 控制動畫狀態

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

使用 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 小工具中。請務必為 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 新增延遲

在本節中,您將為 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 中建立新的 Animation,使用 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>([              // 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,
  );
}

現在熱重載應用程式,並觀察卡片逐一翻轉。您可以嘗試挑戰,變更 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(                                         // 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。匯入動畫套件,並將 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(                     // Add from here...
                animation: animation,
                secondaryAnimation: secondaryAnimation,
                child: child,
              );                                                // To here.
            },
      ),
    );
  },
  child: Text('New Game'),
),

自訂預測返回動畫

1c0558ffa3b76439.gif

預測返回是 Android 的新功能,可讓使用者在前往下一個路徑或應用程式前,先查看目前路徑或應用程式後方有哪些內容。當使用者將手指從螢幕上滑回時,系統會根據手指的位置顯示預覽動畫。

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

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

如要將 openContainer 回呼連結至檢視模型,請將新的 viewModel 新增至 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 替換為 animations 套件中的 OpenContainer 小工具,為 viewModel 和開啟容器回呼新增兩個欄位:

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

後續步驟

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

或者,您也可以下載動畫範例應用程式,瞭解各種動畫技巧。

延伸閱讀

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

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

參考文件