英雄動畫
如何讓元件在兩個螢幕之間飛入飛出。
你可能已經多次見過 Hero 動畫。例如,螢幕上顯示一組待售商品的縮圖。選中一個專案會將其飛入一個包含更多詳細資訊和“購買”按鈕的新螢幕。將影像從一個螢幕飛入另一個螢幕的操作在 Flutter 中稱為 Hero 動畫,儘管相同的動作有時也被稱為共享元素過渡。
你可能想觀看這個介紹 Hero 元件的一分鐘影片
本指南演示瞭如何構建標準 Hero 動畫,以及在飛行過程中將影像從圓形轉換為方形的 Hero 動畫。
路由 (Route) 描述了 Flutter 應用中的頁面或螢幕。
你可以使用 Hero 元件在 Flutter 中建立此動畫。當 Hero 從源路由動畫到目標路由時,目標路由(除去 Hero 本身)會淡入視野。通常,Hero 是 UI 的一小部分(如影像),且兩個路由都包含該部分。從使用者的角度來看,Hero 在路由之間“飛行”。本指南展示瞭如何建立以下 Hero 動畫
標準 Hero 動畫
標準 Hero 動畫將 Hero 從一個路由飛向另一個新路由,通常在不同的位置以不同的尺寸著陸。
以下影片(以慢速錄製)展示了一個典型的例子。點選路由中心的腳蹼(flippers),它們會飛到新藍色路由的左上角,並縮小尺寸。點選藍色路由中的腳蹼(或使用裝置的返回上一路由手勢),腳蹼會飛回原始路由。
徑向 Hero 動畫
在徑向 Hero 動畫中,當 Hero 在路由之間飛行時,其形狀看起來會從圓形變為矩形。
以下影片(以慢速錄製)展示了徑向 Hero 動畫的一個例子。開始時,路由底部出現一排三個圓形影像。點選任何一個圓形影像,該影像會飛到一個以方形顯示它的新路由中。點選方形影像,Hero 會飛回原始路由,並以圓形顯示。
在進入標準或徑向 Hero 動畫的具體章節之前,請閱讀Hero 動畫的基本結構以瞭解如何組織程式碼,並閱讀幕後原理以瞭解 Flutter 如何執行 Hero 動畫。
Hero 動畫的基本結構
#- 在不同的路由中使用兩個具有匹配標記(tag)的 Hero 元件來實現動畫。
- 導航器 (Navigator) 管理著一個包含應用路由的堆疊。
- 將路由推入或從導航器堆疊中彈出路由會觸發該動畫。
- Flutter 框架會計算一個矩形補間動畫
RectTween,該補間定義了 Hero 從源路由飛向目標路由時的邊界。在飛行過程中,Hero 被移動到一個應用覆蓋層 (overlay) 中,使其出現在兩個路由的上方。
如果補間 (tweens) 或補間動畫的概念對你來說很陌生,請檢視 Flutter 動畫教程。
Hero 動畫是使用兩個 Hero 元件實現的:一個描述源路由中的元件,另一個描述目標路由中的元件。從使用者的角度來看,Hero 看起來是共享的,只有開發人員需要了解這個實現細節。Hero 動畫程式碼具有以下結構
- 定義一個起始 Hero 元件,稱為源 Hero。Hero 指定了其圖形表示(通常是影像)和一個標識標籤(tag),並且位於源路由定義的當前顯示元件樹中。
- 定義一個結束 Hero 元件,稱為目標 Hero。該 Hero 也指定了其圖形表示,以及與源 Hero 相同的標籤。至關重要的是,兩個 Hero 元件必須使用相同的標籤建立,通常是一個代表底層資料的物件。為了獲得最佳效果,這些 Hero 應具有幾乎相同的元件樹。
- 建立一個包含目標 Hero 的路由。目標路由定義了動畫結束時存在的元件樹。
- 透過將目標路由推入導航器的堆疊來觸發動畫。導航器的推送和彈出操作會為源路由和目標路由中每對標籤匹配的 Hero 觸發 Hero 動畫。
Flutter 計算將 Hero 邊界從起點動畫到終點(插值計算尺寸和位置)的補間,並在覆蓋層中執行動畫。
下一節將更詳細地描述 Flutter 的處理過程。
幕後原理
#以下內容描述了 Flutter 如何執行從一個路由到另一個路由的過渡。
在過渡之前,源 Hero 等待在源路由的元件樹中。目標路由尚不存在,覆蓋層為空。

將路由推入 Navigator 會觸發動畫。在 t=0.0 時,Flutter 執行以下操作
-
按照 Material 運動規範中所述的曲線運動,在螢幕外計算目標 Hero 的路徑。Flutter 現在知道 Hero 的終點位置。
-
將目標 Hero 放置在覆蓋層中,位置和大小與源 Hero 相同。將 Hero 新增到覆蓋層會改變其 Z 軸順序,使其出現在所有路由的上方。
將源 Hero 移出螢幕。
當 Hero 飛行時,其矩形邊界使用 Hero 的 createRectTween 屬性中指定的 Tween<Rect> 進行動畫處理。預設情況下,Flutter 使用 MaterialRectArcTween 的例項,它沿著曲線路徑對矩形的相對角進行動畫處理。(有關使用不同補間動畫的示例,請參閱徑向 Hero 動畫。)
飛行完成時
-
Flutter 將 Hero 元件從覆蓋層移動到目標路由。覆蓋層現在為空。
-
目標 Hero 出現在目標路由的最終位置。
源 Hero 被恢復到其原始路由。
彈出路由執行相同的過程,將 Hero 動畫回其在源路由中的大小和位置。
關鍵類
#本指南中的示例使用以下類來實現 Hero 動畫
Hero-
從源路由飛向目標路由的元件。為源路由定義一個 Hero,為目標路由定義另一個 Hero,併為每個 Hero 分配相同的標籤。Flutter 會對具有匹配標籤的 Hero 對進行動畫處理。
InkWell-
指定點選 Hero 時會發生什麼。
InkWell的onTap()方法構建新路由並將其推入Navigator的堆疊。 Navigator-
Navigator管理路由堆疊。將路由推入或從Navigator堆疊中彈出路由會觸發該動畫。 Route-
指定螢幕或頁面。除了最基礎的應用外,大多數應用都有多個路由。
標準 Hero 動畫
#- 使用
MaterialPageRoute、CupertinoPageRoute指定路由,或使用PageRouteBuilder構建自定義路由。本節中的示例使用 MaterialPageRoute。 - 透過將目標影像包裝在
SizedBox中,可以更改過渡結束時影像的尺寸。 - 透過將目標影像放置在佈局元件中來更改影像的位置。這些示例使用
Container。
以下每個示例都演示了將影像從一個路由飛向另一個路由的過程。本指南描述了第一個示例。
- hero_animation
-
將 Hero 程式碼封裝在自定義
PhotoHero元件中。按照 Material 運動規範的描述,沿曲線路徑對 Hero 的運動進行動畫處理。 - basic_hero_animation
-
直接使用 Hero 元件。這個更基礎的示例僅供參考,本指南未作介紹。
發生了什麼?
#使用 Flutter 的 Hero 元件可以輕鬆實現將影像從一個路由飛向另一個路由。使用 MaterialPageRoute 指定新路由時,影像會按照 Material Design 運動規範中所述,沿著曲線路徑飛行。
建立一個新的 Flutter 應用,並使用 hero_animation 中的檔案進行更新。
執行示例
- 點選主路由上的照片,將影像飛向一個新路由,該路由在不同位置和比例下顯示相同的照片。
- 透過點選影像,或使用裝置的返回上一路由手勢,返回到上一個路由。
- 你可以使用
timeDilation屬性進一步減慢過渡速度。
PhotoHero 類
#自定義的 PhotoHero 類維護著 Hero 及其尺寸、影像和點選時的行為。PhotoHero 構建了以下元件樹

程式碼如下
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 屬性時,起始路由由MaterialApp隱式推送。 InkWell包裝了影像,可以輕鬆地為源 Hero 和目標 Hero 新增點選手勢。- 將 Material 元件定義為透明顏色,使得影像在飛向目的地時能夠從背景中“彈出”。
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建立目標路由。將目標路由推送到Navigator的堆疊會觸發動畫。 Container將PhotoHero定位在目標路由的左上角,位於AppBar下方。- 目標
PhotoHero的onTap()方法會彈出Navigator的堆疊,觸發將Hero飛回原始路由的動畫。 - 除錯時使用
timeDilation屬性減慢過渡速度。
徑向 Hero 動畫
#- 徑向變換將圓形變為正方形。
- 徑向 Hero 動畫在將 Hero 從源路由飛向目標路由的過程中執行徑向變換。
- MaterialRectCenterArcTween 定義了補間動畫。
- 使用
PageRouteBuilder構建目標路由。
將 Hero 從一個路由飛向另一個路由並將其從圓形變為矩形,是一種可以使用 Hero 元件實現的炫酷效果。為了實現這一點,程式碼對兩個剪裁形狀的交集進行了動畫處理:一個圓形和一個正方形。在整個動畫過程中,圓形剪裁(和影像)從 minRadius 縮放到 maxRadius,而方形剪裁保持恆定尺寸。與此同時,影像從其在源路由中的位置飛向其在目標路由中的位置。有關此過渡的視覺示例,請參閱 Material 運動規範中的徑向變換。
此動畫看起來可能很複雜(確實如此),但你可以根據你的需求自定義提供的示例。大部分繁重的工作都已經為你完成了。
以下每個示例都演示了徑向 Hero 動畫。本指南描述了第一個示例。
- radial_hero_animation
Material 運動規範中所述的徑向 Hero 動畫。
- basic_radial_hero_animation
-
徑向 Hero 動畫的最簡單示例。目標路由沒有 Scaffold、Card、Column 或 Text。這個基礎示例僅供參考,本指南未作介紹。
-
radial_hero_animation_animate
_rectclip -
透過對矩形剪裁的尺寸進行動畫處理來擴充套件 radial_hero_animation。這個更高階的示例僅供參考,本指南未作介紹。
徑向 Hero 動畫涉及將圓形與正方形相交。即使使用 timeDilation 減慢動畫速度,這也可能很難看清,因此你可能考慮在開發過程中啟用 debugPaintSizeEnabled 標誌。
發生了什麼?
#下圖顯示了動畫開始(t = 0.0)和結束(t = 1.0)時的剪裁影像。
藍色漸變(代表影像)表示剪裁形狀的交集位置。在過渡開始時,交集的結果是圓形剪裁(ClipOval)。在變換過程中,ClipOval 從 minRadius 縮放到 maxRadius,而 ClipRect 保持恆定尺寸。在過渡結束時,圓形和矩形剪裁的交集產生一個與 Hero 元件大小相同的矩形。換句話說,在過渡結束時,影像不再被剪裁。
建立一個新的 Flutter 應用,並使用 radial_hero_animation GitHub 目錄中的檔案進行更新。
執行示例
- 點選三個圓形縮圖之一,將影像動畫化為一個更大的正方形,並放置在一個覆蓋原始路由的新路由中間。
- 透過點選影像,或使用裝置的返回上一路由手勢,返回到上一個路由。
- 你可以使用
timeDilation屬性進一步減慢過渡速度。
Photo 類
#Photo 類構建了包含影像的元件樹
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捕獲點選手勢。呼叫函式將onTap()函式傳遞給Photo的建構函式。- 在飛行過程中,
InkWell會在其第一個 Material 祖先上繪製其點選效果。 - Material 元件具有輕微的不透明顏色,因此影像的透明部分會被渲染為彩色。這確保了即使對於帶有透明度的影像,圓形到正方形的過渡也清晰可見。
Photo類在其元件樹中不包含Hero。為了使動畫正常工作,Hero 包裝了RadialExpansion元件。
RadialExpansion 類
#RadialExpansion 元件是該演示的核心,它構建了在過渡期間剪裁影像的元件樹。剪裁後的形狀是由圓形剪裁(在飛行過程中增長)與矩形剪裁(在整個過程中保持恆定大小)的交集產生的。
為此,它構建了以下元件樹
程式碼如下
class RadialExpansion extends StatelessWidget {
const RadialExpansion({
super.key,
required this.maxRadius,
this.child,
}) : clipRectSize = 2.0 * (maxRadius / math.sqrt2);
final double maxRadius;
final double 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元件。-
當 Hero 飛行時,其大小會發生變化,並且由於它限制了子元件的大小,
RadialExpansion元件也會隨之改變大小以匹配。 RadialExpansion動畫由兩個重疊的剪裁建立。-
該示例使用
MaterialRectCenterArcTween定義了補間插值。Hero 動畫的預設飛行路徑使用 Hero 的角來插值補間。這種方法在徑向變換過程中會影響 Hero 的縱橫比,因此新的飛行路徑使用MaterialRectCenterArcTween來透過每個 Hero 的中心點來插值補間。程式碼如下
dartstatic RectTween _createRectTween(Rect? begin, Rect? end) { return MaterialRectCenterArcTween(begin: begin, end: end); }Hero 的飛行路徑仍然遵循弧線,但影像的縱橫比保持不變。