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

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

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

Villain 容許你只需幾行代碼就能夠添加上面的頁面轉換。android

安裝包在這裏。你能夠在項目的 README 如何使用 Villains。這篇文章更側重於解釋 Heroes 和 Villains 以及全部這些背後的思考過程。ios

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


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

先簡單瞭解一下 Hero。github

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

概覽

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

1. 找到並匹配 Heroesapp

第一步是肯定哪些 Hero 存在以及哪些 Hero 具備相同的標記。ide

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 更簡單。

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。就在睡覺以前,我把全部的測試都放在一塊兒,以確保它真的沒問題。而後發生了這個:

每一個測試都只能單獨經過。

我很困惑。每次測試都成功。果真,當我本身運行這兩個測試時,它們很正常。可是當一塊兒運行全部測試時,最後兩個失敗了。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前端後端區塊鏈產品設計人工智能等領域,想要查看更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄

相關文章
相關標籤/搜索