你可能已经多次见过 Hero 动画。例如,一个屏幕显示代表待售商品的缩略图列表。选择一个商品会将其飞到包含更多详细信息和“购买”按钮的新屏幕。将图像从一个屏幕飞到另一个屏幕在 Flutter 中被称为 hero 动画,尽管相同的动作有时也被称为共享元素过渡

你可能想观看这个介绍 Hero widget 的一分钟视频

在新标签页中观看 YouTube 视频:“Hero | Flutter 本周 widget”

本指南演示了如何构建标准 Hero 动画,以及在飞行过程中将图像从圆形转换为方形的 Hero 动画。

你可以在 Flutter 中使用 Hero widget 创建这种动画。当 hero 从源路由动画到目标路由时,目标路由(除去 hero 部分)会淡入视图。通常,hero 是 UI 的一小部分,例如图像,它们是两个路由共有的。从用户的角度来看,hero 在路由之间“飞行”。本指南展示了如何创建以下 Hero 动画

标准 Hero 动画

标准 Hero 动画将 hero 从一个路由飞到新路由,通常在不同的位置以不同的尺寸着陆。

以下视频(慢速录制)展示了一个典型示例。点击路由中心处的鳍状物会将其飞到新的蓝色路由的左上角,尺寸更小。点击蓝色路由中的鳍状物(或使用设备的返回上一路由手势)会将鳍状物飞回原始路由。

在新标签页中观看 YouTube 视频:“Flutter 中的标准 Hero 动画”

径向 Hero 动画

径向 Hero 动画中,当 hero 在路由之间飞行时,其形状会从圆形变为矩形。

以下视频(慢速录制)展示了一个径向 Hero 动画的示例。开始时,路由底部显示一排三个圆形图像。点击任意圆形图像会将其飞到新路由,以方形显示。点击方形图像会将 hero 飞回原始路由,并以圆形显示。

在新标签页中观看 YouTube 视频:“Flutter 中的径向 Hero 动画”

在进入特定于标准径向 Hero 动画的部分之前,请阅读Hero 动画的基本结构以了解如何构建 Hero 动画代码,并阅读幕后原理以了解 Flutter 如何执行 Hero 动画。

Hero 动画的基本结构

#

Hero 动画是使用两个 Hero widget 实现的:一个描述源路由中的 widget,另一个描述目标路由中的 widget。从用户的角度来看,hero 似乎是共享的,只有程序员需要理解这个实现细节。Hero 动画代码具有以下结构

  1. 定义一个起始 Hero widget,称为源 hero。hero 指定其图形表示(通常是图像)和标识标签,并位于源路由定义的当前显示 widget 树中。
  2. 定义一个结束 Hero widget,称为目标 hero。此 hero 也指定其图形表示,并与源 hero 具有相同的标签。两个 Hero widget 必须使用相同的标签创建,通常是表示底层数据的对象,这一点至关重要。为获得最佳效果,这些 hero 应该具有几乎相同的 widget 树。
  3. 创建一个包含目标 hero 的路由。目标路由定义了动画结束时存在的 widget 树。
  4. 通过将目标路由推入 Navigator 的堆栈来触发动画。Navigator 的推入和弹出操作会为源路由和目标路由中每对具有匹配标签的 hero 触发 Hero 动画。

Flutter 计算将 Hero 边界从起点动画到终点(插值大小和位置)的 tween,并在 overlay 中执行动画。

下一节将更详细地描述 Flutter 的过程。

幕后原理

#

以下描述了 Flutter 如何执行从一个路由到另一个路由的过渡。

Before the transition the source hero appears in the source route

在过渡之前,源 hero 在源路由的 widget 树中等待。目标路由尚未存在,且 overlay 为空。


The transition begins

将路由推入 Navigator 会触发动画。在 t=0.0 时,Flutter 执行以下操作:

  • 使用 Material motion 规范中描述的曲线运动,在屏幕外计算目标 hero 的路径。Flutter 现在知道 hero 的最终位置。

  • 将目标 hero 放置在 overlay 中,其位置和大小与 hero 相同。将 hero 添加到 overlay 会改变其 Z 轴顺序,使其显示在所有路由的顶部。

  • 将源 hero 移出屏幕。


The hero flies in the overlay to its final position and size

当 hero 飞行时,其矩形边界使用 Tween<Rect> 进行动画,这在 Hero 的 createRectTween 属性中指定。默认情况下,Flutter 使用 MaterialRectArcTween 的实例,该实例沿曲线路径动画矩形的对角。 (有关使用不同 Tween 动画的示例,请参阅径向 Hero 动画。)


When the transition is complete, the hero is moved from the overlay to the destination route

当飞行完成时

  • Flutter 将 hero widget 从 overlay 移动到目标路由。此时 overlay 为空。

  • 目标 hero 出现在目标路由中的最终位置。

  • 源 hero 被恢复到其路由。


弹出路由执行相同的过程,将 hero 动画回其在源路由中的大小和位置。

核心类

#

本指南中的示例使用以下类来实现 Hero 动画:

Hero
从源路由飞到目标路由的 widget。为源路由定义一个 Hero,为目标路由定义另一个 Hero,并为它们分配相同的标签。Flutter 会为具有匹配标签的 Hero 对执行动画。
InkWell
指定点击 hero 时发生的情况。InkWellonTap() 方法会构建新路由并将其推入 Navigator 的堆栈。
Navigator
Navigator 管理着一个路由堆栈。将路由推入或弹出 Navigator 的堆栈会触发动画。
Route
指定一个屏幕或页面。大多数应用,除了最基本的应用之外,都具有多个路由。

标准 Hero 动画

#

发生了什么?

#

使用 Flutter 的 hero widget 可以轻松实现将图像从一个路由飞到另一个路由。当使用 MaterialPageRoute 指定新路由时,图像会沿着曲线路径飞行,如 Material Design motion 规范中所述。

创建新的 Flutter 应用,并使用 hero_animation 中的文件对其进行更新。

运行示例

  • 点击主路由的照片,将图像飞到新路由,在新路由中以不同的位置和比例显示相同的照片。
  • 通过点击图像或使用设备的返回上一路由手势返回上一路由。
  • 你可以使用 timeDilation 属性进一步减慢过渡速度。

PhotoHero 类

#

自定义 PhotoHero 类维护 hero 及其大小、图像和点击行为。PhotoHero 构建以下 widget 树:

PhotoHero class widget tre

代码如下

dart
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,并设置过渡。

代码如下

dart
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 的堆栈会触发动画。
  • ContainerPhotoHero 定位在目标路由的左上角,位于 AppBar 下方。
  • 目标 PhotoHeroonTap() 方法会弹出 Navigator 的堆栈,触发将 Hero 飞回原始路由的动画。
  • 调试时使用 timeDilation 属性减慢过渡速度。

径向 Hero 动画

#

将 hero 从一个路由飞到另一个路由,同时将其从圆形形状转换为矩形形状,这是一种你可以使用 Hero widget 实现的流畅效果。为此,代码会动画两个裁剪形状的交集:一个圆形和一个方形。在整个动画过程中,圆形裁剪(和图像)从 minRadius 缩放到 maxRadius,而方形裁剪保持恒定大小。同时,图像从其在源路由中的位置飞到其在目标路由中的位置。有关此过渡的视觉示例,请参阅 Material motion 规范中的径向变换

此动画可能看起来很复杂(确实如此),但你可以根据自己的需要自定义所提供的示例。繁重的工作已为你完成。

发生了什么?

#

下图显示了动画开始(t = 0.0)和结束(t = 1.0)时裁剪后的图像。

Radial transformation from beginning to end

蓝色渐变(代表图像)表示裁剪形状的交集位置。在过渡开始时,交集的结果是一个圆形裁剪(ClipOval)。在变换过程中,ClipOvalminRadius 缩放到 maxRadius,而 ClipRect 保持恒定大小。在过渡结束时,圆形和矩形裁剪的交集会产生一个与 hero widget 大小相同的矩形。换句话说,在过渡结束时,图像不再被裁剪。

创建新的 Flutter 应用,并使用 radial_hero_animation GitHub 目录中的文件对其进行更新。

运行示例

  • 点击三个圆形缩略图之一,将图像动画为位于新路由中间的更大方形,该新路由会遮挡原始路由。
  • 通过点击图像或使用设备的返回上一路由手势返回上一路由。
  • 你可以使用 timeDilation 属性进一步减慢过渡速度。

Photo 类

#

Photo 类构建包含图像的 widget 树:

dart
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 树:

RadialExpansion widget tree

代码如下

dart
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 插值。

    代码如下

    dart
    static RectTween _createRectTween(Rect? begin, Rect? end) {
      return MaterialRectCenterArcTween(begin: begin, end: end);
    }

    hero 的飞行路径仍然遵循弧线,但图像的纵横比保持不变。