[譯] Flutter 的 Heroes 和 Villains —— 為 Flutterverse 帶來平衡

DateBro發表於2019-03-01

這是一個關於 Heroes 和 Villains 如何執行的故事。

[譯] Flutter 的 Heroes 和 Villains —— 為 Flutterverse 帶來平衡

一個 Hero 常常與多個 Villain 相伴而生。

Villain 允許你只需幾行程式碼就可以新增上面的頁面轉換。

安裝包在這裡。你可以在專案的 README 如何使用 Villains。這篇文章更側重於解釋 Heroes 和 Villains 以及所有這些背後的思考過程。

Flutter 最令驚奇的一點是它為所有東西提供漂亮和乾淨的 API。我喜歡你使用 Hero 的方式。兩行簡單的程式碼,它就生效了。你只需要把 Hero 扔到這兩個地方,按照標籤分配,其它就不需要管了。


在你理解 Villain 之前,你必須先理解 Hero。

[譯] Flutter 的 Heroes 和 Villains —— 為 Flutterverse 帶來平衡

先簡單瞭解一下 Hero。

我們來快速瞭解一下 Hero 是如何實現的。

概覽

Hero 的動畫涉及三個主要步驟。

1. 找到並匹配 Heroes

第一步是確定哪些 Hero 存在以及哪些 Hero 具有相同的標記。

2. 確定 Hero 位置

然後,捕獲兩個 Hero 的位置並準備好旅程。

3. 啟動旅程

旅程始終在新螢幕上進行,而不在實際的元件中。在開始頁面上的元件在旅程期間被替換成空的佔位符元件 (SizedBox)。而使用 OverlayOverlay可以在所有內容上顯示元件)。

整個 Hero 動畫發生在正在開啟的頁面上。元件是完全獨立,不在頁面之間共享任何狀態的。


NavigationObserver

可以通過 NavigationObserver 觀察壓入和彈出路由的事件。

/// 一個管理 [Hero] 過渡的 [Navigator] observer。
///
/// 應該在 [Navigator.observers] 中使用 [HeroController] 的例項。
/// 這由 [MaterialApp] 自動完成。
class HeroController extends NavigatorObserver
複製程式碼

HeroController

Hero 使用這個類開始旅程。除了能夠自己新增 NavigationObservers 之外,MaterialApp 預設新增了 HeroController看一下這裡。

Hero 元件

  /// 建立一個 Hero
  ///
  /// [tag] 和 [child] 必須非空。
  const Hero({
    Key key,
    @required this.tag,
    this.createRectTween,
    @required this.child,
  }) : assert(tag != null),
       assert(child != null),
       super(key: key);
複製程式碼

Hero 的構造器

Hero 元件實際上並沒有做太多。它擁有 child 和 tag。除此之外,createRectTween 引數決定了 Hero 在飛往目的地時所採用的路由。預設的實現是 MaterialRectArcTween。顧名思義,它將 Hero 沿弧線移動到最終位置。

Hero 的狀態也負責捕獲大小並用佔位符替換自己。

_allHeroesFor

元素(具體元件)放在樹中。通過訪客,你可以沿著樹下去並收集資訊。

  // 返回上下文中所有 Hero 的 map,由 hero 標記索引。
  static Map<Object, _HeroState> _allHeroesFor(BuildContext context) {
    assert(context != null);
    final Map<Object, _HeroState> result = <Object, _HeroState>{};
    void visitor(Element element) {
      if (element.widget is Hero) {
        final StatefulElement hero = element;
        final Hero heroWidget = element.widget;
        final Object tag = heroWidget.tag;
        assert(tag != null);
        assert(() {
          if (result.containsKey(tag)) {
            throw new FlutterError(
              'There are multiple heroes that share the same tag within a subtree.\n'
              'Within each subtree for which heroes are to be animated (typically a PageRoute subtree), '
              'each Hero must have a unique non-null tag.\n'
              'In this case, multiple heroes had the following tag: $tag\n'
              'Here is the subtree for one of the offending heroes:\n'
              '${element.toStringDeep(prefixLineOne: "# ")}'
            );
          }
          return true;
        }());
        final _HeroState heroState = hero.state;
        result[tag] = heroState;
      }
      element.visitChildren(visitor);
    }
    context.visitChildElements(visitor);
    return result;
  }
複製程式碼

heroes.dart

在方法內部宣告瞭一個名為 visitor 的行內函數。context.visitChildElements(visitor) 方法和 element.visitChildren(vistor) 直到訪問完上下文的所有元素才呼叫函式。在每次訪問時,它會檢查這個 child 是否為 Hero,如果是,則將其儲存到 map 中。

旅程的開始

  // 在 from 和 to 中找到匹配的 Hero 對,並啟動新的 Hero 旅程,
  // 或轉移現有的 Hero 旅程。
  void _startHeroTransition(PageRoute<dynamic> from, PageRoute<dynamic> to, _HeroFlightType flightType) {
    // 如果在呼叫幀尾回撥之前刪除了導航器或其中一個路由子樹,
    // 那麼接下來實際上不會開始轉換。
    if (navigator == null || from.subtreeContext == null || to.subtreeContext == null) {
      to.offstage = false; // in case we set this in _maybeStartHeroTransition
      return;
    }

    final Rect navigatorRect = _globalBoundingBoxFor(navigator.context);

    // 在這一點上,toHeroes 可能是第一次建造和佈局。
    final Map<Object, _HeroState> fromHeroes = Hero._allHeroesFor(from.subtreeContext);
    final Map<Object, _HeroState> toHeroes = Hero._allHeroesFor(to.subtreeContext);

    // 如果 `to` 路由是在螢幕外的,
    // 那麼我們暗中將其動畫值恢復到它“移到”螢幕外之前的狀態。
    to.offstage = false;

    for (Object tag in fromHeroes.keys) {
      if (toHeroes[tag] != null) {
        final _HeroFlightManifest manifest = new _HeroFlightManifest(
          type: flightType,
          overlay: navigator.overlay,
          navigatorRect: navigatorRect,
          fromRoute: from,
          toRoute: to,
          fromHero: fromHeroes[tag],
          toHero: toHeroes[tag],
          createRectTween: createRectTween,
        );
        if (_flights[tag] != null)
          _flights[tag].divert(manifest);
        else
          _flights[tag] = new _HeroFlight(_handleFlightEnded)..start(manifest);
      } else if (_flights[tag] != null) {
        _flights[tag].abort();
      }
    }
  }
複製程式碼

heroes.dart

這會響應路由壓入/彈出事件而被呼叫。在第 14 行和第 15 行,你可以看到 _allHeroesFor 呼叫,它可以在兩個頁面上找到所有 Hero。從第 21 行開始構建 _HeroFlightManifest 並啟動旅程。從這裡開始,有一堆動畫的程式碼設定和邊緣情況的處理。我建議你看一下整個類,這很有意思,裡面還有很多值得學習的東西。你也可以看一下這個


Villains 是如何執行的

Villains 要比 Hero 更簡單。

[譯] Flutter 的 Heroes 和 Villains —— 為 Flutterverse 帶來平衡

Hero 和 3 個 Villain 使用(AppBar,Text,FAB)。

他們使用相同的機制來查詢給定上下文的所有 Villain,他們還使用 NavigationObserver 自動對頁面轉換做出反應。但不是從一個螢幕到另一個螢幕的動畫,而是僅在它們各自的螢幕上做的動畫。

SequenceAnimation 和 自定義 TickerProvider

處理動畫時,通常使用 SingleTickerProviderStateMixinTickerProviderStateMixin。在這種情況下,動畫不會在 StatefulWidget 中啟動,因此我們需要另一種方法來訪問 TickerProvider

class TransitionTickerProvider implements TickerProvider {
  final bool enabled;

  TransitionTickerProvider(this.enabled);

  @override
  Ticker createTicker(TickerCallback onTick) {
    return new Ticker(onTick, debugLabel: 'created by $this')..muted = !this.enabled;
  }
}
複製程式碼

自定義一個 ticker 非常簡單。所有這一切都是為了實現 TickerProvider 介面並返回一個新的 Ticker

  static Future playAllVillains(BuildContext context, {bool entrance = true}) {
    List<_VillainState> villains = VillainController._allVillainssFor(context)
      ..removeWhere((villain) {
        if (entrance) {
          return !villain.widget.animateEntrance;
        } else {
          return !villain.widget.animateExit;
        }
      });

    // 用於新頁面動畫的控制器,因為它的時間比實際頁面轉換更長

    AnimationController controller = new AnimationController(vsync: TransitionTickerProvider(TickerMode.of(context)));

    SequenceAnimationBuilder builder = new SequenceAnimationBuilder();

    for (_VillainState villain in villains) {
      builder.addAnimatable(
        anim: Tween<double>(begin: 0.0, end: 1.0),
        from: villain.widget.villainAnimation.from,
        to: villain.widget.villainAnimation.to,
        tag: villain.hashCode,
      );
    }

    SequenceAnimation sequenceAnimation = builder.animate(controller);

    for (_VillainState villain in villains) {
      villain.startAnimation(sequenceAnimation[villain.hashCode]);
    }

    //開始動畫
    return controller.forward().then((_) {
      controller.dispose();
    });
  }
複製程式碼

首先,所有不應該展示的 Villain(那些將 animateExit/animateEntrance 設定為 false 的人)都會被過濾掉。然後建立一個帶有自定義 TickerProviderAnimationController。使用 SequenceAnimation 庫,每個 Villain 被分配一個動畫,它們在各自的時間中執行 0.0 —— 1.0(fromto 持續時間)。最後,動畫全部開始。當它們全部完成時,控制器被丟棄。

Villains 的 build() 方法

  @override
  Widget build(BuildContext context) {
    Widget animatedWidget = widget.villainAnimation
        .animatedWidgetBuilder(widget.villainAnimation.animatable.chain(CurveTween(curve: widget.villainAnimation.curve)).animate(_animation), widget.child);
    if (widget.secondaryVillainAnimation != null) {
      animatedWidget = widget.secondaryVillainAnimation.animatedWidgetBuilder(
          widget.secondaryVillainAnimation.animatable.chain(CurveTween(curve: widget.secondaryVillainAnimation.curve)).animate(_animation), animatedWidget);
    }

    return animatedWidget;
  }
複製程式碼

這可能看起來很可怕,但請先忍耐一下。讓我們看看第 3 行和第 4 行。widget.villainAnimation.animatedWidgetBuilder 是一個自定義的 typedef:

typedef Widget AnimatedWidgetBuilder(Animation animation, Widget child);
複製程式碼

它的工作是返回一個根據動畫繪製的元件(大多數時候返回的元件是一個 AnimatedWidget)。

它得到了 Villain 的 child 和這個動畫:

widget.villainAnimation.animatable.chain(CurveTween(curve: widget.villainAnimation.curve)).animate(_animation)
複製程式碼

鏈方法首先評估 CurveTween。然後它使用該值來評估呼叫它的 animatable。這只是將所需的曲線新增到動畫中。

這是關於 Villain 如何工作的粗略概述,請務必也檢視原始碼並大膽地提出你們的問題。


可變的靜態變數很槽糕,讓我解釋一下

深夜,我坐在我的辦公桌前,寫下測試。幾個小時後,每一次單獨的測試都過去了,似乎沒有 bug。就在睡覺之前,我把所有的測試都放在一起,以確保它真的沒問題。然後發生了這個:

[譯] Flutter 的 Heroes 和 Villains —— 為 Flutterverse 帶來平衡

每個測試都只能單獨通過。

我很困惑。每次測試都成功。果然,當我自己執行這兩個測試時,它們很正常。但是當一起執行所有測試時,最後兩個失敗了。WTF。

第一反應顯然是:“我的程式碼肯定沒錯,它一定對測試的執行方式做了些什麼!也許測試是並行播放因此相互干擾?也許是因為我使用了相同的鍵?”

Brian Egan 向我指出,刪除一個特定的測試修復了錯誤並將其移到頂部使得其他所有測試也失敗了。如果那不是“共享資料”那麼我不知道是什麼。

當我發現問題是什麼時,我忍不住笑了。這正是在某些情況下使用靜態變數不好的原因。

基本上,預定義的動畫都是靜態的。我懶得為每個動畫編寫一個方法來獲取 VillainAnimation 所需的所有引數。所以我使 VillainAnimation 是可變的(壞主意)。這樣我就沒有必要在方法中明確寫出所有必要的引數。使用時看起來像這樣:

Villain(
  villainAnimation: VillainAnimation.fromBottom(0.4)
    ..to = Duration(milliseconds: 150),
  child: Text("HI"),
)
複製程式碼

打破一切的測試應該在頁面轉換完成後開始測試 Villain 轉換。它將動畫的起點設定為 1 秒。因為它是在靜態引用上設定它,之後的測試使用它作為預設值。測試失敗,因為動畫無法在 1 秒到 750 毫秒之間執行。

修復很簡單(使一切都不可變並在方法中傳遞引數)但我仍然覺得這個小錯誤非常有趣。


總結

感謝 Villain 恢復了好壞之間的平衡。

關於 #fluttervillains 的意見和討論是受歡迎的。如果你使用 Villain 一起製作很酷的動畫,我很希望看到它。

我的 Twitter: @norbertkozsir

如果發現譯文存在錯誤或其他需要改進的地方,歡迎到 掘金翻譯計劃 對譯文進行修改並 PR,也可獲得相應獎勵積分。文章開頭的 本文永久連結 即為本文在 GitHub 上的 MarkDown 連結。


掘金翻譯計劃 是一個翻譯優質網際網路技術文章的社群,文章來源為 掘金 上的英文分享文章。內容覆蓋 AndroidiOS前端後端區塊鏈產品設計人工智慧等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄

相關文章