談一談Flutter中的共享元素動畫Hero

吉原拉麵發表於2018-10-22

  如果你是一名安卓開發者,應該很熟悉 **共享元素變換(Shared Element Transition)**這個概念,它可以通過幾行程式碼,就在兩個Activity或者Fragment之間做出流暢的轉場動畫。
  Google把這個概念也帶到了Flutter裡面,這就是我們今天要講的主角——Hero控制元件。通過Hero,我們可以在兩個路由之間,做出流暢的轉場動畫。注意,是兩個路由(Route),在Flutter裡面,Dialog也是路由,因此完全可以使用在Dialog的切換上。
  我們看下效果圖:

shared_element.gif

Hero的使用

  我們現在有兩個元素:源控制元件和目標控制元件。要實現元素共享,首先,我們要將兩個控制元件分別用Hero包裹,同時為它們設定相同的tag。
  源路由中的Hero:

Hero(
        tag: 'hero',
        child: Container(
          color: Colors.lightGreen,
          width: 50.0,
          height: 50.0,
        ));
複製程式碼

  目標路由中的Hero:

Hero(
        tag: 'hero',
        child: Container(
          color: Colors.orange,
          width: 150.0,
          height: 120.0,
        ));
複製程式碼

  接著,給源路由頁面新增路由跳轉邏輯:

GestureDetector(
      child: Hero(
          tag: 'hero',
          child: Container(
             color: Colors.orange,
             width: 150.0,
             height: 120.0,
          )),
      onTap: () {
        Navigator.of(context).push(MaterialPageRoute(builder: (_) {
          return ElementDetailPage();
        }));
      },
    );
複製程式碼

  就是這麼簡單,只需兩步,你就可以完成這個Hero過度動畫了,是不是超級方便呢?

Hero變換時做了什麼?

  Hero就是一個動畫,所以我們將其拆分成三部分來說:動畫開始時、動畫進行中和動畫結束時。

動畫開始時:t=0.0

談一談Flutter中的共享元素動畫Hero
在這個時間點,Flutter做了三件事:

  • 計算目標Hero的位置,然後算出對應的Rect;
  • 把源Hero複製一份,繪製到Overlay上(就是繪製一個與源Hero大小、位置完全相同的Hero,作為目標Hero),然後改變它的Z軸屬性,讓它能顯示在所有路由之上;
  • 把源Hero移出螢幕。

動畫進行時

談一談Flutter中的共享元素動畫Hero
動畫的進行是依靠了 Tween 來實現的,這個東西在寫動畫時總是會用到,大家應該不陌生;通過Hero的createRectTween屬性,將這個變換Tween傳給Hero,Hero內部進行移動動畫的操作。預設情況下,使用的變換是MaterialRectArcTween,注意,這個預設的變換路徑是一條曲線

動畫結束時:t=1.0

談一談Flutter中的共享元素動畫Hero
當移動結束時:

  • Flutter將Overlay中的Hero移除,現在Overlay中就是空白的了;
  • 目標Hero出現在目標路由的最終位置;
  • 源Hero在源路由中被恢復。

此處劃重點!!
  源Hero與目標Hero大小應一致,否則會出現溢位(overflow)!! overflow這個警告我們應該不陌生了,Flutter中必須隨時遵循佈局原則,一不小心就會給你送上overflow大禮包。

createRectTween是個什麼東西

  我們通過自定義createRectTween,可以改變轉換動畫。下面是一個很簡單的設定createRectTween屬性的例子:

createRectTween: (Rect begin, Rect end) {
              return RectTween(
                begin: Rect.fromLTRB(
                    begin.left, begin.top, begin.right, begin.bottom),
                end: Rect.fromLTRB(end.left, end.top, end.right, end.bottom),
              );
            }
複製程式碼

  至於如何自定義createRectTween,可以看一下預設的MaterialRectArcTween的實現,主要是重寫下面三個方法:

  @override
    set begin(Rect value) { }

  @override
    set end(Rect value) { }

  @override
    Rect lerp(double t) { }
複製程式碼

  自定義一個RectTween很複雜,這裡不展開講了。
  這裡要注意一個坑:createRectTween屬性會優先選用目標Hero中的配置。

Tween<Rect> _doCreateRectTween(Rect begin, Rect end) {
    final CreateRectTween createRectTween = manifest.toHero.widget.createRectTween ?? manifest.createRectTween;
    if (createRectTween != null)
      return createRectTween(begin, end);
    return RectTween(begin: begin, end: end);
  }
複製程式碼

  Hero的預設變換為MaterialRectArcTween
  所以,如果你想要push、pop都遵循自定義的RectTween,請給fromHero和toHero都設定createRectTween屬性。如果只設定fromHero的createRectTween屬性,則push時執行自定義createRectTween,pop時執行預設的MaterialRectArcTween。

Hero的實現

  Hero中所有的變換,都是通過HeroController來實現的。但是,開啟Hero類的原始碼,你會發現,這個Hero控制元件內部什麼事情也沒有做,也沒有沒有繫結HeroController,只是純粹地在build方法中建立了一個普通的widget。
  但是,思考一下,Hero是一個與路由相關的動畫控制元件,它並不是一個簡單的Widget,能管理路由切換動畫。這麼看來,Hero似乎應該屬於一個App級別的全域性控制元件(準確地說應該是HeroController)。不知道Flutter團隊是不是這麼想的,實際上,HeroController確實是在App級別就被初始化,並且和NavigatorObserver繫結了。這樣,每次Navigator進行push/pop操作時,HeroController都會收到通知。
  我們可以開啟MaterialApp的原始碼:

@override
  void initState() {
    super.initState();
    _heroController = HeroController(createRectTween: _createRectTween);
    _updateNavigator();
  }

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

void _updateNavigator() {
    if (widget.home != null ||
        widget.routes.isNotEmpty ||
        widget.onGenerateRoute != null ||
        widget.onUnknownRoute != null) {
      _navigatorObservers = List<NavigatorObserver>.from(widget.navigatorObservers)
        ..add(_heroController);
    } else {
      _navigatorObservers = null;
    }
  }
複製程式碼

  在MaterialApp初始化狀態的時候,就初始化好了_heroController,並且在_updateNavigator()方法中將其與_navigatorObservers繫結。_createRectTween返回的是一個MaterialRectArcTween,這解釋了之前提到的一個知識點:預設的Hero動畫的Rect是一個MaterialRectArcTween。
  那麼新的疑問又來了,我們現在有了_heroController,這個_heroController是怎麼和我們佈局中的Hero控制元件聯絡起來的呢?
  我們來看HeroController的原始碼:

@override
  void didPush(Route<dynamic> route, Route<dynamic> previousRoute) {
    ······
    _maybeStartHeroTransition(previousRoute, route, HeroFlightDirection.push);
  }

  @override
  void didPop(Route<dynamic> route, Route<dynamic> previousRoute) {
    ······
    _maybeStartHeroTransition(route, previousRoute, HeroFlightDirection.pop);
  }
複製程式碼

  在頁面push和pop的時候,都呼叫了同一個方法_maybeStartHeroTransition()

void _maybeStartHeroTransition(Route<dynamic> fromRoute, Route<dynamic> toRoute, , HeroFlightDirection flightType) {
      ······
      WidgetsBinding.instance.addPostFrameCallback((Duration value) {
        _startHeroTransition(from, to, animation, flightType);
      });
    }
  }
複製程式碼

  這裡的WidgetsBinding的作用,就是將源路由與目標路由,和_heroController關聯起來。WidgetsBinding.instance.addPostFrameCallback這個監聽,會在當前幀繪製完成後觸發。

  void _startHeroTransition(
    PageRoute<dynamic> from,
    PageRoute<dynamic> to,
    Animation<double> animation,
    HeroFlightDirection flightType,
  ) {
    // If the navigator or one of the routes subtrees was removed before this
    // end-of-frame callback was called, then don't actually start a transition.
    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);

    // At this point the toHeroes may have been built and laid out for the first time.
    final Map<Object, _HeroState> fromHeroes = Hero._allHeroesFor(from.subtreeContext);
    final Map<Object, _HeroState> toHeroes = Hero._allHeroesFor(to.subtreeContext);

    // If the `to` route was offstage, then we're implicitly restoring its
    // animation value back to what it was before it was "moved" offstage.
    to.offstage = false;

    for (Object tag in fromHeroes.keys) {
      if (toHeroes[tag] != null) {
        final HeroFlightShuttleBuilder fromShuttleBuilder = fromHeroes[tag].widget.flightShuttleBuilder;
        final HeroFlightShuttleBuilder toShuttleBuilder = toHeroes[tag].widget.flightShuttleBuilder;

        final _HeroFlightManifest manifest = _HeroFlightManifest(
          type: flightType,
          overlay: navigator.overlay,
          navigatorRect: navigatorRect,
          fromRoute: from,
          toRoute: to,
          fromHero: fromHeroes[tag],
          toHero: toHeroes[tag],
          createRectTween: createRectTween,
          shuttleBuilder:
              toShuttleBuilder ?? fromShuttleBuilder ?? _defaultHeroFlightShuttleBuilder,
        );

        if (_flights[tag] != null)
          _flights[tag].divert(manifest);
        else
          _flights[tag] = _HeroFlight(_handleFlightEnded)..start(manifest);
      } else if (_flights[tag] != null) {
        _flights[tag].abort();
      }
    }
  }
複製程式碼

   _startHeroTransition()的內容比較多,而且都很重要,我就直接全部貼上來了。首先,通過_allHeroesFor()找到源路由和目標路由頁面中所有的Hero控制元件,然後對比Tag,如果找到了tag一致的Hero,那麼就構建一份_HeroFlightManifest,這個清單裡面包括了頁面變換所需要的各種屬性。最後,呼叫_flights[tag] = _HeroFlight(_handleFlightEnded)..start(manifest);函式,開始變換。至於變換的具體動畫實現,這裡就不多說了,主要是通過start()函式開啟動畫,更新Hero的位置:

void start(_HeroFlightManifest initialManifest) {
    ······
    if (manifest.type == HeroFlightDirection.pop)
      _proxyAnimation.parent = ReverseAnimation(manifest.animation);
    else
      _proxyAnimation.parent = manifest.animation;

    manifest.fromHero.startFlight();
    manifest.toHero.startFlight();

    heroRectTween = _doCreateRectTween(
      _globalBoundingBoxFor(manifest.fromHero.context),
      _globalBoundingBoxFor(manifest.toHero.context),
    );

    overlayEntry = OverlayEntry(builder: _buildOverlay);
    manifest.overlay.insert(overlayEntry);
  }
複製程式碼

   結束動畫時,我們可以看到,overlayEntry中的控制元件被remove掉了。

  void _handleAnimationUpdate(AnimationStatus status) {
    if (status == AnimationStatus.completed || status == AnimationStatus.dismissed) {
      _proxyAnimation.parent = null;

      assert(overlayEntry != null);
      overlayEntry.remove();
      overlayEntry = null;

      manifest.fromHero.endFlight();
      manifest.toHero.endFlight();
      onFlightEnded(this);
    }
  }
複製程式碼

   當目標路由被pop的時候又會發生什麼呢?因為pop的時候,也是執行的_startHeroTransition()方法,跟push的時候是一樣的,只不過執行的動畫是反著的,就不多說了:

void _startHeroTransition(
    PageRoute<dynamic> from,
    PageRoute<dynamic> to,
    Animation<double> animation,
    HeroFlightDirection flightType,
  ) {
    ······
    _flights[tag] = _HeroFlight(_handleFlightEnded)..start(manifest);
    ·······
}

void start(_HeroFlightManifest initialManifest) {
  ······
    if (manifest.type == HeroFlightDirection.pop)
        _proxyAnimation.parent = ReverseAnimation(manifest.animation);
    ······
}
複製程式碼

小練習
dribbble_shopper.gif

   在Dribble上找到了這個設計圖,我覺得用來聯絡Hero轉換再適合不過了,大家可以按照這個設計來練練手。
具體設計稿請看:dribbble.com/shots/54098…
參考Demo:gitee.com/yumi0629/Fl…

相關文章