主動畫 (Hero animations)

你可能經常遇到 hero 動畫。比如,頁面上顯示的代售商品列表。選擇一件商品後,應用會跳轉至包含更多細節以及“購買”按鈕的新頁面。在 Flutter 中,影象從當前頁面轉到另一個頁面稱為 hero 動畫,相同的動作有時也被稱為 共享元素過渡

下面的一分鐘影片介紹了 Hero widget:

這個指南示範瞭如何建立標準 hero 動畫,以及 hero 動畫如何在飛行過程中將影象形狀由圓形變成正方形。

您可以在 Flutter 中使用 Hero widgets 建立這個動畫。當 hero 動畫從原頁面到目標頁面,目標頁面(減去 hero)淡入視野。可以說,heroes 是 UI 的一小部分,就像影象,兩個頁面有共同之處。從使用者的角度來說,hero 在頁面間「飛翔」。本指南展示如何建立如下 hero 動畫:

標準 hero 動畫

一個 標準 hero 動畫 使 hero 從一頁飛至新頁面,通常以不同大小到達不同的目的地。

下面的影片(慢放)示範了一個典型範例。點選頁面中間的 flippers,它將飛至一個新的藍色頁面的左上角,並縮小。點選藍色頁面中的 flippers(或者使用裝置的回到前頁手勢),它將返回原頁面。

徑向 hero 動畫

徑向 hero 動畫 中,隨著 hero 在頁面間飛翔,它的形狀也會有圓形變成矩形。

下面的影片(慢放)示範了一個徑向 hero 動畫的範例。開始,一排三個圓形的影象在頁面底部。點選任意圓形影象,其飛至新頁面,並變成正方形。點選正方形影象,hero 返回至原頁面,並變回圓形。

在學習 標準徑向 hero 動畫之前,請閱讀 hero 動畫基本結構 來學習如何建構 hero 動畫程式碼,以及 幕後 來了解 Flutter 如何顯示一個 hero 動畫。

hero 動畫基本結構

Hero 動畫需要使用兩個 Hero widgets 來實現:一個用來在原頁面中描述 widget,另一個在目標頁面中描述 widget。從使用者角度來說,hero 似乎是分享的,只有程式設計師需要了解實施細節。

Hero 動畫程式碼有如下結構:

  1. 定義一個起始 Hero widget,被稱為 source hero。該 hero 指定圖形表示(通常是影象),以及識別標籤,並且在由原頁面定義的當前顯示的 widget 樹中。

  2. 定義一個截至 Hero widget,被稱為 destination hero。該 hero 也指定圖形表示,並與 source hero 使用同樣的標籤。 這是基本,兩個 hero widgets 要建立相同的標籤,通常是代表基礎資料的物件。為了獲得最佳效果,heroes 應該有幾乎完全相同的 widget 樹。

  3. 建立一個含有 destination hero 的頁面。目標頁面定義了動畫結束時應有的 widget 樹。

  4. 透過推送目標頁面到 Navigator 堆疊來觸發動畫。 Navigator 推送並彈出操作觸發原頁面和目標頁面中含有配對標籤 heroes 的 hero 動畫。

Flutter 設定了 tween 用來界定 Hero 從起點到終點的界限(插入大小和位置),並在圖層上執行動畫。

下一章節將更詳細地介紹 Flutter 的過程。

幕後

下面將介紹 Flutter 如何執行一個頁面到另一頁面的過渡。

在過渡之前 source hero 出現在原頁面中

過渡前,source hero 在原頁面的 widget 樹中等待。而目標頁面此時並不存在,圖層也是空的。


過渡開始

推送一個頁面到 Navigator 來觸發動畫。t=0.0 時,Flutter 執行如下動作:

  • 使用 Material motion spec 中介紹的曲線運動計算 destination hero 路徑,後臺執行。 Flutter 限制知道 hero 應在何處終止。

  • 將 destination hero 放到圖層,與 source hero 相同的位置和大小。新增一個 hero 到圖層改變其 Z 軸的順序,這樣才可以出現在所有頁面的上面。

  • 將 source hero 移至後臺執行。


The hero flies in the overlay to its final position and size (hero 飛入圖層到達其最終位置和大小)

hero 飛翔時,它的矩形邊界使用 Hero 的 createRectTween 屬性中特定的 Tween<Rect> 進行動畫。預設情況下,Flutter 使用 MaterialRectArcTween 的範例,它沿著一個曲線路徑設定矩形對角動畫。(參考 徑向 hero 動畫,該範例使用了不同的補間動畫)


When the transition is complete, the hero is moved from the overlay to the destination route (當過渡完成時,hero 從圖層移動到目的頁面)

當飛翔完成時:

  • Flutter 將 hero widget 從圖層移動到目標頁面。圖層現在是空的。

  • destination hero 出現在目標圖層的最終位置。

  • source hero 被儲存到原頁面中。


彈出的頁面執行同樣的過程,hero 動畫回到原頁面並回復原來大小和位置。

基本類

本指南中的範例使用瞭如下類來實現 hero 動畫:

Hero
從原頁面飛到目標頁面的 widget。定義一個原頁面的 Hero 和另一個目標頁面的 Hero,並設定相同的標籤。 Flutter 為成對的含有匹配標籤的 heroes 設定動畫。

Inkwell
指定點選 hero 時發生什麼。 InkWell 的 onTap() 方法可以建立新頁面並推送至 Navigator 的堆疊。

Navigator
Navigator 管理一個頁面堆疊。推送或彈出 Navigator 堆疊中的頁面觸發動畫。

Route
指定螢幕或頁面。除最基本的應用程式外,大部分含有多頁面。

標準 hero 動畫

然後呢?

使用 Flutter 的 hero widget 可以輕鬆實現影象由一個頁面飛至另一個。當使用 MaterialPageRoute 指定新頁面時,影象將沿 Material Design motion spec 中介紹的曲線路徑飛翔。

建立一個新的 Flutter 範例 並使用來自 hero_animation 的檔案更新。

執行範例:

  • 點選主頁的圖片使影象飛至新頁面並在不同位置以不同規格顯示相同圖片。

  • 點選影象或使用裝置的回到前頁手勢返回之前頁面。

  • 可以使用 timeDilation 屬性來減緩過渡。

PhotoHero 類

自訂的 PhotoHero 類保留了 hero 以及其大小,影象,和點選時的動作。PhotoHero 建立如下 widget 樹:

PhotoHero class widget tree

程式碼如下:

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

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

  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 作為應用程式的主頁屬性時,起始頁面由 MaterialApp 隱含推送。

  • InkWell 裹挾影象,使得為 source hero 和 destination hero 新增點選動作變得簡單。

  • 用透明色定義 Material widget 使圖片在飛至目標頁時可以從背景中“彈出”。

  • SizedBox 指定動畫起始和結束時 hero 的大小。

  • 設定影象的 fit 屬性到 BoxFit.contain,可以確保在過渡過程中儘可能放大,且不改變長寬比例。

HeroAnimation 類

HeroAnimation 類可以建立 source PhotoHero 和 destination PhotoHero,並建立過渡。

程式碼如下:

class HeroAnimation extends StatelessWidget {
  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: (BuildContext context) {
                return Scaffold(
                  appBar: AppBar(
                    title: const Text('Flippers Page'),
                  ),
                  body: Container(
                    // The blue background emphasizes that it's a new route.
                    color: Colors.lightBlueAccent,
                    padding: const EdgeInsets.all(16.0),
                    alignment: Alignment.topLeft,
                    child: PhotoHero(
                      photo: 'images/flippers-alpha.png',
                      width: 100.0,
                      onTap: () {
                        Navigator.of(context).pop();
                      },
                    ),
                  ),
                );
              }
            ));
          },
        ),
      ),
    );
  }
}

重要資訊:

  • 當用戶點選含有 source hero 的 InkWell 時,程式碼使用 MaterialPageRoute 產生目標頁面。並將目標頁面推送至 Navigator 堆疊,觸發動畫。

  • ContainerPhotoHero 置於目標頁面左上角,AppBar 的下方。

  • 目標頁 PhotoHeroonTap() 函式會彈出 Navigator 的堆疊,觸發動畫 Hero 飛回至原頁面。

  • 在除錯時,可以使用 timeDilation 屬性來減緩過渡。


徑向 hero 動畫

hero 從一個頁面飛至另一頁的同時由圓形過渡到矩形,這是一個滑入效果,可使用 Hero widgets 來實現。要做到這一點,程式碼需要動畫兩個剪裁形狀的交叉:一個圓形和一個正方形。整個動畫中,圓形剪裁(和圖片)由 minRadius 縮放到 maxRadius,而正方形剪裁保持大小不變。同時,影象從原頁面飛至目標頁面的相同位置。這個過渡的效果範例,請參見 Material motion spec 中的 徑向過渡

這個動畫看起來複雜,但是您可以根據自身需要自訂範例。艱鉅的工作已為您完成。

然後呢?

下面的圖表顯示了在動畫起始(t = 0.0)和結束(t = 1.0)時的剪裁影象。

Radial transformation from beginning to end

藍色漸變(代表影象),表明剪裁形狀交叉的位置。在過渡的開始,交叉的結果是圓形剪裁 (ClipOval)。在過渡過程中,ClipOval 由 minRadius 縮放至 maxRadiusClipRect 則保持原尺寸。在過渡結束時,圓形和矩形剪裁的交集產生一個與 hero widget 相同大小的矩形。也就是說,在過渡結束時,圖片已不再被剪裁。

建立一個新的 Flutter 範例 並使用來自 radial_hero_animation 的檔案更新。

執行範例:

  • 點選三個圓形縮圖中的任意一個,使影象變成位於新頁面中間的一個較大的正方形,且覆蓋原頁面。

  • 點選圖片或使用裝置的返回手勢,返回之前頁面。

  • 可以使用 timeDilation 屬性來減緩過渡。

Photo 類

Photo 類建立儲存影象的 widget 樹:

class Photo extends StatelessWidget {
  Photo({ Key key, this.photo, this.color, this.onTap }) : super(key: key);

  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.withOpacity(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,demo 的核心,建立過渡過程中剪裁影象的 widget 樹。剪裁的形狀來自於圓形剪裁(飛翔過程中增長)和矩形剪裁(自始至終保持一致大小)的交集。

為此,它建立了如下 widget 樹:

RadialExpansion widget tree

程式碼如下:

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

  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 飛翔時會改變大小,因為它限制了 child 的大小,所以 RadialExpansion widget 會改變大小以匹配。

  • RadialExpansion 動畫由兩個重疊的剪裁建立。

  • 這個範例用 MaterialRectCenterArcTween 定義了補間插值。 hero 動畫的預設飛翔路徑,利用 heroes 的角插值補間。這個方法會影響到徑向過渡時 hero 的長寬比例,所以新的飛翔路徑使用 MaterialRectCenterArcTween 方法,利用每個 hero 的中心點來插值補間。

    程式碼如下:

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

    Hero 的飛行路線仍然是一個弧線,但影象的長寬比將保持不變。