英雄动画
如何让组件在两个屏幕之间飞入飞出。
你可能已经多次见过 Hero 动画。例如,屏幕上显示一组待售商品的缩略图。选中一个项目会将其飞入一个包含更多详细信息和“购买”按钮的新屏幕。将图像从一个屏幕飞入另一个屏幕的操作在 Flutter 中称为 Hero 动画,尽管相同的动作有时也被称为共享元素过渡。
你可能想观看这个介绍 Hero 组件的一分钟视频
本指南演示了如何构建标准 Hero 动画,以及在飞行过程中将图像从圆形转换为方形的 Hero 动画。
路由 (Route) 描述了 Flutter 应用中的页面或屏幕。
你可以使用 Hero 组件在 Flutter 中创建此动画。当 Hero 从源路由动画到目标路由时,目标路由(除去 Hero 本身)会淡入视野。通常,Hero 是 UI 的一小部分(如图像),且两个路由都包含该部分。从用户的角度来看,Hero 在路由之间“飞行”。本指南展示了如何创建以下 Hero 动画
标准 Hero 动画
标准 Hero 动画将 Hero 从一个路由飞向另一个新路由,通常在不同的位置以不同的尺寸着陆。
以下视频(以慢速录制)展示了一个典型的例子。点击路由中心的脚蹼(flippers),它们会飞到新蓝色路由的左上角,并缩小尺寸。点击蓝色路由中的脚蹼(或使用设备的返回上一路由手势),脚蹼会飞回原始路由。
径向 Hero 动画
在径向 Hero 动画中,当 Hero 在路由之间飞行时,其形状看起来会从圆形变为矩形。
以下视频(以慢速录制)展示了径向 Hero 动画的一个例子。开始时,路由底部出现一排三个圆形图像。点击任何一个圆形图像,该图像会飞到一个以方形显示它的新路由中。点击方形图像,Hero 会飞回原始路由,并以圆形显示。
在进入标准或径向 Hero 动画的具体章节之前,请阅读Hero 动画的基本结构以了解如何组织代码,并阅读幕后原理以了解 Flutter 如何执行 Hero 动画。
Hero 动画的基本结构
#- 在不同的路由中使用两个具有匹配标记(tag)的 Hero 组件来实现动画。
- 导航器 (Navigator) 管理着一个包含应用路由的堆栈。
- 将路由推入或从导航器堆栈中弹出路由会触发该动画。
- Flutter 框架会计算一个矩形补间动画
RectTween,该补间定义了 Hero 从源路由飞向目标路由时的边界。在飞行过程中,Hero 被移动到一个应用覆盖层 (overlay) 中,使其出现在两个路由的上方。
如果补间 (tweens) 或补间动画的概念对你来说很陌生,请查看 Flutter 动画教程。
Hero 动画是使用两个 Hero 组件实现的:一个描述源路由中的组件,另一个描述目标路由中的组件。从用户的角度来看,Hero 看起来是共享的,只有开发人员需要了解这个实现细节。Hero 动画代码具有以下结构
- 定义一个起始 Hero 组件,称为源 Hero。Hero 指定了其图形表示(通常是图像)和一个标识标签(tag),并且位于源路由定义的当前显示组件树中。
- 定义一个结束 Hero 组件,称为目标 Hero。该 Hero 也指定了其图形表示,以及与源 Hero 相同的标签。至关重要的是,两个 Hero 组件必须使用相同的标签创建,通常是一个代表底层数据的对象。为了获得最佳效果,这些 Hero 应具有几乎相同的组件树。
- 创建一个包含目标 Hero 的路由。目标路由定义了动画结束时存在的组件树。
- 通过将目标路由推入导航器的堆栈来触发动画。导航器的推送和弹出操作会为源路由和目标路由中每对标签匹配的 Hero 触发 Hero 动画。
Flutter 计算将 Hero 边界从起点动画到终点(插值计算尺寸和位置)的补间,并在覆盖层中执行动画。
下一节将更详细地描述 Flutter 的处理过程。
幕后原理
#以下内容描述了 Flutter 如何执行从一个路由到另一个路由的过渡。
在过渡之前,源 Hero 等待在源路由的组件树中。目标路由尚不存在,覆盖层为空。

将路由推入 Navigator 会触发动画。在 t=0.0 时,Flutter 执行以下操作
-
按照 Material 运动规范中所述的曲线运动,在屏幕外计算目标 Hero 的路径。Flutter 现在知道 Hero 的终点位置。
-
将目标 Hero 放置在覆盖层中,位置和大小与源 Hero 相同。将 Hero 添加到覆盖层会改变其 Z 轴顺序,使其出现在所有路由的上方。
将源 Hero 移出屏幕。
当 Hero 飞行时,其矩形边界使用 Hero 的 createRectTween 属性中指定的 Tween<Rect> 进行动画处理。默认情况下,Flutter 使用 MaterialRectArcTween 的实例,它沿着曲线路径对矩形的相对角进行动画处理。(有关使用不同补间动画的示例,请参阅径向 Hero 动画。)
飞行完成时
-
Flutter 将 Hero 组件从覆盖层移动到目标路由。覆盖层现在为空。
-
目标 Hero 出现在目标路由的最终位置。
源 Hero 被恢复到其原始路由。
弹出路由执行相同的过程,将 Hero 动画回其在源路由中的大小和位置。
关键类
#本指南中的示例使用以下类来实现 Hero 动画
Hero-
从源路由飞向目标路由的组件。为源路由定义一个 Hero,为目标路由定义另一个 Hero,并为每个 Hero 分配相同的标签。Flutter 会对具有匹配标签的 Hero 对进行动画处理。
InkWell-
指定点击 Hero 时会发生什么。
InkWell的onTap()方法构建新路由并将其推入Navigator的堆栈。 Navigator-
Navigator管理路由堆栈。将路由推入或从Navigator堆栈中弹出路由会触发该动画。 Route-
指定屏幕或页面。除了最基础的应用外,大多数应用都有多个路由。
标准 Hero 动画
#- 使用
MaterialPageRoute、CupertinoPageRoute指定路由,或使用PageRouteBuilder构建自定义路由。本节中的示例使用 MaterialPageRoute。 - 通过将目标图像包装在
SizedBox中,可以更改过渡结束时图像的尺寸。 - 通过将目标图像放置在布局组件中来更改图像的位置。这些示例使用
Container。
以下每个示例都演示了将图像从一个路由飞向另一个路由的过程。本指南描述了第一个示例。
- hero_animation
-
将 Hero 代码封装在自定义
PhotoHero组件中。按照 Material 运动规范的描述,沿曲线路径对 Hero 的运动进行动画处理。 - basic_hero_animation
-
直接使用 Hero 组件。这个更基础的示例仅供参考,本指南未作介绍。
发生了什么?
#使用 Flutter 的 Hero 组件可以轻松实现将图像从一个路由飞向另一个路由。使用 MaterialPageRoute 指定新路由时,图像会按照 Material Design 运动规范中所述,沿着曲线路径飞行。
创建一个新的 Flutter 应用,并使用 hero_animation 中的文件进行更新。
运行示例
- 点击主路由上的照片,将图像飞向一个新路由,该路由在不同位置和比例下显示相同的照片。
- 通过点击图像,或使用设备的返回上一路由手势,返回到上一个路由。
- 你可以使用
timeDilation属性进一步减慢过渡速度。
PhotoHero 类
#自定义的 PhotoHero 类维护着 Hero 及其尺寸、图像和点击时的行为。PhotoHero 构建了以下组件树

代码如下
class PhotoHero extends StatelessWidget {
const PhotoHero({
super.key,
required this.photo,
this.onTap,
required this.width,
});
final String photo;
final VoidCallback? onTap;
final double width;
@override
Widget build(BuildContext context) {
return SizedBox(
width: width,
child: Hero(
tag: photo,
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: onTap,
child: Image.asset(
photo,
fit: BoxFit.contain,
),
),
),
),
);
}
}
关键信息
- 当
HeroAnimation被设置为应用的 home 属性时,起始路由由MaterialApp隐式推送。 InkWell包装了图像,可以轻松地为源 Hero 和目标 Hero 添加点击手势。- 将 Material 组件定义为透明颜色,使得图像在飞向目的地时能够从背景中“弹出”。
SizedBox指定了 Hero 在动画开始和结束时的尺寸。- 将图像的
fit属性设置为BoxFit.contain,确保图像在过渡过程中尽可能大,且不会改变其纵横比。
HeroAnimation 类
#HeroAnimation 类创建了源和目标 PhotoHero,并设置了过渡效果。
代码如下
class HeroAnimation extends StatelessWidget {
const HeroAnimation({super.key});
Widget build(BuildContext context) {
timeDilation = 5.0; // 1.0 means normal animation speed.
return Scaffold(
appBar: AppBar(
title: const Text('Basic Hero Animation'),
),
body: Center(
child: PhotoHero(
photo: 'images/flippers-alpha.png',
width: 300.0,
onTap: () {
Navigator.of(context).push(MaterialPageRoute<void>(
builder: (context) {
return Scaffold(
appBar: AppBar(
title: const Text('Flippers Page'),
),
body: Container(
// Set background to blue to emphasize that it's a new route.
color: Colors.lightBlueAccent,
padding: const EdgeInsets.all(16),
alignment: Alignment.topLeft,
child: PhotoHero(
photo: 'images/flippers-alpha.png',
width: 100.0,
onTap: () {
Navigator.of(context).pop();
},
),
),
);
}
));
},
),
),
);
}
}
关键信息
- 当用户点击包含源 Hero 的
InkWell时,代码会使用MaterialPageRoute创建目标路由。将目标路由推送到Navigator的堆栈会触发动画。 Container将PhotoHero定位在目标路由的左上角,位于AppBar下方。- 目标
PhotoHero的onTap()方法会弹出Navigator的堆栈,触发将Hero飞回原始路由的动画。 - 调试时使用
timeDilation属性减慢过渡速度。
径向 Hero 动画
#- 径向变换将圆形变为正方形。
- 径向 Hero 动画在将 Hero 从源路由飞向目标路由的过程中执行径向变换。
- MaterialRectCenterArcTween 定义了补间动画。
- 使用
PageRouteBuilder构建目标路由。
将 Hero 从一个路由飞向另一个路由并将其从圆形变为矩形,是一种可以使用 Hero 组件实现的炫酷效果。为了实现这一点,代码对两个剪裁形状的交集进行了动画处理:一个圆形和一个正方形。在整个动画过程中,圆形剪裁(和图像)从 minRadius 缩放到 maxRadius,而方形剪裁保持恒定尺寸。与此同时,图像从其在源路由中的位置飞向其在目标路由中的位置。有关此过渡的视觉示例,请参阅 Material 运动规范中的径向变换。
此动画看起来可能很复杂(确实如此),但你可以根据你的需求自定义提供的示例。大部分繁重的工作都已经为你完成了。
以下每个示例都演示了径向 Hero 动画。本指南描述了第一个示例。
- radial_hero_animation
Material 运动规范中所述的径向 Hero 动画。
- basic_radial_hero_animation
-
径向 Hero 动画的最简单示例。目标路由没有 Scaffold、Card、Column 或 Text。这个基础示例仅供参考,本指南未作介绍。
-
radial_hero_animation_animate
_rectclip -
通过对矩形剪裁的尺寸进行动画处理来扩展 radial_hero_animation。这个更高级的示例仅供参考,本指南未作介绍。
径向 Hero 动画涉及将圆形与正方形相交。即使使用 timeDilation 减慢动画速度,这也可能很难看清,因此你可能考虑在开发过程中启用 debugPaintSizeEnabled 标志。
发生了什么?
#下图显示了动画开始(t = 0.0)和结束(t = 1.0)时的剪裁图像。
蓝色渐变(代表图像)表示剪裁形状的交集位置。在过渡开始时,交集的结果是圆形剪裁(ClipOval)。在变换过程中,ClipOval 从 minRadius 缩放到 maxRadius,而 ClipRect 保持恒定尺寸。在过渡结束时,圆形和矩形剪裁的交集产生一个与 Hero 组件大小相同的矩形。换句话说,在过渡结束时,图像不再被剪裁。
创建一个新的 Flutter 应用,并使用 radial_hero_animation GitHub 目录中的文件进行更新。
运行示例
- 点击三个圆形缩略图之一,将图像动画化为一个更大的正方形,并放置在一个覆盖原始路由的新路由中间。
- 通过点击图像,或使用设备的返回上一路由手势,返回到上一个路由。
- 你可以使用
timeDilation属性进一步减慢过渡速度。
Photo 类
#Photo 类构建了包含图像的组件树
class Photo extends StatelessWidget {
const Photo({super.key, required this.photo, this.color, this.onTap});
final String photo;
final Color? color;
final VoidCallback onTap;
Widget build(BuildContext context) {
return Material(
// Slightly opaque color appears where the image has transparency.
color: Theme.of(context).primaryColor.withValues(alpha: 0.25),
child: InkWell(
onTap: onTap,
child: Image.asset(
photo,
fit: BoxFit.contain,
),
),
);
}
}
关键信息
InkWell捕获点击手势。调用函数将onTap()函数传递给Photo的构造函数。- 在飞行过程中,
InkWell会在其第一个 Material 祖先上绘制其点击效果。 - Material 组件具有轻微的不透明颜色,因此图像的透明部分会被渲染为彩色。这确保了即使对于带有透明度的图像,圆形到正方形的过渡也清晰可见。
Photo类在其组件树中不包含Hero。为了使动画正常工作,Hero 包装了RadialExpansion组件。
RadialExpansion 类
#RadialExpansion 组件是该演示的核心,它构建了在过渡期间剪裁图像的组件树。剪裁后的形状是由圆形剪裁(在飞行过程中增长)与矩形剪裁(在整个过程中保持恒定大小)的交集产生的。
为此,它构建了以下组件树
代码如下
class RadialExpansion extends StatelessWidget {
const RadialExpansion({
super.key,
required this.maxRadius,
this.child,
}) : clipRectSize = 2.0 * (maxRadius / math.sqrt2);
final double maxRadius;
final double clipRectSize;
final Widget? child;
@override
Widget build(BuildContext context) {
return ClipOval(
child: Center(
child: SizedBox(
width: clipRectSize,
height: clipRectSize,
child: ClipRect(
child: child, // Photo
),
),
),
);
}
}
关键信息
Hero 包装了
RadialExpansion组件。-
当 Hero 飞行时,其大小会发生变化,并且由于它限制了子组件的大小,
RadialExpansion组件也会随之改变大小以匹配。 RadialExpansion动画由两个重叠的剪裁创建。-
该示例使用
MaterialRectCenterArcTween定义了补间插值。Hero 动画的默认飞行路径使用 Hero 的角来插值补间。这种方法在径向变换过程中会影响 Hero 的纵横比,因此新的飞行路径使用MaterialRectCenterArcTween来通过每个 Hero 的中心点来插值补间。代码如下
dartstatic RectTween _createRectTween(Rect? begin, Rect? end) { return MaterialRectCenterArcTween(begin: begin, end: end); }Hero 的飞行路径仍然遵循弧线,但图像的纵横比保持不变。