你可能已經多次看到 Hero 動畫。例如,一個螢幕顯示代表待售商品的縮圖列表。選擇一個商品會將其飛到新螢幕,其中包含更多詳細資訊和“購買”按鈕。在 Flutter 中,將影像從一個螢幕飛到另一個螢幕稱為**英雄動畫**,儘管相同的動作有時被稱為**共享元素過渡**。

你可能想觀看這個介紹 Hero widget 的一分鐘影片

在新標籤頁中在 YouTube 上觀看:“Hero | Flutter widget of the week”

本指南演示瞭如何構建標準 Hero 動畫,以及在飛行過程中將影像從圓形轉換為方形的 Hero 動畫。

你可以在 Flutter 中使用 Hero widget 建立此動畫。當英雄從源路由動畫到目標路由時,目標路由(減去英雄)逐漸淡入視野。通常,英雄是 UI 的一小部分,例如影像,並且兩個路由都具有這些共同部分。從使用者的角度來看,英雄在路由之間“飛翔”。本指南展示瞭如何建立以下英雄動畫

標準 Hero 動畫

**標準英雄動畫**將英雄從一個路由飛到新路由,通常會降落在不同的位置並具有不同的大小。

以下影片(以慢速錄製)顯示了一個典型的示例。點選路由中心的鰭狀肢會將其飛到新的藍色路由的左上角,尺寸更小。點選藍色路由中的鰭狀肢(或使用裝置的返回上一路由手勢)會將鰭狀肢飛回原始路由。

在新標籤頁中在 YouTube 上觀看:“Flutter 中的標準英雄動畫”

徑向 Hero 動畫

在**徑向英雄動畫**中,當英雄在路由之間飛行時,其形狀似乎從圓形變為矩形。

以下影片(以慢速錄製)顯示了徑向英雄動畫的示例。開始時,路由底部出現一排三個圓形影像。點選任何圓形影像會將該影像飛到新路由,並以方形形狀顯示。點選方形影像會將英雄飛回原始路由,並以圓形形狀顯示。

在新標籤頁中在 YouTube 上觀看:“Flutter 中的徑向英雄動畫”

在轉到特定於標準徑向英雄動畫的部分之前,請閱讀英雄動畫的基本結構以瞭解如何構造英雄動畫程式碼,並閱讀幕後以瞭解 Flutter 如何執行英雄動畫。

Hero 動畫的基本結構

#

Hero 動畫使用兩個`Hero` widget 實現:一個描述源路由中的 widget,另一個描述目標路由中的 widget。從使用者的角度來看,hero 似乎是共享的,只有程式設計師需要了解此實現細節。Hero 動畫程式碼具有以下結構

  1. 定義一個起始 Hero widget,稱為**源英雄**。該英雄指定其圖形表示(通常是影像)和標識標籤,並存在於源路由定義的當前顯示的 widget 樹中。
  2. 定義一個結束 Hero widget,稱為**目標英雄**。該英雄也指定其圖形表示,並與源英雄具有相同的標籤。**兩個 hero widget 都必須使用相同的標籤建立**,通常是表示底層資料的物件。為了獲得最佳結果,英雄應具有幾乎相同的 widget 樹。
  3. 建立包含目標英雄的路由。目標路由定義了動畫結束時存在的 widget 樹。
  4. 透過將目標路由推入 Navigator 堆疊來觸發動畫。Navigator 的 push 和 pop 操作會為源路由和目標路由中每對具有匹配標籤的英雄觸發英雄動畫。

Flutter 計算動畫化 Hero 邊界從起點到終點(插值大小和位置)的 tween,並在覆蓋層中執行動畫。

下一節將更詳細地描述 Flutter 的過程。

幕後

#

以下描述了 Flutter 如何執行從一個路由到另一個路由的轉換。

Before the transition the source hero appears in the source route

在轉換之前,源英雄在源路由的 widget 樹中等待。目標路由尚不存在,並且覆蓋層為空。


The transition begins

將路由推送到 `Navigator` 會觸發動畫。在 `t=0.0` 時,Flutter 執行以下操作

  • 使用 Material motion 規範中描述的曲線運動,在螢幕外計算目標英雄的路徑。Flutter 現在知道英雄的最終位置。

  • 將目標英雄放置在覆蓋層中,其位置和大小與**源**英雄相同。將英雄新增到覆蓋層會改變其 Z 軸順序,使其顯示在所有路由之上。

  • 將源英雄移出螢幕。


The hero flies in the overlay to its final position and size

當英雄飛行時,其矩形邊界使用Tween<Rect>進行動畫,在 Hero 的`createRectTween`屬性中指定。預設情況下,Flutter 使用`MaterialRectArcTween`的例項,它沿著曲線路徑動畫矩形的對角。 (有關使用不同 Tween 動畫的示例,請參閱徑向英雄動畫。)


When the transition is complete, the hero is moved from the overlay to the destination route

當飛行完成時

  • Flutter 將 hero widget 從覆蓋層移動到目標路由。覆蓋層現在為空。

  • 目標英雄出現在目標路由中的最終位置。

  • 源英雄恢復到其路由。


彈出路由執行相同的過程,將英雄動畫回其在源路由中的大小和位置。

核心類

#

本指南中的示例使用以下類來實現英雄動畫

Hero
從源路由飛到目標路由的 widget。為源路由定義一個 Hero,為目標路由定義另一個 Hero,併為它們分配相同的標籤。Flutter 動畫具有匹配標籤的英雄對。
InkWell
指定點選英雄時發生的情況。`InkWell` 的 `onTap()` 方法構建新路由並將其推送到 `Navigator` 的堆疊。
Navigator
`Navigator` 管理一個路由堆疊。將路由推入或從 `Navigator` 堆疊中彈出路由會觸發動畫。
Route
指定螢幕或頁面。大多數應用程式,除了最基本的應用程式之外,都有多個路由。

標準 Hero 動畫

#

發生了什麼?

#

使用 Flutter 的 hero widget,將影像從一個路由飛到另一個路由很容易實現。當使用 `MaterialPageRoute` 指定新路由時,影像會沿著Material Design motion 規範中描述的曲線路徑飛行。

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

要執行示例

  • 點選主路由的照片,將影像飛到新路由,其中顯示相同照片的不同位置和比例。
  • 透過點選影像或使用裝置的返回上一路由手勢返回上一路由。
  • 你可以使用 `timeDilation` 屬性進一步減慢過渡。

PhotoHero 類

#

自定義 PhotoHero 類維護 hero 及其大小、影像和點選時的行為。PhotoHero 構建以下 widget 樹

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 屬性提供時,`MaterialApp` 隱式推送起始路由。
  • 一個 `InkWell` 包裹影像,使其很容易為源和目標 hero 新增點選手勢。
  • 將 Material widget 定義為透明顏色,使影像在飛向目的地時能夠從背景中“彈出”。
  • `SizedBox` 指定 hero 在動畫開始和結束時的大小。
  • 將影像的 `fit` 屬性設定為 `BoxFit.contain`,確保影像在過渡期間儘可能大,同時不改變其縱橫比。

HeroAnimation 類

#

`HeroAnimation` 類建立源和目標 PhotoHeroes,並設定過渡。

這是程式碼

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` 建立目標路由。將目標路由推入 `Navigator` 的堆疊會觸發動畫。
  • `Container` 將 `PhotoHero` 定位在目標路由的左上角,位於 `AppBar` 下方。
  • 目標 `PhotoHero` 的 `onTap()` 方法彈出 `Navigator` 的堆疊,觸發動畫,將 `Hero` 飛回原始路由。
  • 在除錯時使用 `timeDilation` 屬性減慢過渡。

徑向 Hero 動畫

#

在將 hero 從一個路由飛到另一個路由時,將其從圓形形狀轉換為矩形形狀是一種巧妙的效果,你可以使用 Hero widget 實現。為此,程式碼動畫兩個剪裁形狀的交集:一個圓形和一個方形。在整個動畫過程中,圓形剪裁(和影像)從 `minRadius` 縮放到 `maxRadius`,而方形剪裁保持恆定大小。同時,影像從其在源路由中的位置飛到其在目標路由中的位置。有關此過渡的視覺示例,請參閱 Material motion 規範中的徑向變換

這個動畫可能看起來很複雜(確實如此),但你可以**根據你的需求自定義提供的示例。**繁重的工作已經為你完成。

發生了什麼?

#

下圖顯示了動畫開始 (`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 樹

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` 捕獲點選手勢。呼叫函式將 `onTap()` 函式傳遞給 `Photo` 的建構函式。
  • 在飛行過程中,`InkWell` 會在其第一個 Material 祖先上繪製其飛濺效果。
  • Material widget 具有輕微不透明的顏色,因此影像的透明部分會呈現顏色。這確保了圓形到方形的過渡易於檢視,即使是具有透明度的影像。
  • `Photo` 類在其 widget 樹中不包含 `Hero`。為了使動畫正常工作,hero 包裹了 `RadialExpansion` widget。

RadialExpansion 類

#

`RadialExpansion` widget 是演示的核心,它構建了在過渡期間剪裁影像的 widget 樹。剪裁形狀是由圓形剪裁(在飛行過程中增長)與矩形剪裁(始終保持恆定大小)的交集產生的。

為此,它構建了以下 widget 樹

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` 動畫由兩個重疊的剪裁建立。

  • 示例使用`MaterialRectCenterArcTween`定義 tween 插值。hero 動畫的預設飛行路徑使用英雄的角插值 tween。此方法在徑向變換期間影響 hero 的縱橫比,因此新的飛行路徑使用 `MaterialRectCenterArcTween` 使用每個英雄的中心點插值 tween。

    這是程式碼

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

    hero 的飛行路徑仍然遵循弧線,但影像的縱橫比保持不變。