跳至主要内容

英雄动画

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

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


Hero | Flutter widget of the week

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

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

标准 Hero 动画

标准 Hero 动画将 Hero 从一个路由飞到一个新路由,通常会落在不同的位置,并且大小也不同。

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


Flutter 中的标准 Hero 动画

径向 Hero 动画

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

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


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 的 push 和 pop 操作会为源路由和目标路由中具有匹配标签的每对 Hero 触发 Hero 动画。

Flutter 计算补间,该补间将 Hero 的边界从起点动画到终点(插值大小和位置),并在叠加层中执行动画。

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

幕后

#

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

Before the transition the source hero appears in the source route

过渡前,源 Hero 在源路由的 widget 树中等待。目标路由尚不存在,叠加层为空。


The transition begins

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

  • 根据 Material 运动规范中描述的曲线运动,计算目标 Hero 的路径(屏幕外)。Flutter 现在知道 Hero 最终会到达哪里。

  • 将目标 Hero 放置在叠加层中,与 Hero 的位置和大小相同。将 Hero 添加到叠加层会更改其 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 从叠加层移动到目标路由。叠加层现在为空。

  • 目标 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 运动规范 所述。

创建一个新的 Flutter 示例 并使用 hero_animation 中的文件对其进行更新。

要运行示例

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

PhotoHero 类

#

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

PhotoHero class widget tree

代码如下

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 包裹着图像,使得为源和目标英雄添加点击手势变得非常简单。
  • 使用透明颜色定义 Material 组件,使图像在飞向目标位置时能够“弹出”背景。
  • SizedBox 指定了动画开始和结束时英雄的大小。
  • 将 Image 的 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();
                      },
                    ),
                  ),
                );
              }
            ));
          },
        ),
      ),
    );
  }
}

关键信息

  • 当用户点击包含源英雄的 InkWell 时,代码使用 MaterialPageRoute 创建目标路由。将目标路由推入 Navigator 的堆栈会触发动画。
  • ContainerPhotoHero 定位在目标路由的左上角,位于 AppBar 下方。
  • 目标 PhotoHeroonTap() 方法弹出 Navigator 的堆栈,触发将 Hero 飞回原始路由的动画。
  • 使用 timeDilation 属性在调试时减慢过渡速度。

径向 Hero 动画

#

让英雄从一个路由飞到另一个路由,同时从圆形变换为矩形,这是一种流畅的效果,您可以使用 Hero 组件实现。为了实现这一点,代码动画化了两个剪辑形状的交集:圆形和方形。在整个动画过程中,圆形剪辑(以及图像)从 minRadius 缩放至 maxRadius,而方形剪辑保持恒定大小。同时,图像从其在源路由中的位置飞到其在目标路由中的位置。有关此过渡的视觉示例,请参阅材质运动规范中的径向变换

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

发生了什么?

#

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

Radial transformation from beginning to end

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

创建一个新的 Flutter 示例,并使用 radial_hero_animation GitHub 目录中的文件进行更新。

要运行示例

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

Photo 类

#

Photo 类构建包含图像的组件树。

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.withOpacity(0.25),
      child: InkWell(
        onTap: onTap,
        child: Image.asset(
          photo,
          fit: BoxFit.contain,
        ),
      ),
    );
  }
}

关键信息

  • InkWell 捕获点击手势。调用函数将 onTap() 函数传递给 Photo 的构造函数。
  • 在飞行过程中,InkWell 在其第一个 Material 父组件上绘制其水波纹。
  • Material 组件具有略微不透明的颜色,因此图像的透明部分会以颜色渲染。这确保了圆形到方形的过渡易于查看,即使对于具有透明度的图像也是如此。
  • Photo 类在其组件树中不包含 Hero。为了使动画正常工作,英雄需要包裹 RadialExpansion 组件。

RadialExpansion 类

#

RadialExpansion 组件是演示的核心,它构建了在过渡期间剪辑图像的组件树。剪辑形状是由圆形剪辑(在飞行过程中增长)与矩形剪辑(在整个过程中保持恒定大小)的交集产生的。

为此,它构建了以下组件树。

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
          ),
        ),
      ),
    );
  }
}

关键信息

  • 英雄包裹着 RadialExpansion 组件。

  • 当英雄飞行时,其大小会发生变化,并且因为它限制了其子组件的大小,所以 RadialExpansion 组件的大小也会随之改变。

  • RadialExpansion 动画由两个重叠的剪辑创建。

  • 示例使用MaterialRectCenterArcTween 定义补间插值。英雄动画的默认飞行路径使用英雄的角来插值补间。这种方法会影响径向变换期间英雄的纵横比,因此新的飞行路径使用 MaterialRectCenterArcTween 来使用每个英雄的中心点插值补间。

    代码如下

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

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