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

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

在新标签页中在 YouTube 上观看:“Hero | Flutter widget of the week”

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

你可以在 Flutter 中使用 Hero widget 创建此动画。当英雄从源路由动画到目标路由时,目标路由(减去英雄)逐渐淡入视野。通常,英雄是 UI 的一小部分,例如图像,并且两个路由都具有这些共同部分。从用户的角度来看,英雄在路由之间“飞翔”。本指南展示了如何创建以下英雄动画

标准 Hero 动画

**标准英雄动画**将英雄从一个路由飞到新路由,通常会降落在不同的位置并具有不同的大小。

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

在新标签页中在 YouTube 上观看:“Flutter 中的标准英雄动画”

径向 Hero 动画

在**径向英雄动画**中,当英雄在路由之间飞行时,其形状似乎从圆形变为矩形。

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

在新标签页中在 YouTube 上观看:“Flutter 中的径向英雄动画”

在转到特定于标准径向英雄动画的部分之前,请阅读英雄动画的基本结构以了解如何构造英雄动画代码,并阅读幕后以了解 Flutter 如何执行英雄动画。

Hero 动画的基本结构

#

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

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

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

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

幕后

#

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

Before the transition the source hero appears in the source route

在转换之前,源英雄在源路由的 widget 树中等待。目标路由尚不存在,并且覆盖层为空。


The transition begins

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

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

  • 将目标英雄放置在覆盖层中,其位置和大小与**源**英雄相同。将英雄添加到覆盖层会改变其 Z 轴顺序,使其显示在所有路由之上。

  • 将源英雄移出屏幕。


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

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


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

当飞行完成时

  • Flutter 将 hero widget 从覆盖层移动到目标路由。覆盖层现在为空。

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

  • 源英雄恢复到其路由。


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

核心类

#

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

Hero
从源路由飞到目标路由的 widget。为源路由定义一个 Hero,为目标路由定义另一个 Hero,并为它们分配相同的标签。Flutter 动画具有匹配标签的英雄对。
InkWell
指定点击英雄时发生的情况。`InkWell` 的 `onTap()` 方法构建新路由并将其推送到 `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 添加点击手势。
  • 将 Material widget 定义为透明颜色,使图像在飞向目的地时能够从背景中“弹出”。
  • `SizedBox` 指定 hero 在动画开始和结束时的大小。
  • 将图像的 `fit` 属性设置为 `BoxFit.contain`,确保图像在过渡期间尽可能大,同时不改变其纵横比。

HeroAnimation 类

#

`HeroAnimation` 类创建源和目标 PhotoHeroes,并设置过渡。

这是代码

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

径向 Hero 动画

#

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

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

发生了什么?

#

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

Radial transformation from beginning to end

蓝色渐变(代表图像)表示剪裁形状的交集。在过渡开始时,交集的结果是圆形剪裁(`ClipOval`)。在变换过程中,`ClipOval` 从 `minRadius` 缩放到 `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`定义 tween 插值。hero 动画的默认飞行路径使用英雄的角插值 tween。此方法在径向变换期间影响 hero 的纵横比,因此新的飞行路径使用 `MaterialRectCenterArcTween` 使用每个英雄的中心点插值 tween。

    这是代码

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

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