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

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

内容简介:如果你是一名安卓开发者,应该很熟悉 **共享元素变换(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》,希望对大家有所帮助,如果大家有任何疑问请给我留言,小编会及时回复大家的。在此也非常感谢大家对 码农网 的支持!

查看所有标签

猜你喜欢:

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

Python Algorithms

Python Algorithms

Magnus Lie Hetland / Apress / 2010-11-24 / USD 49.99

Python Algorithms explains the Python approach to algorithm analysis and design. Written by Magnus Lie Hetland, author of Beginning Python, this book is sharply focused on classical algorithms, but it......一起来看看 《Python Algorithms》 这本书的介绍吧!

SHA 加密
SHA 加密

SHA 加密工具

RGB HSV 转换
RGB HSV 转换

RGB HSV 互转工具

RGB CMYK 转换工具
RGB CMYK 转换工具

RGB CMYK 互转工具