跳到主內容

英雄動畫

如何讓元件在兩個螢幕之間飛入飛出。

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

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

在新標籤頁中觀看 YouTube 影片:“Hero | Flutter 本週元件”

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

你可以使用 Hero 元件在 Flutter 中建立此動畫。當 Hero 從源路由動畫到目標路由時,目標路由(除去 Hero 本身)會淡入視野。通常,Hero 是 UI 的一小部分(如影像),且兩個路由都包含該部分。從使用者的角度來看,Hero 在路由之間“飛行”。本指南展示瞭如何建立以下 Hero 動畫

標準 Hero 動畫

標準 Hero 動畫將 Hero 從一個路由飛向另一個新路由,通常在不同的位置以不同的尺寸著陸。

以下影片(以慢速錄製)展示了一個典型的例子。點選路由中心的腳蹼(flippers),它們會飛到新藍色路由的左上角,並縮小尺寸。點選藍色路由中的腳蹼(或使用裝置的返回上一路由手勢),腳蹼會飛回原始路由。

在新標籤頁中觀看 YouTube 影片:“Flutter 中的標準 Hero 動畫”

徑向 Hero 動畫

徑向 Hero 動畫中,當 Hero 在路由之間飛行時,其形狀看起來會從圓形變為矩形。

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

在新標籤頁中觀看 YouTube 影片:“Flutter 中的徑向 Hero 動畫”

在進入標準徑向 Hero 動畫的具體章節之前,請閱讀Hero 動畫的基本結構以瞭解如何組織程式碼,並閱讀幕後原理以瞭解 Flutter 如何執行 Hero 動畫。

Hero 動畫的基本結構

#

Hero 動畫是使用兩個 Hero 元件實現的:一個描述源路由中的元件,另一個描述目標路由中的元件。從使用者的角度來看,Hero 看起來是共享的,只有開發人員需要了解這個實現細節。Hero 動畫程式碼具有以下結構

  1. 定義一個起始 Hero 元件,稱為源 Hero。Hero 指定了其圖形表示(通常是影像)和一個標識標籤(tag),並且位於源路由定義的當前顯示元件樹中。
  2. 定義一個結束 Hero 元件,稱為目標 Hero。該 Hero 也指定了其圖形表示,以及與源 Hero 相同的標籤。至關重要的是,兩個 Hero 元件必須使用相同的標籤建立,通常是一個代表底層資料的物件。為了獲得最佳效果,這些 Hero 應具有幾乎相同的元件樹。
  3. 建立一個包含目標 Hero 的路由。目標路由定義了動畫結束時存在的元件樹。
  4. 透過將目標路由推入導航器的堆疊來觸發動畫。導航器的推送和彈出操作會為源路由和目標路由中每對標籤匹配的 Hero 觸發 Hero 動畫。

Flutter 計算將 Hero 邊界從起點動畫到終點(插值計算尺寸和位置)的補間,並在覆蓋層中執行動畫。

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

幕後原理

#

以下內容描述了 Flutter 如何執行從一個路由到另一個路由的過渡。

Before the transition the source hero appears in the source route

在過渡之前,源 Hero 等待在源路由的元件樹中。目標路由尚不存在,覆蓋層為空。


The transition begins

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

  • 按照 Material 運動規範中所述的曲線運動,在螢幕外計算目標 Hero 的路徑。Flutter 現在知道 Hero 的終點位置。

  • 將目標 Hero 放置在覆蓋層中,位置和大小與 Hero 相同。將 Hero 新增到覆蓋層會改變其 Z 軸順序,使其出現在所有路由的上方。

  • 將源 Hero 移出螢幕。


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

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


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

飛行完成時

  • Flutter 將 Hero 元件從覆蓋層移動到目標路由。覆蓋層現在為空。

  • 目標 Hero 出現在目標路由的最終位置。

  • 源 Hero 被恢復到其原始路由。


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

關鍵類

#

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

Hero

從源路由飛向目標路由的元件。為源路由定義一個 Hero,為目標路由定義另一個 Hero,併為每個 Hero 分配相同的標籤。Flutter 會對具有匹配標籤的 Hero 對進行動畫處理。

InkWell

指定點選 Hero 時會發生什麼。InkWellonTap() 方法構建新路由並將其推入 Navigator 的堆疊。

Navigator

Navigator 管理路由堆疊。將路由推入或從 Navigator 堆疊中彈出路由會觸發該動畫。

Route

指定螢幕或頁面。除了最基礎的應用外,大多數應用都有多個路由。

標準 Hero 動畫

#

發生了什麼?

#

使用 Flutter 的 Hero 元件可以輕鬆實現將影像從一個路由飛向另一個路由。使用 MaterialPageRoute 指定新路由時,影像會按照 Material Design 運動規範中所述,沿著曲線路徑飛行。

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

執行示例

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

PhotoHero 類

#

自定義的 PhotoHero 類維護著 Hero 及其尺寸、影像和點選時的行為。PhotoHero 構建了以下元件樹

PhotoHero class widget tree

程式碼如下

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 和目標 Hero 新增點選手勢。
  • 將 Material 元件定義為透明顏色,使得影像在飛向目的地時能夠從背景中“彈出”。
  • 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 建立目標路由。將目標路由推送到 Navigator 的堆疊會觸發動畫。
  • ContainerPhotoHero 定位在目標路由的左上角,位於 AppBar 下方。
  • 目標 PhotoHeroonTap() 方法會彈出 Navigator 的堆疊,觸發將 Hero 飛回原始路由的動畫。
  • 除錯時使用 timeDilation 屬性減慢過渡速度。

徑向 Hero 動畫

#

將 Hero 從一個路由飛向另一個路由並將其從圓形變為矩形,是一種可以使用 Hero 元件實現的炫酷效果。為了實現這一點,程式碼對兩個剪裁形狀的交集進行了動畫處理:一個圓形和一個正方形。在整個動畫過程中,圓形剪裁(和影像)從 minRadius 縮放到 maxRadius,而方形剪裁保持恆定尺寸。與此同時,影像從其在源路由中的位置飛向其在目標路由中的位置。有關此過渡的視覺示例,請參閱 Material 運動規範中的徑向變換

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

發生了什麼?

#

下圖顯示了動畫開始(t = 0.0)和結束(t = 1.0)時的剪裁影像。

Radial transformation from beginning to end

藍色漸變(代表影像)表示剪裁形狀的交集位置。在過渡開始時,交集的結果是圓形剪裁(ClipOval)。在變換過程中,ClipOvalminRadius 縮放到 maxRadius,而 ClipRect 保持恆定尺寸。在過渡結束時,圓形和矩形剪裁的交集產生一個與 Hero 元件大小相同的矩形。換句話說,在過渡結束時,影像不再被剪裁。

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

執行示例

  • 點選三個圓形縮圖之一,將影像動畫化為一個更大的正方形,並放置在一個覆蓋原始路由的新路由中間。
  • 透過點選影像,或使用裝置的返回上一路由手勢,返回到上一個路由。
  • 你可以使用 timeDilation 屬性進一步減慢過渡速度。

Photo 類

#

Photo 類構建了包含影像的元件樹

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 元件具有輕微的不透明顏色,因此影像的透明部分會被渲染為彩色。這確保了即使對於帶有透明度的影像,圓形到正方形的過渡也清晰可見。
  • Photo 類在其元件樹中不包含 Hero。為了使動畫正常工作,Hero 包裝了 RadialExpansion 元件。

RadialExpansion 類

#

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

為此,它構建了以下元件樹

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 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 的中心點來插值補間。

    程式碼如下

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

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