英雄动画
你可能已经多次见过 Hero 动画。例如,一个屏幕显示代表待售商品的缩略图列表。选择一个商品会将其飞到包含更多详细信息和“购买”按钮的新屏幕。将图像从一个屏幕飞到另一个屏幕在 Flutter 中被称为 hero 动画,尽管相同的动作有时也被称为共享元素过渡。
你可能想观看这个介绍 Hero widget 的一分钟视频
本指南演示了如何构建标准 Hero 动画,以及在飞行过程中将图像从圆形转换为方形的 Hero 动画。
一个 路由(Route) 描述了 Flutter 应用中的一个页面或屏幕。
你可以在 Flutter 中使用 Hero widget 创建这种动画。当 hero 从源路由动画到目标路由时,目标路由(除去 hero 部分)会淡入视图。通常,hero 是 UI 的一小部分,例如图像,它们是两个路由共有的。从用户的角度来看,hero 在路由之间“飞行”。本指南展示了如何创建以下 Hero 动画
标准 Hero 动画
标准 Hero 动画将 hero 从一个路由飞到新路由,通常在不同的位置以不同的尺寸着陆。
以下视频(慢速录制)展示了一个典型示例。点击路由中心处的鳍状物会将其飞到新的蓝色路由的左上角,尺寸更小。点击蓝色路由中的鳍状物(或使用设备的返回上一路由手势)会将鳍状物飞回原始路由。
径向 Hero 动画
在径向 Hero 动画中,当 hero 在路由之间飞行时,其形状会从圆形变为矩形。
以下视频(慢速录制)展示了一个径向 Hero 动画的示例。开始时,路由底部显示一排三个圆形图像。点击任意圆形图像会将其飞到新路由,以方形显示。点击方形图像会将 hero 飞回原始路由,并以圆形显示。
在进入特定于标准或径向 Hero 动画的部分之前,请阅读Hero 动画的基本结构以了解如何构建 Hero 动画代码,并阅读幕后原理以了解 Flutter 如何执行 Hero 动画。
Hero 动画的基本结构
#- 在不同路由中使用两个具有匹配标签的 Hero widget 来实现动画。
- Navigator 管理着包含应用路由的堆栈。
- 将路由推入或弹出 Navigator 堆栈会触发动画。
- Flutter 框架会计算一个矩形 tween,
RectTween
,它定义了 hero 从源路由飞到目标路由时的边界。在飞行过程中,hero 会被移动到应用程序的 overlay 上,以便它显示在两个路由的顶部。
如果你对 tween 或 tweening 的概念不熟悉,请查看Flutter 中的动画教程。
Hero 动画是使用两个 Hero
widget 实现的:一个描述源路由中的 widget,另一个描述目标路由中的 widget。从用户的角度来看,hero 似乎是共享的,只有程序员需要理解这个实现细节。Hero 动画代码具有以下结构
- 定义一个起始 Hero widget,称为源 hero。hero 指定其图形表示(通常是图像)和标识标签,并位于源路由定义的当前显示 widget 树中。
- 定义一个结束 Hero widget,称为目标 hero。此 hero 也指定其图形表示,并与源 hero 具有相同的标签。两个 Hero widget 必须使用相同的标签创建,通常是表示底层数据的对象,这一点至关重要。为获得最佳效果,这些 hero 应该具有几乎相同的 widget 树。
- 创建一个包含目标 hero 的路由。目标路由定义了动画结束时存在的 widget 树。
- 通过将目标路由推入 Navigator 的堆栈来触发动画。Navigator 的推入和弹出操作会为源路由和目标路由中每对具有匹配标签的 hero 触发 Hero 动画。
Flutter 计算将 Hero 边界从起点动画到终点(插值大小和位置)的 tween,并在 overlay 中执行动画。
下一节将更详细地描述 Flutter 的过程。
幕后原理
#以下描述了 Flutter 如何执行从一个路由到另一个路由的过渡。
在过渡之前,源 hero 在源路由的 widget 树中等待。目标路由尚未存在,且 overlay 为空。
将路由推入 Navigator
会触发动画。在 t=0.0
时,Flutter 执行以下操作:
使用 Material motion 规范中描述的曲线运动,在屏幕外计算目标 hero 的路径。Flutter 现在知道 hero 的最终位置。
将目标 hero 放置在 overlay 中,其位置和大小与源 hero 相同。将 hero 添加到 overlay 会改变其 Z 轴顺序,使其显示在所有路由的顶部。
将源 hero 移出屏幕。
当 hero 飞行时,其矩形边界使用 Tween<Rect> 进行动画,这在 Hero 的 createRectTween
属性中指定。默认情况下,Flutter 使用 MaterialRectArcTween
的实例,该实例沿曲线路径动画矩形的对角。 (有关使用不同 Tween 动画的示例,请参阅径向 Hero 动画。)
当飞行完成时
Flutter 将 hero widget 从 overlay 移动到目标路由。此时 overlay 为空。
目标 hero 出现在目标路由中的最终位置。
源 hero 被恢复到其路由。
弹出路由执行相同的过程,将 hero 动画回其在源路由中的大小和位置。
核心类
#本指南中的示例使用以下类来实现 Hero 动画:
Hero
- 从源路由飞到目标路由的 widget。为源路由定义一个 Hero,为目标路由定义另一个 Hero,并为它们分配相同的标签。Flutter 会为具有匹配标签的 Hero 对执行动画。
InkWell
- 指定点击 hero 时发生的情况。
InkWell
的onTap()
方法会构建新路由并将其推入Navigator
的堆栈。 Navigator
Navigator
管理着一个路由堆栈。将路由推入或弹出Navigator
的堆栈会触发动画。Route
- 指定一个屏幕或页面。大多数应用,除了最基本的应用之外,都具有多个路由。
标准 Hero 动画
#- 使用
MaterialPageRoute
、CupertinoPageRoute
指定路由,或者使用PageRouteBuilder
构建自定义路由。本节中的示例使用 MaterialPageRoute。 - 通过将目标图像包装在
SizedBox
中来更改过渡结束时图像的大小。 - 通过将目标图像放置在布局 widget 中来更改图像的位置。这些示例使用
Container
。
以下每个示例都演示了将图像从一个路由飞到另一个路由。本指南描述了第一个示例。
- hero_animation
- 将 hero 代码封装在自定义
PhotoHero
widget 中。根据 Material motion 规范中描述的曲线路径动画 hero 的运动。 - basic_hero_animation
- 直接使用 hero widget。这个更基础的示例仅供参考,本指南不作描述。
发生了什么?
#使用 Flutter 的 hero widget 可以轻松实现将图像从一个路由飞到另一个路由。当使用 MaterialPageRoute
指定新路由时,图像会沿着曲线路径飞行,如 Material Design motion 规范中所述。
创建新的 Flutter 应用,并使用 hero_animation 中的文件对其进行更新。
运行示例
- 点击主路由的照片,将图像飞到新路由,在新路由中以不同的位置和比例显示相同的照片。
- 通过点击图像或使用设备的返回上一路由手势返回上一路由。
- 你可以使用
timeDilation
属性进一步减慢过渡速度。
PhotoHero 类
#自定义 PhotoHero 类维护 hero 及其大小、图像和点击行为。PhotoHero 构建以下 widget 树:

代码如下
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 widget 定义为透明颜色,使图像在飞向目标时能够从背景中“弹出”。
SizedBox
指定动画开始和结束时 hero 的大小。- 将 Image 的
fit
属性设置为BoxFit.contain
,确保图像在过渡期间尽可能大,同时不改变其纵横比。
HeroAnimation 类
#HeroAnimation
类创建源 PhotoHero 和目标 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 从源路由飞到目标路由时执行径向变换。
- MaterialRectCenter
Arc Tween 定义了 tween 动画。 - 使用
PageRouteBuilder
构建目标路由。
将 hero 从一个路由飞到另一个路由,同时将其从圆形形状转换为矩形形状,这是一种你可以使用 Hero widget 实现的流畅效果。为此,代码会动画两个裁剪形状的交集:一个圆形和一个方形。在整个动画过程中,圆形裁剪(和图像)从 minRadius
缩放到 maxRadius
,而方形裁剪保持恒定大小。同时,图像从其在源路由中的位置飞到其在目标路由中的位置。有关此过渡的视觉示例,请参阅 Material motion 规范中的径向变换。
此动画可能看起来很复杂(确实如此),但你可以根据自己的需要自定义所提供的示例。繁重的工作已为你完成。
以下每个示例都演示了一个径向 Hero 动画。本指南描述了第一个示例。
- radial_hero_animation
- 如 Material motion 规范中所述的径向 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 widget 大小相同的矩形。换句话说,在过渡结束时,图像不再被裁剪。
创建新的 Flutter 应用,并使用 radial_hero_animation GitHub 目录中的文件对其进行更新。
运行示例
- 点击三个圆形缩略图之一,将图像动画为位于新路由中间的更大方形,该新路由会遮挡原始路由。
- 通过点击图像或使用设备的返回上一路由手势返回上一路由。
- 你可以使用
timeDilation
属性进一步减慢过渡速度。
Photo 类
#Photo
类构建包含图像的 widget 树:
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 widget 具有轻微不透明的颜色,因此图像的透明部分会呈现颜色。这确保了圆形到方形的过渡易于观察,即使对于具有透明度的图像也是如此。
Photo
类在其 widget 树中不包含Hero
。为了使动画正常工作,hero 会包裹RadialExpansion
widget。
RadialExpansion 类
#RadialExpansion
widget 是演示的核心,它构建在过渡期间裁剪图像的 widget 树。裁剪后的形状是圆形裁剪(在飞行期间增长)与矩形裁剪(在整个过程中保持恒定大小)交集的结果。
为此,它构建了以下 widget 树:

代码如下
class RadialExpansion extends StatelessWidget {
const RadialExpansion({
super.key,
required this.maxRadius,
this.child,
}) : clipRectSize = 2.0 * (maxRadius / math.sqrt2);
final double maxRadius;
final 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
widget。当 hero 飞行时,它的大小会改变,并且由于它约束了其子级的大小,
RadialExpansion
widget 也会随之改变大小以匹配。RadialExpansion
动画由两个重叠的裁剪创建。该示例使用
MaterialRectCenterArcTween
定义了 tweening 插值。Hero 动画的默认飞行路径使用 hero 的角落进行 tween 插值。这种方法会影响径向变换期间 hero 的纵横比,因此新的飞行路径使用MaterialRectCenterArcTween
根据每个 hero 的中心点进行 tween 插值。代码如下
dartstatic RectTween _createRectTween(Rect? begin, Rect? end) { return MaterialRectCenterArcTween(begin: begin, end: end); }
hero 的飞行路径仍然遵循弧线,但图像的纵横比保持不变。