跳到主内容

英雄动画

如何让组件在两个屏幕之间飞入飞出。

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

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

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

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

你可以使用 Hero 组件在 Flutter 中创建此动画。当 Hero 从源路由动画到目标路由时,目标路由(除去 Hero 本身)会淡入视野。通常,Hero 是 UI 的一小部分(如图像),且两个路由都包含该部分。从用户的角度来看,Hero 在路由之间“飞行”。本指南展示了如何创建以下 Hero 动画

标准 Hero 动画

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

以下视频(以慢速录制)展示了一个典型的例子。点击路由中心的脚蹼(flippers),它们会飞到新蓝色路由的左上角,并缩小尺寸。点击蓝色路由中的脚蹼(或使用设备的返回上一路由手势),脚蹼会飞回原始路由。

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

径向 Hero 动画

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

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

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

在进入标准径向 Hero 动画的具体章节之前,请阅读Hero 动画的基本结构以了解如何组织代码,并阅读幕后原理以了解 Flutter 如何执行 Hero 动画。

Hero 动画的基本结构

#

Hero 动画是使用两个 Hero 组件实现的:一个描述源路由中的组件,另一个描述目标路由中的组件。从用户的角度来看,Hero 看起来是共享的,只有开发人员需要了解这个实现细节。Hero 动画代码具有以下结构

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

Flutter 计算将 Hero 边界从起点动画到终点(插值计算尺寸和位置)的补间,并在覆盖层中执行动画。

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

幕后原理

#

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

Before the transition the source hero appears in the source route

在过渡之前,源 Hero 等待在源路由的组件树中。目标路由尚不存在,覆盖层为空。


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 飞行时,其矩形边界使用 Hero 的 createRectTween 属性中指定的 Tween<Rect> 进行动画处理。默认情况下,Flutter 使用 MaterialRectArcTween 的实例,它沿着曲线路径对矩形的相对角进行动画处理。(有关使用不同补间动画的示例,请参阅径向 Hero 动画。)


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

飞行完成时

  • Flutter 将 Hero 组件从覆盖层移动到目标路由。覆盖层现在为空。

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

  • 源 Hero 被恢复到其原始路由。


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

关键类

#

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

Hero

从源路由飞向目标路由的组件。为源路由定义一个 Hero,为目标路由定义另一个 Hero,并为每个 Hero 分配相同的标签。Flutter 会对具有匹配标签的 Hero 对进行动画处理。

InkWell

指定点击 Hero 时会发生什么。InkWellonTap() 方法构建新路由并将其推入 Navigator 的堆栈。

Navigator

Navigator 管理路由堆栈。将路由推入或从 Navigator 堆栈中弹出路由会触发该动画。

Route

指定屏幕或页面。除了最基础的应用外,大多数应用都有多个路由。

标准 Hero 动画

#

发生了什么?

#

使用 Flutter 的 Hero 组件可以轻松实现将图像从一个路由飞向另一个路由。使用 MaterialPageRoute 指定新路由时,图像会按照 Material Design 运动规范中所述,沿着曲线路径飞行。

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

运行示例

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

PhotoHero 类

#

自定义的 PhotoHero 类维护着 Hero 及其尺寸、图像和点击时的行为。PhotoHero 构建了以下组件树

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 包装了图像,可以轻松地为源 Hero 和目标 Hero 添加点击手势。
  • 将 Material 组件定义为透明颜色,使得图像在飞向目的地时能够从背景中“弹出”。
  • SizedBox 指定了 Hero 在动画开始和结束时的尺寸。
  • 将图像的 fit 属性设置为 BoxFit.contain,确保图像在过渡过程中尽可能大,且不会改变其纵横比。

HeroAnimation 类

#

HeroAnimation 类创建了源和目标 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 组件实现的炫酷效果。为了实现这一点,代码对两个剪裁形状的交集进行了动画处理:一个圆形和一个正方形。在整个动画过程中,圆形剪裁(和图像)从 minRadius 缩放到 maxRadius,而方形剪裁保持恒定尺寸。与此同时,图像从其在源路由中的位置飞向其在目标路由中的位置。有关此过渡的视觉示例,请参阅 Material 运动规范中的径向变换

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

发生了什么?

#

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

Radial transformation from beginning to end

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

创建一个新的 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.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 组件是该演示的核心,它构建了在过渡期间剪裁图像的组件树。剪裁后的形状是由圆形剪裁(在飞行过程中增长)与矩形剪裁(在整个过程中保持恒定大小)的交集产生的。

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

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 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 的中心点来插值补间。

    代码如下

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

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