跳至主要內容

Flutter 动画分析之 Hero

JI,XIAOYONG...大约 14 分钟

Flutter 中的 Hero 动画是指可以在切换页面时自动跨页面实现 Widget 放大缩小、位移的动画,在用户看起来好像是当前页面的 Widget“飞”入到另外一个页面,底层基于 Overlay 实现。

本文对其原理和应用做一简单分析,主要是对官方介绍open in new window的理解与分析,感兴趣的可以直接阅读官方文档。

使用

简单使用 Standard hero animations

详细的代码可以从simple_hero_animation.dartopen in new window获取。

Hero 动画的使用比较简单:

  • 分别在两个 Flutter 页面将需要实现 Hero 动画的 Widget(一般是图片)作为 Hero 控件的 child
  • 为这两个 Hero 指定同一个 tag
  • Hero 底层基于 Overlay 实现,所有 Hero.child 可以使用 Position 等适用于 Stack.child 的属性

这里要注意:

  • 这两个 Page 要是相邻的页面,否则 Hero 动画不会生效
  • 同一个页面,不能有多个 Hero.tag 相同的 Hero 控件
  • Hero 动画默认只支持大小(size)和位置(location)变化,所以 Hero.child 不建议有其他变化(Don't rotate your heroes)

按照上述的要求,我们再 FirstHeroPage 中添加第一个 Hero Widget:

Align(
            alignment: Alignment.bottomLeft,
            child: Hero(
                // 注意这里的 tag 要和下一个页面的 Hero.tag 一致
                tag: "HeroTag",
                // child 是 Hero 动画会作用的地方
                child: HeroChildWidget(
                  size: const Size.square(100),
                  name: "First",
                  onTap: () {
                    Navigator.push(context,
                        MaterialPageRoute(builder: (context) {
                      return const SecondHeroPage();
                    }));
                  },
                )),
          )

然后在要跳转到的第二个页面 SecondHeroPage 添加目标 Hero:

          Align(
            alignment: Alignment.topCenter,
            child: Hero(
                // 这里的 tag 与上一个页面的 Hero.tag 一致
                tag: "HeroTag",
                child: HeroChildWidget(
                  size: const Size.square(200),
                  name: "Second",
                  onTap: () {
                    Navigator.pop(context);
                  },
                )),
          )

这样,当从 FirstHeroPage 跳转到 SecondHeroPage 的时候,Hero 动画作用于 Hero.child,看起来好像是前一个页面的 HeroChildWidget 逐渐从 Alignment.bottomLeft 移动到 Alignment.topCenter,并且大小也从 100 逐渐变为 200。

Simple Hero Animation
Simple Hero Animation

原理分析

那么 Hero 动画是如何实现这个效果的呢?

Flutter 中 Page 之间的跳转由 Navigator 管理,在 Navigator 管理的所有 Flutter Page 的上层有一个叠加层 Overlay,在 Z 轴方向上处于所有 Page 上层,其内部的 widget 可以被独立管理。

以当前 Page 为 A,要跳转的 Page 为 B:

  • 那么当从 A 跳转到 B 时,Hero 动画框架会先隐藏 A 中的 Hero.child 并用相同大小的(不可见)组件占位;

  • 与此同时,Hero 动画框架读取要跳转去的 B 中的 Hero.child 并据此创建 Widget,将其大小和位置与为 A 中 Hero.child 对齐,放入到 Overlay 中。同时计算器过渡到 B 中的 Hero.child 位置和大小的路径动画,并执行;

    这也是为什么在上一步我们简单的 Hero 动画中,A 向 B 页面切换时过渡的 Widget 中文字虽然是Second,但是样式看起来和页面中的文字样式不一样,这是因为Overlay(可视为一个特殊的 Stack)open in new window上的组件是单独管理的,没有使用我们的 Material 样式。
    要解决这个问题也很简单,在 Hero.child 中添加Material组件使其应用样式即可。

  • 当 B 创建好之后,Hero 动画正在运行中,所以 B 中的 Hero.child 也会被相同大小的(不可见)组件占位;

  • 当 Hero 动画播放完毕之后,位于 Overlay 中的 Widget 被移除,A 和 B 中的 Hero.child 也正常显示。

  • 如果又从 B 返回 A 则上述步骤又会反向(A、B 中 Hero.child 换位)执行一次。

下图是 Flutter 官方对 Hero 动画执行的解释,刚好处于由 A 到 B 过渡的过程,可以看到 A、B 页面中的 Widget 都被移除,只有根据目标 Page——B 中的 Hero.child 创建的位于 Overlay 的 Destination hero 控件在从 A 中对应的位置和大小过渡到 B 中对应的位置和大小:

Hero 动画进行中的图解
Hero 动画进行中的图解

进阶使用 Radial hero animations

默认实现的 Hero 动画只支持大小和位移变化,通过使用 Radial Transformation(径向转化)可以实现由圆变为正方形的过渡动画。

径向过渡 是由圆形变成正方形的过渡动画。
—— flutter.cn 官网open in new window

径向动画的本质还是 Hero 动画,只不过在 Hero 动画之上做了一层由 ClipOval 和 ClipRect 组成的裁剪遮罩,通过二者的配合,导致其重叠部分的内容看起来好像在 Hero 动画播放的时候在正方形之间切换。

官方的实现为radial_hero_animation_animate_rectclipopen in new window,但是为了便于理解径向动画的原理,我们在之前的 Hero 动画的简单使用代码open in new window基础上进行修改。

详细的代码可以从advanced_hero_animation.dartopen in new window获取。

相对于之前的,我们主要将 HeroChildWidget 类修改为 HeroClippedChildWidget 类:

const Size maxClipOvalDiameter = Size.square(200);
const Size minClipOvalDiameter = Size.square(100);

class HeroClippedChildWidget extends StatelessWidget {
  const HeroClippedChildWidget(
      {Key? key, required this.size, required this.name, required this.onTap})
      : super(key: key);

  final Size size;
  final String name;
  final VoidCallback onTap;

  
  Widget build(BuildContext context) {
    // 与圆内切的正方形边长 s = 圆半径 * 根号 2 = 直径 * 根号 2 / 2
    var clipRectSize = maxClipOvalDiameter.width * math.sqrt2 / 2;
    return SizedBox(
      height: size.height,
      width: size.width,
      child: GestureDetector(
        onTap: onTap,
        // 当 Hero 动画变到最小时,ClipOval 与 ClipRect 相交部分是 ClipOval 形状
        child: ClipOval(
          child: Center(
            // 当 Hero 动画变到最大时,ClipOval 与 ClipRect 相交部分是 ClipRect 形状
            child: ClipRect(
              child: SizedBox(
                height: clipRectSize,
                width: clipRectSize,
                child: Container(
                  color: Colors.blueAccent,
                  child: Center(
                    child: Text(name),
                  ),
                ),
              ),
            ),
          ),
        ),
      ),
    );
  }
}

其中,FirstHeroPage 的 HeroClippedChildWidget.minClipOvalDiameter,SecondHeroPage 中则为 maxClipOvalDiameter。

其效果如图:

advanced_hero
advanced_hero

实际上根据此方式,我们也可以监听 Hero.child 的大小、位置变化从而推测出 Hero 动画的进度和方向(是从 from 到 to,还是相反),从而实现更丰富的动画效果。

比如监听进度从而实现旋转:https://github.com/jixiaoyong/flutter_custom_widget/blob/master/lib/widgets/animation_hero_child.dartopen in new window

原理分析

此类能够实现切换的同时修改 Hero.child 的样式,主要在于 ClipOval 和 ClipRect 的组合效果:

Radial hero animations 的示意图
Radial hero animations 的示意图

ClipRect 的大小固定为var clipRectSize = maxClipOvalDiameter.width * math.sqrt2 / 2,而 ClipOval 的大小则随着 Hero 动画的变化而变化,始终与 HeroClippedChildWidget 保持一致。

假设从页面 A 到 B 切换时 Hero.child 会从小变大,那么:

  • 刚刚从页面 A 切换时,位于 Overlay 的 HeroClippedChildWidget/ClipOval 大小为 minClipOvalDiameter(也就是 100*100),此时 ClipRect 的大小 clipRectSize(也就是 200/2*根号 2 约等于 141)大于 ClipOval 的大小,所以他们的相交处为 ClipOval,故而 HeroClippedChildWidget 显示为直径为 100 的圆。
  • 当刚刚完整切换到页面 B,Hero 动画将要结束时,位于 Overlay 的 HeroClippedChildWidget/ClipOval 大小为 maxClipOvalDiameter(也就是 200*200),此时 ClipRect 的大小 clipRectSize(依旧是 141)小于 ClipOval 的大小,所以他们的相交处为 ClipRect,故而 HeroClippedChildWidget 显示为边长约为 141 的正方形。
  • 在二者的过渡阶段,就是 ClipOval 注解从小于 ClipRect 变化为大于 ClipRect 的过程,他们相交的区域也从圆变为圆角,再变为正方形。

其他属性

此外,Hero 还有几个属性可以供我们自定义:

  • flightShuttleBuilder 替换页面切换时的默认过渡 Widget。比如从页面 A 切换到 B 时,默认是使用 B 中的 Hero.child,如果此值不为 null 则会展示 flightShuttleBuilder 返回的 Widget。
  • placeholderBuilder 当 Hero 动画开始,页面 A、B 中 Hero.child 隐藏时,会展示 placeholderBuilder 返回的内容或者为空(默认)
  • createRectTween 定义 Hero 动画的过渡组件动画渐变的方式,MaterialApp 默认使用 MaterialRectArcTween(),此外还有 MaterialRectCenterArcTween()(可以等比例缩放)、MaterialPointArcTween()。

示意图如下:

flutter.cn 官网 Hero 示意图
flutter.cnopen in new window 官网 Hero 示意图

底层实现

Hero

Hero 是一个 StatefulWidget,除了之前提到的各个属性之外,还提供了_allHeroesFor()方法供 HeroController 调用,用于查找指定 Context 下所有的 Hero Widget,并在检测到有重复 Hero.tag 时报错。

class Hero extends StatefulWidget{

  const Hero({
    Key? key,
    required this.tag,// 每个页面不能有多个相同 tag 的 Hero
    this.createRectTween,// 定义 Hero 过渡 Widget 切换方式
    this.flightShuttleBuilder,// 替换默认的过渡 Widget
    this.placeholderBuilder,// 在 Hero 动画开始时占位
    this.transitionOnUserGestures = false,// 是否同步手势
    required this.child,// 要实现 Hero 动画的 Widget
  }) : super(key: key);

  // 返回一个 Key 为 Hero.tag 的 map
  static Map<Object, _HeroState> _allHeroesFor(
    BuildContext context,
    bool isUserGestureTransition,
    NavigatorState navigator,
  ) {
    ...
  }
}

_HeroState

_HeroState 主要是提供 startFlight/endFlight 供 HeroController->_HeroFlight->_HeroFlightManifest 调用;以及根据初始化以及开始/技术 Flight 动画后状态的变化而更改 Hero.child 在屏幕上面(对应页面上原先 Hero Widget 所处位置的区域)的显示效果。

  void startFlight({ bool shouldIncludedChildInPlaceholder = false }) {
    _shouldIncludeChild = shouldIncludedChildInPlaceholder;
    assert(mounted);
    final RenderBox box = context.findRenderObject()! as RenderBox;
    assert(box != null && box.hasSize);
    setState(() {
      _placeholderSize = box.size;
    });
  }

  void endFlight({ bool keepPlaceholder = false }) {
    if (keepPlaceholder || _placeholderSize == null)
      return;

    _placeholderSize = null;
    if (mounted) {
      // Tell the widget to rebuild if it's mounted. _placeholderSize has already
      // been updated.
      setState(() {});
    }
  }

可以看出,startFlight/endFlight 方法主要影响的是_placeholderSize 的值,并引发 rebuild:

  Widget build(BuildContext context) {
    assert(
      context.findAncestorWidgetOfExactType<Hero>() == null,
      'A Hero widget cannot be the descendant of another Hero widget.',
    );

    // _placeholderSize 不为 null 则展示占位 Widget
    final bool showPlaceholder = _placeholderSize != null;

    if (showPlaceholder && widget.placeholderBuilder != null) {
      // 如果有指定占位 Widget,并且需要展示
      return widget.placeholderBuilder!(context, _placeholderSize!, widget.child);
    }

    if (showPlaceholder && !_shouldIncludeChild) {
      // 在 Hero 动画执行时,默认是用相同大小的 SizeBox 占位
      return SizedBox(
        width: _placeholderSize!.width,
        height: _placeholderSize!.height,
      );
    }

    return SizedBox(
      width: _placeholderSize?.width,
      height: _placeholderSize?.height,
      child: Offstage(
        offstage: showPlaceholder,
        child: TickerMode(
          enabled: !showPlaceholder,
          // 只有动画未开始/结束时才会展示 child
          child: KeyedSubtree(key: _key, child: widget.child),
        ),
      ),
    );
  }

可以看到,Hero 和_HeroState 主要是提供了操纵、统计当前 Page 的 Hero Widget 以及控制其 Hero.child 及占位 Widget 的显示与否,那么在哪里发起和结束 Hero 动画,以及绘制过渡 Widget 的呢?

_HeroFlightManifest

“_HeroFlightManifest:Everything known about a hero flight that's to be started or diverted.”

_HeroFlightManifest 是一个数据类,主要封装了fromHero/toHero两个_HeroState,并提供 Hero Widget 的位置信息 Rect;此外还封装了过渡动画相关的get animationcreateHeroRectTween方法。

class _HeroFlightManifest {
  _HeroFlightManifest({
    required this.type,
    required this.overlay,
    required this.navigatorSize,
    required this.fromRoute,
    required this.toRoute,
    required this.fromHero,
    required this.toHero,
    required this.createRectTween,
    required this.shuttleBuilder,
    required this.isUserGestureTransition,
    required this.isDiverted,
  }) : assert(fromHero.widget.tag == toHero.widget.tag);

  late final Rect fromHeroLocation = _boundingBoxFor(fromHero.context, fromRoute.subtreeContext);

  late final Rect toHeroLocation = _boundingBoxFor(toHero.context, toRoute.subtreeContext);

  static Rect _boundingBoxFor(BuildContext context, BuildContext? ancestorContext) {
    // 从 context 中找到对应的 RenderObject 并转化得到其在 ancestorContext 坐标系中的 Rect
  }

  // 过渡动画相关
  Tween<Rect?> createHeroRectTween({ required Rect? begin, required Rect? end }) {
    final CreateRectTween? createRectTween = toHero.widget.createRectTween ?? this.createRectTween;
    return createRectTween?.call(begin, end) ?? RectTween(begin: begin, end: end);
  }

  Animation<double> get animation {
    return CurvedAnimation(
      // push 和 pop 采用不同的 animation
      parent: (type == HeroFlightDirection.push) ? toRoute.animation! : fromRoute.animation!,
      curve: Curves.fastOutSlowIn,
      reverseCurve: isDiverted ? null : Curves.fastOutSlowIn.flipped,
    );
  }
}

HeroController

HeroController 真正在路由切换时操作 Hero 动画。

无论是 CupertinoApp 还是 MaterialApp 都提供了创建 HeroController 的静态方法,在他们对应的 State.initState 方法中创建。

CupertinoTabView 也创建有自己的 HeroController。

以 MaterialApp 为例:

  // MaterialApp 类
  static HeroController createMaterialHeroController() {
    return HeroController(
      createRectTween: (Rect? begin, Rect? end) {
        return MaterialRectArcTween(begin: begin, end: end);
      },
    );
  }

在_MaterialAppState 中创建并持有 HeroController:

class _MaterialAppState extends State<MaterialApp> {
  late HeroController _heroController;

  
  void initState() {
    super.initState();
    _heroController = MaterialApp.createMaterialHeroController();
  }

    
  Widget build(BuildContext context) {
    // 创建 WidgetsApp
    Widget result = _buildWidgetApp(context);
    ...

    return ScrollConfiguration(
      behavior: widget.scrollBehavior ?? const MaterialScrollBehavior(),
      // 注意这里将_heroController 传给 HeroControllerScope
      child: HeroControllerScope(
        controller: _heroController,
        child: result,
      ),
    );
  }
}

HeroControllerScope 是一个 InheritedWidget,经过上述代码,MaterialApp 内部可以通过HeroControllerScope.of()方法获取到 HeroController。

而在 WidgetsApp 中创建的 Navigator 对应的NavigatorState._updateHeroController方法中会使用其获取 HeroController,并通过HeroController._navigator = thisNavigatorState._updateEffectiveObservers()方法与之绑定。

这样当 Navigator 切换页面时,各个时间都会通知到 HeroController,执行其 didPush/didPop/didRemove/didReplace/didStartUserGesture/didStopUserGesture 等方法,从而触发/终止 Hero 动画。

class HeroController extends NavigatorObserver {

  HeroController({ this.createRectTween });

  final CreateRectTween? createRectTween;

  // 当前位于 Overlay 中的所有 Hero 动画,key 是 Hero.tag
  final Map<Object, _HeroFlight> _flights = <Object, _HeroFlight>{};

  // 从父类 NavigatorObserver 继承
  NavigatorState? _navigator
}

_maybeStartHeroTransition

HeroController 从 NavigatorObserver 继承到的方法中,除了 didStopUserGesture 都会执行 HeroController._maybeStartHeroTransition 方法:

  // If we're transitioning between different page routes, start a hero transition
  // after the toRoute has been laid out with its animation's value at 1.0.
  void _maybeStartHeroTransition(
    Route<dynamic>? fromRoute,
    Route<dynamic>? toRoute,
    HeroFlightDirection flightType,
    bool isUserGestureTransition,
  ) {
    if (toRoute != fromRoute && toRoute is PageRoute<dynamic> && fromRoute is PageRoute<dynamic>) {
      final PageRoute<dynamic> from = fromRoute;
      final PageRoute<dynamic> to = toRoute;

      // A user gesture may have already completed the pop, or we might be the initial route
      switch (flightType) {
        case HeroFlightDirection.pop:
          if (from.animation!.value == 0.0) {
            return;
          }
          break;
        case HeroFlightDirection.push:
          if (to.animation!.value == 1.0) {
            return;
          }
          break;
      }

      // 对于 pop 事件,如果 maintainState 为 true,那么我们立即得知 hero 动画的目标尺寸
      // 因为这表示上一个页面还存在,不需要重新 layout
      if (isUserGestureTransition && flightType == HeroFlightDirection.pop && to.maintainState) {
        _startHeroTransition(from, to, flightType, isUserGestureTransition);
      } else {
        // 否则应该等到下一帧、to 页面 layout 之后再开始

        // Putting a route offstage changes its animation value to 1.0. Once this
        // frame completes, we'll know where the heroes in the `to` route are
        // going to end up, and the `to` route will go back onstage.
        to.offstage = to.animation!.value == 0.0;

        WidgetsBinding.instance.addPostFrameCallback((Duration value) {
          _startHeroTransition(from, to, flightType, isUserGestureTransition);
        });
      }
    }
  }

_startHeroTransition

当上述条件满足之后,便可以开始执行 Hero 动画。

  // Find the matching pairs of heroes in from and to and either start or a new
  // hero flight, or divert an existing one.
  void _startHeroTransition(
    PageRoute<dynamic> from,
    PageRoute<dynamic> to,
    HeroFlightDirection flightType,
    bool isUserGestureTransition,
  ) {
    // 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;

    final NavigatorState? navigator = this.navigator;
    // 注意这里获取到了 OverlayState,用来放置 Hero 过渡动画
    final OverlayState? overlay = navigator?.overlay;
    // If the navigator or the overlay was removed before this end-of-frame
    // callback was called, then don't actually start a transition, and we don'
    // t have to worry about any Hero widget we might have hidden in a previous
    // flight, or ongoing flights.
    if (navigator == null || overlay == null)
      return;

    final RenderObject? navigatorRenderObject = navigator.context.findRenderObject();

    if (navigatorRenderObject is! RenderBox) {
      assert(false, 'Navigator $navigator has an invalid RenderObject type ${navigatorRenderObject.runtimeType}.');
      return;
    }
    assert(navigatorRenderObject.hasSize);

    // At this point, the toHeroes may have been built and laid out for the first time.
    //
    // If `fromSubtreeContext` is null, call endFlight on all toHeroes, for good measure.
    // If `toSubtreeContext` is null abort existingFlights.
    final BuildContext? fromSubtreeContext = from.subtreeContext;
    final Map<Object, _HeroState> fromHeroes = fromSubtreeContext != null
      ? Hero._allHeroesFor(fromSubtreeContext, isUserGestureTransition, navigator)
      : const <Object, _HeroState>{};
    final BuildContext? toSubtreeContext = to.subtreeContext;
    final Map<Object, _HeroState> toHeroes = toSubtreeContext != null
      ? Hero._allHeroesFor(toSubtreeContext, isUserGestureTransition, navigator)
      : const <Object, _HeroState>{};

    for (final MapEntry<Object, _HeroState> fromHeroEntry in fromHeroes.entries) {
      final Object tag = fromHeroEntry.key;
      final _HeroState fromHero = fromHeroEntry.value;
      final _HeroState? toHero = toHeroes[tag];
      final _HeroFlight? existingFlight = _flights[tag];
      final _HeroFlightManifest? manifest = toHero == null
        ? null
        : _HeroFlightManifest(
            type: flightType,
            overlay: overlay,
            navigatorSize: navigatorRenderObject.size,
            fromRoute: from,
            toRoute: to,
            fromHero: fromHero,
            toHero: toHero,
            createRectTween: createRectTween,
            // 优先使用 toHero、fromHero 指定的 flightShuttleBuilder,没有的话
            // 使用默认的 shuttleBuilder,也就是 toHero.child
            shuttleBuilder: toHero.widget.flightShuttleBuilder
                          ?? fromHero.widget.flightShuttleBuilder
                          ?? _defaultHeroFlightShuttleBuilder,
            isUserGestureTransition: isUserGestureTransition,
            isDiverted: existingFlight != null,
          );

      // Only proceed with a valid manifest. Otherwise abort the existing
      // flight, and call endFlight when this for loop finishes.
      if (manifest != null && manifest.isValid) {
        toHeroes.remove(tag);
        if (existingFlight != null) {
          // 如果已经存在 Hero 过渡动画,则将其转到新的方向
          existingFlight.divert(manifest);
        } else {
          // 开始全新的 Hero 过渡动画
          _flights[tag] = _HeroFlight(_handleFlightEnded)..start(manifest);
        }
      } else {
        existingFlight?.abort();
      }
    }

    // The remaining entries in toHeroes are those failed to participate in a
    // new flight (for not having a valid manifest).
    //
    // This can happen in a route pop transition when a fromHero is no longer
    // mounted, or kept alive by the [KeepAlive] mechanism but no longer visible.
    // TODO(LongCatIsLooong): resume aborted flights: https://github.com/flutter/flutter/issues/72947
    for (final _HeroState toHero in toHeroes.values)
      toHero.endFlight();
  }

  void _handleFlightEnded(_HeroFlight flight) {
    _flights.remove(flight.manifest.tag);
  }

  Widget _defaultHeroFlightShuttleBuilder(
    BuildContext flightContext,
    Animation<double> animation,
    HeroFlightDirection flightDirection,
    BuildContext fromHeroContext,
    BuildContext toHeroContext,
  ) {
    final Hero toHero = toHeroContext.widget as Hero;
    return toHero.child;
  }

_HeroFlight

经过上面的分析,最终是在_HeroFlight 方法中真正执行 Hero 动画:

class _HeroFlight {
  // The simple case: we're either starting a push or a pop animation.
  void start(_HeroFlightManifest initialManifest) {
    assert(!_aborted);
    assert(() {
      final Animation<double> initial = initialManifest.animation;
      assert(initial != null);
      final HeroFlightDirection type = initialManifest.type;
      assert(type != null);
      switch (type) {
        case HeroFlightDirection.pop:
          return initial.value == 1.0 && initialManifest.isUserGestureTransition
              // During user gesture transitions, the animation controller isn't
              // driving the reverse transition, but should still be in a previously
              // completed stage with the initial value at 1.0.
              ? initial.status == AnimationStatus.completed
              : initial.status == AnimationStatus.reverse;
        case HeroFlightDirection.push:
          return initial.value == 0.0 && initial.status == AnimationStatus.forward;
      }
    }());

    manifest = initialManifest;

    final bool shouldIncludeChildInPlaceholder;
    switch (manifest.type) {
      case HeroFlightDirection.pop:
        _proxyAnimation.parent = ReverseAnimation(manifest.animation);
        shouldIncludeChildInPlaceholder = false;
        break;
      case HeroFlightDirection.push:
        _proxyAnimation.parent = manifest.animation;
        shouldIncludeChildInPlaceholder = true;
        break;
    }

    heroRectTween = manifest.createHeroRectTween(begin: manifest.fromHeroLocation, end: manifest.toHeroLocation);
    // 执行_HeroState.startFlight 方法,移除 Hero.child,展示占位 Widget
    manifest.fromHero.startFlight(shouldIncludedChildInPlaceholder: shouldIncludeChildInPlaceholder);
    manifest.toHero.startFlight();
    // 在 overlay 上添加过渡组件_buildOverlay
    manifest.overlay.insert(overlayEntry = OverlayEntry(builder: _buildOverlay));
    // 监听动画进度,以便实时改变过渡组件_buildOverlay 的样式
    _proxyAnimation.addListener(onTick);
  }
}

Hero 动画实际播放的过渡动画内容,由_HeroFlight._buildOverlay 根据动画进度创建:

  // The OverlayEntry WidgetBuilder callback for the hero's overlay.
  Widget _buildOverlay(BuildContext context) {
    assert(manifest != null);
    // 默认是 toHero.child
    shuttle ??= manifest.shuttleBuilder(
      context,
      manifest.animation,
      manifest.type,
      manifest.fromHero.context,
      manifest.toHero.context,
    );
    assert(shuttle != null);

    return AnimatedBuilder(
      // 监听动画进度
      animation: _proxyAnimation,
      child: shuttle,
      builder: (BuildContext context, Widget? child) {
        final Rect rect = heroRectTween.evaluate(_proxyAnimation)!;
        final RelativeRect offsets = RelativeRect.fromSize(rect, manifest.navigatorSize);
        // 这里根据动画的进度更改 shuttle 的位置和大小、透明度
        // Overlay 本质上是一个特殊的 Stack,所以这里使用 Positioned 定位
        return Positioned(
          top: offsets.top,
          right: offsets.right,
          bottom: offsets.bottom,
          left: offsets.left,
          child: IgnorePointer(
            child: RepaintBoundary(
              child: FadeTransition(
                opacity: _heroOpacity,
                child: child,
              ),
            ),
          ),
        );
      },
    );
  }

总结

Flutter 中 Hero 动画是基于 Overlay 实现的,监听 Navigator 路由变化,从而在不同 Flutter 页面切换时触发的、表现为 Hero.child 从当前页面“飞”到目标页面对应位置,并伴随着位置、大小、透明度等变化的动画。

MaterialApp 或者 CupertinoApp 对应的State.initState方法中创建 HeroController,并通过 HeroControllerScope 提供给前面创建的 WidgetsApp 内部创建的 Navigator 并绑定;这样当路由变化时 HeroController 收到通知并在HeroState._startHeroTransition方法中通过Hero._allHeroesFor方法获取到当前页和目标页面的 Hero 动画组件,将其 Animation、Tween、Rect、_HeroState 等封装到_HeroFlightManifest 中,传递给_HeroFlight.start执行动画,并在_HeroFlight.onTick方法监听处理动画进度,从而导致_HeroFlight._buildOverlay创建的过渡 Widget 位置、大小、透明度等变化,产生 Hero Widget“飞入”的视觉效果。

参考资料

主动画 (Hero animations)open in new window

Hero classopen in new window

Overlay classopen in new window

radial_hero_animation_animate_rectclipopen in new window

文章标题:《Flutter 动画分析之 Hero》
本文作者: JI,XIAOYONG
发布时间: 2022/08/14 19:14:01 UTC+8
更新时间: 2023/12/30 16:17:02 UTC+8
written by human, not by AI
本文地址: https://jixiaoyong.github.io/blog/posts/62beedda.html
版权声明: 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 许可协议。转载请注明出处!
你认为这篇文章怎么样?
  • 0
  • 0
  • 0
  • 0
  • 0
  • 0
评论
  • 按正序
  • 按倒序
  • 按热度
Powered by Waline v2.15.8