Flutter 中的動畫

1. 簡介

動畫是提升應用程式使用者體驗的絕佳方式,可向使用者傳達重要資訊,並讓應用程式更精緻,使用起來也更令人愉悅。

Flutter 動畫架構總覽

Flutter 會在每個影格重建部分小工具樹狀結構,顯示動畫效果。它提供預先建構的動畫效果和其他 API,可讓您更輕鬆地建立及組合動畫。

  • 隱含動畫是預先建構的動畫效果,可自動執行整個動畫。當動畫的目標值變更時,動畫會從目前值執行至目標值,並顯示中間的每個值,讓小工具順暢地呈現動畫效果。隱性動畫的範例包括 AnimatedSize、AnimatedScale 和 AnimatedPositioned。
  • 明確動畫也是預先建構的動畫效果,但需要 Animation 物件才能運作。例如:SizeTransition、ScaleTransition 或 PositionedTransition。
  • 動畫是代表執行中或已停止動畫的類別,由代表動畫執行目標值的值,以及代表動畫在任何指定時間顯示於螢幕上的目前值的狀態組成。這是 Listenable 的子類別,會在動畫執行期間狀態變更時通知監聽器。
  • AnimationController 可用來建立動畫及控制動畫狀態。您可以使用 forward()、reset()、stop() 和 repeat() 等方法控制動畫,不必定義顯示的動畫效果,例如縮放、大小或位置。
  • 補間動畫用於內插開始值和結束值之間的數值,且可代表任何型別,例如 double、Offset 或 Color。
  • 曲線可用於調整參數隨時間變化的速率。執行動畫時,通常會套用緩和曲線,在動畫開始或結束時加快或減慢變化速率。曲線會接受介於 0.0 至 1.0 之間的輸入值,並傳回介於 0.0 至 1.0 之間的輸出值。

建構項目

在本程式碼研究室中,您將建構選擇題測驗遊戲,並加入各種動畫效果和技巧。

3026390ad413769c.gif

您將瞭解如何...

  • 建立可產生大小和顏色動畫效果的小工具
  • 建立 3D 卡片翻轉效果
  • 使用動畫套件中預先建立的精美動畫效果
  • 新增對最新版 Android 提供的預測返回手勢支援

課程內容

在本程式碼研究室中,您將瞭解:

  • 如何使用隱含動畫效果,以少量程式碼製作美觀的動畫。
  • 如何使用明確動畫效果,透過 AnimatedSwitcher 或 AnimationController 等預先建構的動畫小工具設定自己的效果。
  • 如何使用 AnimationController 定義自己的小工具,顯示 3D 效果。
  • 如何使用 animations 套件,以最少的設定顯示精美動畫效果。

軟硬體需求

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

2. 設定 Flutter 開發環境

如要完成本實驗室,您需要兩項軟體:Flutter SDK 和編輯器。

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

  • 連線至電腦並設為開發人員模式的實體 Android (建議使用,以便在步驟 7 中導入預測返回手勢) 或 iOS 裝置。
  • iOS 模擬器 (需要安裝 Xcode 工具)。
  • Android Emulator (需在 Android Studio 中設定)。
  • 瀏覽器 (偵錯時必須使用 Chrome)。
  • Windows、Linux 或 macOS 桌上型電腦。您必須在要部署的平台上進行開發。因此,如要開發 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 指令並指定目標裝置,例如 android、ios 或 chrome。如需支援的平台完整清單,請參閱「支援的平台」頁面。

flutter run -d android

您也可以使用偏好的 IDE 執行應用程式並進行偵錯。詳情請參閱官方 Flutter 說明文件。

瀏覽程式碼

這個入門應用程式是選擇題測驗遊戲,由兩個畫面組成,並遵循模型-檢視畫面-檢視模型 (MVVM) 設計模式。QuestionScreen (View) 會使用 QuizViewModel (View-Model) 類別,向使用者詢問 QuestionBank (Model) 類別中的多項選擇題。

  • 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 小工具會使用隱含動畫更新大小。這裡不會為 Icon's color 加上動畫效果,只會為 scale 加上動畫效果,這是由 AnimatedScale 小工具完成。

84aec4776e70b870.gif

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

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

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

您也可以嘗試 AnimatedIcon,這個函式會在圖示形狀之間實作轉場效果。不過,AnimatedIcons 類別中沒有星號圖示的預設實作方式。

我們將改用 ImplicitlyAnimatedWidget 的另一個子類別 (名為 TweenAnimationBuilder),並將 Tween 做為參數。Tween 類別會採用兩個值 (begin 和 end) 並計算中間值,以便動畫顯示這些值。在本例中,我們會使用 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

請注意,ColorTween 的 end 值會根據 isActive 參數的值而變更。這是因為每當 Tween.end 值有所變更,TweenAnimationBuilder 就會重新執行動畫。發生這種情況時,新動畫會從目前的動畫值執行至新的結束值,因此您可以隨時變更顏色 (即使動畫正在執行),並顯示具有正確中間值的流暢動畫效果。

套用曲線

這兩種動畫效果的速率都保持不變,但動畫加速或減速時,通常會更吸引人且提供更多資訊。

Curve 會套用加/減速函式,定義參數隨時間變化的速率。Flutter 隨附 Curves 類別中預先建構的加/減速曲線集合,例如 easeIn 或 easeOut。

5dabe68d1210b8a1.gif3a9e7490c594279a.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

熱重載應用程式,即可看到套用至 AnimatedSize 和 TweenAnimationBuilder 的曲線。

206dd8d9c1fae95.gif

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

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

如要開啟開發人員工具,請確認應用程式是以偵錯模式執行,然後在 VSCode 的「Debug toolbar」中選取「Widget Inspector」,或在 IntelliJ / Android Studio 的「Debug tool window」中選取「Open Flutter DevTools」按鈕。

3ce33dc01d096b14.png363ae0fbcd0c2395.png

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

adea0a16d01127ad.png

5. 使用明確的動畫效果

與隱含動畫類似,明確動畫也是預先建構的動畫效果,但明確動畫會將 Animation 物件做為參數,而非採用目標值。舉例來說,如果動畫已由導覽轉場、AnimatedSwitcher 或 AnimationController 定義,這些函式就非常實用。

使用明確的動畫效果

如要開始使用明確的動畫效果,請使用 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>,定義傳入和傳出小工具移動時的開始和結束偏移。

Tween 有一個輔助函式 animate(),可將任何 Animation 轉換為套用 Tween 的另一個 Animation。也就是說,Tween 可用於將 AnimatedSwitcher 提供的 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.animate 將 Curve 套用至 Animation,然後將 0.0 到 1.0 範圍的 Tween 轉換為 Tween,在 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
  },

自訂 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 物件的動畫效果 (與採用目標 value 和 duration 的 ImplicitlyAnimatedWidgets 相對)
  • Animation 類別代表正在執行的動畫,但不會定義特定效果。
  • 使用 Tween().animate 或 Animation.drive(),將 Tweens 和 Curves (使用 CurveTween) 套用至動畫。
  • 使用 AnimatedSwitcher 的 layoutBuilder 參數調整子項的版面配置方式。

6. 控制動畫狀態

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

使用 AnimationController 執行動畫

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

  1. 建立 StatefulWidget
  2. 在 State 類別中使用 SingleTickerProviderStateMixin mixin,為 AnimationController 提供 Ticker
  3. 在 initState 生命週期方法中初始化 AnimationController,並將目前的 State 物件提供給 vsync (TickerProvider) 參數。
  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 中,使用 TweenSequence 建立新的 Animation,套用延遲。請注意,這不會使用 dart:async 程式庫中的任何公用程式,例如 Future.delayed。這是因為延遲是動畫的一部分,而非小工具使用 AnimationController 時明確控制的項目。在開發人員工具中啟用慢速動畫時,由於這項動畫效果使用相同的 TickerProvider,因此更容易偵錯。

如要使用 TweenSequence,請建立兩個 TweenSequenceItem,其中一個包含 ConstantTween,可將動畫維持在 0 的相對時間長度,另一個則包含從 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,
  );
}

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

28b5291de9b3f55f.gif

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

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

製作導覽轉場動畫

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 也支援預測返回手勢。系統會使用名為 PageTransitionsBuilder 的特殊 PredictiveBackPageTransitionsBuilder 監聽預測返回手勢,並根據手勢進度驅動頁面轉場效果。

預測返回功能僅適用於 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」
  • 如何將 Curves 和 Tweens 套用至動畫
  • 如何使用預先建構的轉場效果小工具,例如 AnimatedSwitcher 或 PageRouteBuilder
  • 如何使用 animations 套件中預先建構的精美動畫效果,例如 FadeThroughTransition 和 OpenContainer
  • 如何自訂預設轉場動畫,包括在 Android 上新增預測返回支援功能。

3026390ad413769c.gif

後續步驟

查看下列程式碼研究室:

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

延伸閱讀

如需更多動畫資源,請前往 flutter.dev:

或者,您也可以參閱 Medium 上的下列文章:

參考文件