你很可能已經多次見過 hero 動畫。例如,一個螢幕顯示了一個待售商品的縮圖列表。選擇其中一個商品時,該縮圖會飛到新的螢幕,顯示更多細節及「購買」按鈕。將圖片從一個螢幕飛到另一個螢幕,在 Flutter 中稱為 hero 動畫,而這種動作有時也被稱為 共享元素轉場

你可以觀看這段一分鐘的影片,快速認識 Hero 元件:

Watch on YouTube in a new tab: "Hero | Flutter widget of the week"

本指南將示範如何建立標準 hero 動畫,以及在飛行過程中將圖片從圓形變換為方形的 hero 動畫。

你可以在 Flutter 中透過 Hero 元件建立這種動畫。當 hero 從來源 route 動畫到目標 route 時,目標 route(不含 hero)會淡入顯示。通常,hero 是 UI 中兩個 route 共同擁有的小部分,例如圖片。對使用者來說,hero 就像是在 route 之間「飛行」。本指南將說明如何建立下列 hero 動畫:

標準 hero 動畫

標準 hero 動畫 會讓 hero 從一個 route 飛到新的 route,通常會落在不同的位置並改變尺寸。

下方影片(以慢速錄製)展示了一個典型範例。點擊 route 中央的 flippers,會讓它們飛到新藍色 route 的左上角,並縮小尺寸。點擊藍色 route 中的 flippers(或使用裝置的返回手勢)則會讓 flippers 飛回原本的 route。

Watch on YouTube in a new tab: "Standard hero animation in Flutter"

徑向 hero 動畫

徑向 hero 動畫 中,hero 在 route 之間飛行時,其形狀會從圓形變為矩形。

下方影片(以慢速錄製)展示了一個徑向 hero 動畫的範例。一開始,三個圓形圖片的橫列出現在 route 底部。點擊任一圓形圖片,會將該圖片飛到新 route,並以方形顯示。點擊方形圖片,則會讓 hero 飛回原本的 route,並以圓形顯示。

Watch on YouTube in a new tab: "Radial hero animation in Flutter"

在進入 標準徑向 hero 動畫的專屬章節前,建議先閱讀 hero 動畫的基本結構,了解 hero 動畫程式碼的架構,以及 幕後原理,深入理解 Flutter 如何執行 hero 動畫。

hero 動畫的基本結構

#

Hero 動畫是利用兩個 Hero 元件實作:一個描述來源 route 的元件,另一個描述目標 route 的元件。對使用者而言,hero 看起來像是被「共用」的,只有開發者需要理解這個實作細節。Hero 動畫的程式碼結構如下:

  1. 定義起始的 Hero 元件,稱為 來源 hero。hero 需指定其圖像表現(通常是一張圖片)、一個識別用的 tag,並存在於來源 route 所定義的 widget tree 中。
  2. 定義結束的 Hero 元件,稱為 目標 hero。此 hero 也需指定其圖像表現,以及與來源 hero 相同的 tag。兩個 hero 元件必須使用相同的 tag,通常是代表底層資料的物件。為了最佳效果,兩個 hero 的 widget tree 應盡可能一致。
  3. 建立包含目標 hero 的 route。目標 route 定義動畫結束時存在的 widget tree。
  4. 透過將目標 route 推入(push)Navigator 的堆疊來觸發動畫。Navigator 的 push 和 pop 操作,會對來源與目標 route 中 tag 相同的 hero 配對執行動畫。

Flutter 會計算一個 Tween,將 Hero 的邊界從起點動畫到終點(同時插值尺寸與位置),並在 overlay 上執行動畫。

下一節將更詳細說明 Flutter 的處理流程。

幕後原理

#

以下說明 Flutter 如何執行 route 之間的轉場。

轉場前,來源 hero 顯示於來源 route

在轉場前,來源 hero 停留於來源 route 的 widget tree。目標 route 尚未建立,overlay 也是空的。


轉場開始

將 route 推入 Navigator 會觸發動畫。在 t=0.0,Flutter 執行下列動作:

  • 依據 Material motion 規範,離線計算目標 hero 的路徑,使用曲線運動。Flutter 現在已知道 hero 的最終位置。

  • 將目標 hero 放入 overlay,位置與尺寸與 來源 hero 一致。將 hero 加入 overlay 會改變其 Z 軸順序,使其顯示於所有 route 之上。

  • 將來源 hero 移出螢幕。


hero 在 overlay 中飛行至最終位置與尺寸

hero 飛行時,其矩形邊界會使用 Tween<Rect> 進行動畫,這是由 Hero 的 createRectTween 屬性指定。預設情況下,Flutter 會使用 MaterialRectArcTween 實例,該實例會讓矩形的對角線角落沿著曲線路徑動畫。(請參閱 徑向 hero 動畫,了解使用不同 Tween 動畫的範例。)


轉場完成後,hero 從 overlay 移至目標 route

飛行結束時:

  • Flutter 會將 hero 元件從 overlay 移到目標 route。此時 overlay 已清空。

  • 目標 hero 會出現在目標 route 的最終位置。

  • 來源 hero 會被還原至其 route。


彈出(pop)route 時,會執行相同流程,將 hero 動畫回來源 route 的尺寸與位置。

重要類別

#

本指南的範例會用到下列類別來實作 hero 動畫:

Hero
負責從來源 route 飛到目標 route 的元件。為來源 route 與目標 route 各定義一個 Hero,並賦予相同的 tag。Flutter 會針對 tag 相同的 hero 配對執行動畫。
InkWell
指定點擊 hero 時的行為。InkWellonTap() 方法會建立新 route,並將其推入 Navigator 的堆疊。
Navigator
Navigator 管理一個 route 堆疊。將 route 推入或彈出 Navigator 的堆疊會觸發動畫。
Route
指定一個螢幕或頁面。大多數應用程式(除了最基本的)都會有多個 route。

標準 hero 動畫

#

發生了什麼事?

#

在 Flutter 中,使用 hero 元件實作圖片從一個 route 飛到另一個 route 的動畫非常簡單。當使用 MaterialPageRoute 指定新 route 時,圖片會沿著 Material Design 動態規範 所描述的曲線路徑飛行。

建立一個新的 Flutter 應用程式,並使用 hero_animation 的檔案進行更新。

執行範例時:

  • 點擊首頁 route 的照片,會將圖片飛到新 route,並以不同的位置與縮放顯示同一張照片。
  • 點擊圖片或使用裝置的返回手勢,即可返回前一個 route。
  • 你可以利用 timeDilation 屬性進一步放慢轉場動畫。

PhotoHero 類別

#

自訂的 PhotoHero 類別負責維護 hero,以及其尺寸、圖片和點擊時的行為。PhotoHero 會建立下列 widget tree:

PhotoHero class widget tre

程式碼如下:

dart
class PhotoHero extends StatelessWidget {
  const PhotoHero({
    super.key,
    required this.photo,
    this.onTap,
    required this.width,
  });

  final String photo;
  final VoidCallback? onTap;
  final double width;

  @override
  Widget build(BuildContext context) {
    return SizedBox(
      width: width,
      child: Hero(
        tag: photo,
        child: Material(
          color: Colors.transparent,
          child: InkWell(
            onTap: onTap,
            child: Image.asset(
              photo,
              fit: BoxFit.contain,
            ),
          ),
        ),
      ),
    );
  }
}

關鍵資訊:

  • HeroAnimation被指定為應用程式的 home 屬性時,起始 Route 會由MaterialApp隱式推入。
  • 一個InkWell包裹了圖片,讓你可以很輕鬆地在來源與目標 Hero 上加入點擊手勢。
  • 使用透明色定義 Material 元件 (Widget),可以讓圖片在飛往目標時「跳脫」背景。
  • SizedBox指定了動畫開始與結束時 Hero 的尺寸。
  • 將圖片的fit屬性設為BoxFit.contain,可以確保圖片在轉場過程中盡可能放大,同時不改變其長寬比。

HeroAnimation 類別

#

HeroAnimation類別會建立來源與目標的 PhotoHero,並設定轉場動畫。

以下是程式碼:

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

  Widget build(BuildContext context) {
    timeDilation = 5.0; // 1.0 means normal animation speed.

    return Scaffold(
      appBar: AppBar(
        title: const Text('Basic Hero Animation'),
      ),
      body: Center(
        child: PhotoHero(
          photo: 'images/flippers-alpha.png',
          width: 300.0,
          onTap: () {
            Navigator.of(context).push(MaterialPageRoute<void>(
              builder: (context) {
                return Scaffold(
                  appBar: AppBar(
                    title: const Text('Flippers Page'),
                  ),
                  body: Container(
                    // Set background to blue to emphasize that it's a new route.
                    color: Colors.lightBlueAccent,
                    padding: const EdgeInsets.all(16),
                    alignment: Alignment.topLeft,
                    child: PhotoHero(
                      photo: 'images/flippers-alpha.png',
                      width: 100.0,
                      onTap: () {
                        Navigator.of(context).pop();
                      },
                    ),
                  ),
                );
              }
            ));
          },
        ),
      ),
    );
  }
}

關鍵資訊:

  • 當使用者點擊包含來源 hero 的 InkWell 時, 程式碼會使用 MaterialPageRoute 建立目標路由(destination route)。 將目標路由推送到 Navigator 的堆疊時, 會觸發動畫(Animation)。
  • Container 會將 PhotoHero 定位在目標 路由左上角、AppBar 的下方。
  • 目標 PhotoHeroonTap() 方法 會彈出(pop)Navigator 的堆疊,觸發動畫, 讓 Hero 飛回原始路由。
  • 除錯時可使用 timeDilation 屬性來減慢轉場速度。

放射狀 hero 動畫(Radial hero animations)

#

將 hero 從一個路由飛到另一個路由,同時從圓形變換為矩形, 是一個很炫的效果,你可以透過 Hero 元件(Widgets)來實現。 為了達成這個效果,程式碼會對兩個剪裁形狀(clip shapes):圓形與方形,進行交集動畫。 在整個動畫過程中,圓形剪裁(以及圖片)會從 minRadius 縮放到 maxRadius, 而方形剪裁則維持固定大小。與此同時, 圖片會從來源路由的位置飛到目標路由的位置。若想看此轉場的視覺範例,請參考 Material motion 規範中的 Radial transformation

這個動畫看起來或許很複雜(事實上也確實如此),但你可以依需求自訂提供的範例。大部分繁重的工作都已經幫你完成。

發生了什麼事?

#

下圖顯示了動畫開始(t = 0.0)與結束(t = 1.0)時被剪裁的圖片。

Radial transformation from beginning to end

藍色漸層(代表圖片)顯示了剪裁形狀的交集區域。在轉場開始時, 交集的結果是一個圓形剪裁(ClipOval)。 在轉換過程中,ClipOval 會從 minRadius 縮放到 maxRadius, 而 ClipRect 則維持固定大小。 在轉場結束時,圓形與方形剪裁的交集會產生一個與 hero 元件(Widget)同樣大小的矩形。換句話說,轉場結束後,圖片不再被剪裁。

建立一個新的 Flutter 應用程式, 並使用 radial_hero_animation GitHub 目錄中的檔案進行更新。

執行範例步驟:

  • 點擊三個圓形縮圖其中之一,將圖片動畫放大到新路由中央的方形,並遮蔽原始路由。
  • 點擊圖片或使用裝置的返回手勢,即可回到前一個路由。
  • 你可以使用 timeDilation 屬性進一步減慢轉場速度。

Photo 類別

#

Photo 類別會建立包含圖片的元件樹(widget tree):

dart
class Photo extends StatelessWidget {
  const Photo({super.key, required this.photo, this.color, this.onTap});

  final String photo;
  final Color? color;
  final VoidCallback onTap;

  Widget build(BuildContext context) {
    return Material(
      // Slightly opaque color appears where the image has transparency.
      color: Theme.of(context).primaryColor.withValues(alpha: 0.25),
      child: InkWell(
        onTap: onTap,
        child: Image.asset(
          photo,
          fit: BoxFit.contain,
        ),
      ),
    );
  }
}

關鍵資訊:

  • InkWell 負責捕捉點擊(tap)手勢。 呼叫的函式會將 onTap() 函式傳遞給 Photo 的建構子。
  • 在動畫過渡期間,InkWell 會在其第一個 Material 祖先上繪製其水波(splash)效果。
  • Material 元件(Widget)具有略帶透明的顏色,因此 圖片中透明的部分會以顏色呈現。 這確保了從圓形到方形的轉換過程,即使對於有透明區域的圖片,也能清楚可見。
  • Photo 類別在其元件樹中不包含 Hero。 為了讓動畫能正常運作,hero 會包裹 RadialExpansion 元件(Widget)。

RadialExpansion 類別

#

RadialExpansion 元件(Widget)是本範例的核心, 負責建立在動畫過渡期間裁切圖片的元件樹(widget tree)。 裁切後的形狀是由一個圓形裁切(在過渡期間會放大) 與一個矩形裁切(在整個過程中維持固定大小)相交所產生。

為了達成這個目的,它會建立以下的元件樹:

RadialExpansion widget tree

以下是相關程式碼:

dart
class RadialExpansion extends StatelessWidget {
  const RadialExpansion({
    super.key,
    required this.maxRadius,
    this.child,
  }) : clipRectSize = 2.0 * (maxRadius / math.sqrt2);

  final double maxRadius;
  final clipRectSize;
  final Widget child;

  @override
  Widget build(BuildContext context) {
    return ClipOval(
      child: Center(
        child: SizedBox(
          width: clipRectSize,
          height: clipRectSize,
          child: ClipRect(
            child: child, // Photo
          ),
        ),
      ),
    );
  }
}

關鍵資訊:

  • hero 會包裹 RadialExpansion 元件(Widget)。

  • 當 hero 飛行時,其尺寸會改變,並且因為它會限制其子元件的尺寸,所以 RadialExpansion 元件(Widget)也會隨之調整尺寸以配合。

  • RadialExpansion 動畫(Animation)是由兩個重疊的裁切(clip)所產生。

  • 此範例使用 MaterialRectCenterArcTween 來定義補間插值(tweening interpolation)。 hero 動畫(hero animation)的預設飛行路徑,會使用 hero 的角落來進行 tween 的插值。 這種做法會影響 hero 在徑向轉換(radial transformation)期間的長寬比,因此新的飛行路徑會使用 MaterialRectCenterArcTween,以各 hero 的中心點來進行 tween 的插值。

    以下是程式碼:

    dart
    static RectTween _createRectTween(Rect? begin, Rect? end) {
      return MaterialRectCenterArcTween(begin: begin, end: end);
    }

    Hero 的飛行路徑仍然遵循一個弧線, 但圖片的長寬比會保持不變。