Flutter 动画分析之 Hero
Flutter 中的 Hero 动画是指可以在切换页面时自动跨页面实现 Widget 放大缩小、位移的动画,在用户看起来好像是当前页面的 Widget“飞”入到另外一个页面,底层基于 Overlay 实现。
本文对其原理和应用做一简单分析,主要是对官方介绍的理解与分析,感兴趣的可以直接阅读官方文档。
使用
简单使用 Standard hero animations
详细的代码可以从simple_hero_animation.dart获取。
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。
原理分析
那么 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)上的组件是单独管理的,没有使用我们的 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 中对应的位置和大小:
进阶使用 Radial hero animations
默认实现的 Hero 动画只支持大小和位移变化,通过使用 Radial Transformation(径向转化)可以实现由圆变为正方形的过渡动画。
径向过渡 是由圆形变成正方形的过渡动画。
—— flutter.cn 官网
径向动画的本质还是 Hero 动画,只不过在 Hero 动画之上做了一层由 ClipOval 和 ClipRect 组成的裁剪遮罩,通过二者的配合,导致其重叠部分的内容看起来好像在 Hero 动画播放的时候在圆和正方形之间切换。
官方的实现为radial_hero_animation_animate_rectclip,但是为了便于理解径向动画的原理,我们在之前的 Hero 动画的简单使用代码基础上进行修改。
详细的代码可以从advanced_hero_animation.dart获取。
相对于之前的,我们主要将 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;
@override
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。
其效果如图:
实际上根据此方式,我们也可以监听 Hero.child 的大小、位置变化从而推测出 Hero 动画的进度和方向(是从 from 到 to,还是相反),从而实现更丰富的动画效果。
比如监听进度从而实现旋转:https://github.com/jixiaoyong/flutter_custom_widget/blob/master/lib/widgets/animation_hero_child.dart
原理分析
此类能够实现切换的同时修改 Hero.child 的样式,主要在于 ClipOval 和 ClipRect 的组合效果:
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()。
示意图如下:
底层实现
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 animation
和createHeroRectTween
方法。
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;
@override
void initState() {
super.initState();
_heroController = MaterialApp.createMaterialHeroController();
}
@override
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 = this
及NavigatorState._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“飞入”的视觉效果。