Hero 動畫
你很可能已經多次見過 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 動畫的程式碼結構如下:
- 定義起始的 Hero 元件,稱為 來源 hero。hero 需指定其圖像表現(通常是一張圖片)、一個識別用的 tag,並存在於來源 route 所定義的 widget tree 中。
- 定義結束的 Hero 元件,稱為 目標 hero。此 hero 也需指定其圖像表現,以及與來源 hero 相同的 tag。兩個 hero 元件必須使用相同的 tag,通常是代表底層資料的物件。為了最佳效果,兩個 hero 的 widget tree 應盡可能一致。
- 建立包含目標 hero 的 route。目標 route 定義動畫結束時存在的 widget tree。
- 透過將目標 route 推入(push)Navigator 的堆疊來觸發動畫。Navigator 的 push 和 pop 操作,會對來源與目標 route 中 tag 相同的 hero 配對執行動畫。
Flutter 會計算一個 Tween,將 Hero 的邊界從起點動畫到終點(同時插值尺寸與位置),並在 overlay 上執行動畫。
下一節將更詳細說明 Flutter 的處理流程。
幕後原理
#以下說明 Flutter 如何執行 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 飛行時,其矩形邊界會使用 Tween<Rect> 進行動畫,這是由 Hero 的 createRectTween 屬性指定。預設情況下,Flutter 會使用 MaterialRectArcTween 實例,該實例會讓矩形的對角線角落沿著曲線路徑動畫。(請參閱 徑向 hero 動畫,了解使用不同 Tween 動畫的範例。)

飛行結束時:
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 時的行為。
InkWell的onTap()方法會建立新 route,並將其推入Navigator的堆疊。 NavigatorNavigator管理一個 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:
程式碼如下:
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,並設定轉場動畫。
以下是程式碼:
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的下方。- 目標
PhotoHero的onTap()方法 會彈出(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)時被剪裁的圖片。

藍色漸層(代表圖片)顯示了剪裁形狀的交集區域。在轉場開始時, 交集的結果是一個圓形剪裁(ClipOval)。 在轉換過程中,ClipOval 會從 minRadius 縮放到 maxRadius, 而 ClipRect 則維持固定大小。 在轉場結束時,圓形與方形剪裁的交集會產生一個與 hero 元件(Widget)同樣大小的矩形。換句話說,轉場結束後,圖片不再被剪裁。
建立一個新的 Flutter 應用程式, 並使用 radial_hero_animation GitHub 目錄中的檔案進行更新。
執行範例步驟:
- 點擊三個圓形縮圖其中之一,將圖片動畫放大到新路由中央的方形,並遮蔽原始路由。
- 點擊圖片或使用裝置的返回手勢,即可回到前一個路由。
- 你可以使用
timeDilation屬性進一步減慢轉場速度。
Photo 類別
#Photo 類別會建立包含圖片的元件樹(widget tree):
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)。 裁切後的形狀是由一個圓形裁切(在過渡期間會放大) 與一個矩形裁切(在整個過程中維持固定大小)相交所產生。
為了達成這個目的,它會建立以下的元件樹:
以下是相關程式碼:
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 的插值。以下是程式碼:
dartstatic RectTween _createRectTween(Rect? begin, Rect? end) { return MaterialRectCenterArcTween(begin: begin, end: end); }Hero 的飛行路徑仍然遵循一個弧線, 但圖片的長寬比會保持不變。