英雄动画

您可能已经多次看到英雄动画。例如,一个屏幕显示一个缩略图列表,代表待售物品。选择一个物品会将其飞到一个新屏幕,其中包含更多详细信息和一个“购买”按钮。在 Flutter 中,将图像从一个屏幕飞到另一个屏幕称为英雄动画,尽管有时也称这种运动为共享元素转换

您可能想要观看此一分钟视频,介绍 Hero 窗口小部件

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

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

标准英雄动画

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

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


径向英雄动画

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

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


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

英雄动画的基本结构

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

  1. 定义一个起始英雄小部件,称为源英雄。英雄指定其图形表示(通常是图像)和识别标记,并且在源路由定义的当前显示小部件树中。
  2. 定义一个结束英雄小部件,称为目标英雄。此英雄还指定其图形表示,以及与源英雄相同的标记。至关重要的是,两个英雄小部件都使用相同的标记创建,通常是表示基础数据的对象。为了获得最佳效果,英雄应该具有几乎完全相同的小部件树。
  3. 创建一个包含目标英雄的路由。目标路由定义了动画结束时存在的部件树。
  4. 通过将目标路由推入导航器的堆栈来触发动画。导航器推送和弹出操作会为源路由和目标路由中具有匹配标记的每对英雄触发英雄动画。

Flutter 计算从起点到终点(插值大小和位置)对英雄边界进行动画处理的补间,并在覆盖层中执行动画。

下一部分将更详细地描述 Flutter 的流程。

幕后

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

Before the transition the source hero appears in the source route

在转换之前,源英雄在源路由的小部件树中等待。目标路由尚未存在,覆盖层为空。


The transition begins

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

  • 使用材料运动规范中描述的曲线运动,计算屏幕外目标英雄的路径。Flutter 现在知道英雄最终会出现在哪里。

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

  • 将源英雄移出屏幕。


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

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


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

当飞行完成时

  • Flutter 将英雄小部件从叠加层移到目标路由。叠加层现在为空。

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

  • 源英雄恢复到其路由。


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

基本类

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

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

标准英雄动画

发生了什么?

使用 Flutter 的英雄小组件,可以轻松实现将图像从一个路由飞到另一个路由。使用 MaterialPageRoute 指定新路由时,图像将沿着曲线路径飞行,如 Material Design 动效规范 所述。

创建新的 Flutter 示例,并使用 hero_animation 中的文件进行更新。

运行示例

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

PhotoHero 类

自定义 PhotoHero 类维护英雄及其大小、图像和轻触时的行为。PhotoHero 构建以下小组件树

PhotoHero class widget tree

代码如下

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

代码如下

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

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

发生了什么?

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

Radial transformation from beginning to end

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

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

运行示例

  • 轻触三个圆形缩略图中的一个,将图像动画处理为一个较大的正方形,该正方形位于新路由的中间,遮挡了原始路由。
  • 轻触图像或使用设备的返回到上一个路由的手势,返回到上一个路由。
  • 可以使用 timeDilation 属性进一步减慢过渡速度。

Photo 类

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

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

代码如下

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 来使用每个英雄的中心点插值补间。

    代码如下

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

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