谈一谈Flutter中的共享元素动画Hero

栏目: IOS · Android · 发布时间: 6年前

内容简介:如果你是一名安卓开发者,应该很熟悉 **共享元素变换(Shared Element Transition)**这个概念,它可以通过几行代码,就在两个Activity或者Fragment之间做出流畅的转场动画。Google把这个概念也带到了Flutter里面,这就是我们今天要讲的主角——Hero控件。通过Hero,我们可以在两个路由之间,做出流畅的转场动画。注意,是两个路由(Route),在Flutter里面,Dialog也是路由,因此完全可以使用在Dialog的切换上。我们看下效果图:

如果你是一名安卓开发者,应该很熟悉 **共享元素变换(Shared Element Transition)**这个概念,它可以通过几行代码,就在两个Activity或者Fragment之间做出流畅的转场动画。

Google把这个概念也带到了Flutter里面,这就是我们今天要讲的主角——Hero控件。通过Hero,我们可以在两个路由之间,做出流畅的转场动画。注意,是两个路由(Route),在Flutter里面,Dialog也是路由,因此完全可以使用在Dialog的切换上。

我们看下效果图:

谈一谈Flutter中的共享元素动画Hero

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中的配置。目标Hero没有配置 createRectTween 时,才会使用源Hero的 createRectTween

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 这个监听,会返回给我们4个值: PageRoute<dynamic> from (源路由)、 PageRoute<dynamic> to (目标路由)、 Animation<double> animationHeroFlightDirection flightType

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);
    ······
}
复制代码

小练习
谈一谈Flutter中的共享元素动画Hero

在Dribble上找到了这个设计图,我觉得用来联系Hero转换再适合不过了,大家可以按照这个设计来练练手。

具体设计稿请看: dribbble.com/shots/54098…

参考Demo: gitee.com/yumi0629/Fl…


以上所述就是小编给大家介绍的《谈一谈Flutter中的共享元素动画Hero》,希望对大家有所帮助,如果大家有任何疑问请给我留言,小编会及时回复大家的。在此也非常感谢大家对 码农网 的支持!

查看所有标签

猜你喜欢:

本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们

C程序设计语言

C程序设计语言

(美)Brian W. Kernighan、(美)Dennis M. Ritchie / 徐宝文、李志译、尤晋元审校 / 机械工业出版社 / 2004-1 / 30.00元

在计算机发展的历史上,没有哪一种程序设计语言像C语言这样应用广泛。本书原著即为C语言的设计者之一Dennis M.Ritchie和著名计算机科学家Brian W.Kernighan合著的一本介绍C语言的权威经典著作。我们现在见到的大量论述C语言程序设计的教材和专著均以此书为蓝本。原著第1版中介绍的C语言成为后来广泛使用的C语言版本——标准C的基础。人们熟知的“hello,World"程序就是由本书......一起来看看 《C程序设计语言》 这本书的介绍吧!

SHA 加密
SHA 加密

SHA 加密工具

html转js在线工具
html转js在线工具

html转js在线工具