不知不觉到了 Hero 动画

9,048 阅读11分钟

本篇文章翻译自 👉Hero animations,向开发者介绍了 Hero 动画的用处和分类,不同类型的 Hero 动画的创建。

其实在我们的开发过程中,我们可能已经看见过 Hero 动画了,比如像电商类 App 的一个典型场景,商品列表页到商品详情页,列表页的缩略图需要带到详情页,并且带的过程中可能有大小,位置等的变化。在 Flutter 中,这种页面简直共有元素的动画就叫做 Hero 动画 ,有的时候也叫做 共享元素动画

Flutter 官方的每周组件也介绍了 Hero 组件:👉 Hero 组件介绍

本文就演示如何构建标准的 Hero 动画,以及在页面过渡过程中将图像从圆形转换为方形的 Hero 动画。

可以使用 Hero 组件来创建这种动画。随着Hero动画从源路由到目标路由,目标路由也会在这一过程中淡入到视图上。一般来说,Hero 组件是两个页面 UI 的一部分,比如图片等等。从用户体验的角度来说,Hero 组件是从源路由飞到了目标路由。我们就用代码实现下面的 Hero 效果。

Standard hero animations

标准的 Hero 动画是 Hero 元素从一个页面到另一个页面,并且一般情况下位置和尺寸会有变化。比如是这样的:

standard (1).gif

第一个页面图片是在中间的,到了第二个蓝色页面,图片的位置和大小都发生了变化。从第二个页面到第一个页面,图片又还原到最初的样子。

Radial hero animations

radial hero 动画中, 随着页面的过渡,Hero的形状会发生变化从圆形到矩形。比如下面的效果:

radial (1).gif

上面的效果就是一个radial hero 动画,底部的三个元素,依次展示到第二个页面的中间,并且形状从圆形到矩形。从第二个页面回到第一个页面,图片元素还原到最初的样子。

Hero 动画的基本结构

  • 在不同的 Route 声明两个 Hero 组件,两个 Hero 组件的 tag 要一致。
  • Navigator 管理应用的路由栈
  • 路由的 Push 或者 Pop 触发 Hero 动画
  • 边框效果是由 RectTween 实现的,从源路由到目标路由的过程中,这个效果值会变化。也许你可能会有疑问,为啥第二个路由还没显示呢,作为页面的一部分的 Hero 却可以显示? 因为在过渡期间,Hero 是放在应用的 Overlay 上的,所以它才可以显示在所有的 Route 上。

Hero 动画是由两个 Hero 组件实现的,一个在源路由中,一个在目标路由中。虽然从用户体验的角度,两个 UI 是共享的,只是样子变化了。这都不重要,因为只需要我们程序知道怎么计算的就可以了😭。

这里注意一点,Hero 动画不能加到 Dialog 上

Hero 动画主要是下面几部分:

  1. 在源路由定义一个 Hero 组件,这个组件叫做 源 hero,需要给 源hero 设置两个参数,待添加动画的组件,比如图片等等,和动画的唯一标示 tag

  2. 在目标路由定义一个 Hero 组件,这个组件叫做目标 hero,这个目标Hero需要和源Hero的tag一样,这也是最重要的一点,并且目标Hero也需要包裹一个带添加动画的组件。为了动画的效果达到最佳,目标Hero源Hero包裹的内容最好一样

  3. 创建一个包含 目标Hero 的路由,路由定义的树会在动画结束时渲染出来

  4. Navigator 的 push 或者 pop 操作会触发 Hero 动画,会去匹配 Hero 动画的 tag

Flutter 会计算 Hero 动画从开始到结束的补间,补间就是效果比如尺寸大小和位置摆放。真正承载动画效果的是在 overlay 中,而不是源或者目标路由中。

幕后工作

下面我们就介绍 Flutter 是怎么执行 Hero 的。

image.png

在执行动画之前,源 Hero 在 源路由的 Widget 树上。目标路由还不存在,Overlay 也是空的。

image.png

我们使用 Navigator Push 一个 路由,就会触发动画的执行。在动画开始的时刻,也就是 t=0.0, Flutter 就会执行下面的动作:

  • 现在 Flutter 已经知道 Hero 动画到哪里停止,它会计算 Hero 动画的路径,动画的效果是 Material 运动的设计规范,这里注意一点,动画是不依附任何页面的

  • 把 目标Hero 和 源Hero 都放在 Overlay 上,他们的大小和尺寸都是我们给他设置的。在Overlay 上进行动画效果,所以可以在页面之上显示效果

  • 页面之上进行动画

image.png

当 Hero 动画移动的时候,边框效果使用 Tween<Rect> ,具体的实现是 Hero 刻的 createRectTween 方法。默认情况下,Flutter 使用的 MaterialRectArcTween 效果。

image.png

动画完成之后:

  • Flutter 会把 Overlay 上的目标Hero,移动到目标路由(页面)上,Overlay 就是空的了。
  • 目标Hero 就出现在了页面上最终的位置
  • 源Hero就存储在了页面上

Push 页面 Hero 动画会前进,Pop 页面会让 Hero 动画反向执行。

关键类

Hero 动画的实现需要使用到下面的类:

  • Hero 是一个动画组件,会让子组件从源路由动画到目标路由,使用的时候需要指定相同的tag属性。Flutter 会用 tag 匹对 Hero。

  • Inkwell 用于手势识别,onTap() 的执行的时候 push 个新的页面,触发 Hero 动画。

  • Navigator 管理路由栈,可以 Push 或者 Pop

  • Route 承载一个页面,一般情况下,一个 Route 代表了 一个页面。大多数应用都是多路由的。

标准的 Hero 动画

关键点

  • 使用 MaterialPageRoute、 CupertinoPageRoute、 自定义 PageRouteBuilder 指定路由,案例用的是 MaterialPageRoute

  • 使用 SizedBox 组件包裹 Image 组件,实现页面切换时,尺寸动画的效果

  • 把图片组件放在目标页面的 Widget 树上,源页面和目标页面的 Widget 树不同,Image 组件在树中的位置也不同。

继续写代码

从一个页面到另一页面的动画可以使用 Flutter 的 Hero 组件,如果目标路由是 MaterialPageRoute ,那么动画的效果会使用 👉Material的效果

Create a new Flutter example  使用代码👉 hero_animation.

按着下面的步骤运行:

  • 点击主页页面的图片,会打开新页面,新页面会呈现一个不同尺寸和位置的图片

  • 点击图像或者物理返回会返回到前一个路由

  • 可以使用 timeDilation 属性让动画的速度降下来

PhotoHero 类

自定义的 PhotoHero 类维护这个 Hero、尺寸、图片和点击的行为,代码如下:

class PhotoHero extends StatelessWidget {
  const PhotoHero({ Key key, this.photo, this.onTap, this.width }) : super(key: key);

  final String photo;
  final VoidCallback onTap;
  final double width;

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

代码的关键信息:

  • InkWell 包裹了 Image 组件,让源路由和目标路由的手势添加变得简单了。
  • 代码中的 MaterialColors.transparent 的效果是,当图片动画到目的地之后,图像可以从背景中 “pop out” (弹出来)。
  • SizedBox 的含义是指定Hero的大小
  • Image 的 fit 属性是为了让图片在容器内尽可能大,这个尽可能大是指不改变宽高比。可以看这里👉图文组件

PhotoHero 的树结构是:

photohero-class.png

HeroAnimation 类

PhotoHero 类是显示类,HeroAnimation 类是动画类,这个类创建了源路由和目标路由,并且关联了动画。

代码如下:

class HeroAnimation extends StatelessWidget {
  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: (BuildContext context) {
                return Scaffold(
                  appBar: AppBar(
                    title: const Text('Flippers Page'),
                  ),
                  body: Container(
                    // The blue background emphasizes that it's a new route.
                    color: Colors.lightBlueAccent,
                    padding: const EdgeInsets.all(16.0),
                    alignment: Alignment.topLeft,
                    child: PhotoHero(
                      photo: 'images/flippers-alpha.png',
                      width: 100.0,
                      onTap: () {
                        Navigator.of(context).pop();
                      },
                    ),
                  ),
                );
              }
            ));
          },
        ),
      ),
    );
  }
}

关键信息:

  • 用户点击图片创建一个 MaterialPageRoute 的路由,并且使用 Navigator 把路由添加到栈中

  • Container 容器让 PhotoHero 放置在页面的左上角,当然在 AppBar 的下面

  • onTap() 触发页面切换和动画

  • timeDilation 属性让动画变慢了

动画的效果: standard (1).gif


Radial hero animations

关键点

  • radial 效果是把圆形的边框动画成方形边框

  • 从源路由到目标路由,Hero 执行径向的转换。

  • MaterialRectCenterArcTween 定义了径向效果

  • 使用 PageRouteBuilder 定义目标路由

进行页面跳转的同时进行形状的变化,会让动画更加的流畅。为了实现这一效果,代码会动画两个形状的交集:圆形和正方形。在整个动画过程中,圆形的裁剪从 minRadius 到 maxRadius,方形的裁剪始终保持同一个大小。同时,图片也从源路由动画到目标路由的指定位置。

动画可能看起来很复杂(确实很复杂),但开发者可以根据需要定制所提供的示例。一般性的代码已经完成了。

继续写代码

下面的算法展示了图片的裁剪过程,从开始的(t = 0.0)到结束的(t = 1.0)。

Radial transformation from beginning to end

蓝色的渐变代表图片,表示裁剪形状的交点。在动画的开始,相交的结果是一个圆形。在动画过程中,ClipOvalminRadius 缩放到 maxRadius,而 ClipRect 保持恒定的大小。在动画的最后,圆形和矩形的交集会生成一个矩形,这个矩形与 Hero 组件的大小相同。也就是说,在动画结束时,图像不再被裁剪。

动画的代码在这里👉radial_hero_animation

按着下面的步骤操作:

  • 点击三个圆形缩略图中的一个,使图像动画到一个更大的正方形,正方形在目标路由的中间

  • 点击图像返回到上一个源路由,也可以物理返回

  • 使用 timeDilation 属性慢放动画

Photo class

The Photo class builds the widget tree that holds the image:

content_copy

class Photo extends StatelessWidget {
  Photo({ Key key, this.photo, this.color, this.onTap }) : super(key: key);

  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 组件捕捉点击事件,执行的动作是构造方法传进来的回调

  • 动画期间,InkWell 会使用第一个 Material 祖先节点的效果,比如水波纹等等

  • Material 组件有一个稍微不透明的背景色,这样即使是图片透明的部分也会有一个背景色。确保了圆形到方形的过渡很容易被看到。

  • Photo 类中没有包含 Hero 组件,为了让动画生效,Hero 包装了 RadialExpansion 组件。

RadialExpansion class

RadialExpansion 组件是 Demo 的核心,构建了 裁剪图片的 Widget树。裁剪的形状是圆形和矩形的交集,圆形是随着动画正向变大,反向变小的,矩形的大小是不变的。

代码如下:

class RadialExpansion extends StatelessWidget {
  RadialExpansion({
    Key key,
    this.maxRadius,
    this.child,
  }) : clipRectSize = 2.0 * (maxRadius / math.sqrt2),
       super(key: key);

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

上面代码形成的节点树:

radial-expansion-class.png

关键点:

  • Hero 组件包裹了 RadialExpansion 组件

  • 在动画的过程中,它的尺寸和 RadialExpansion 的尺寸都会改变

  • RadialExpansion 动画是被两个重叠的裁剪组件创建的

  • 案例使用 MaterialRectCenterArcTween 定义了补间的插值,默认的动画路径使用 Hero 角度的计算值(sqrt)来进行插值。这种方法会在径向变化期间会影响Hero的长宽比。因此径向动画使用 MaterialRectCenterArcTween 来使用 Hero的中心点和角度计算进行差值。

代码如下:

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

完成的代码是这样的:

import 'dart:math' as math;

import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart' show timeDilation;

class Photo extends StatelessWidget {
  const Photo({Key? key, required this.photo, this.onTap}) : super(key: key);

  final String photo;
  final VoidCallback? onTap;

  @override
  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: LayoutBuilder(
          builder: (BuildContext context, BoxConstraints size) {
            return Image.asset(
              photo,
              fit: BoxFit.contain,
            );
          },
        ),
      ),
    );
  }
}

class RadialExpansion extends StatelessWidget {
  const RadialExpansion({
    Key? key,
    required this.maxRadius,
    this.child,
  })  : clipRectSize = 2.0 * (maxRadius / math.sqrt2),
        super(key: key);

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

class RadialExpansionDemo extends StatelessWidget {
  const RadialExpansionDemo({Key? key}) : super(key: key);

  static double kMinRadius = 32.0;
  static double kMaxRadius = 128.0;
  static Interval opacityCurve =
      const Interval(0.0, 0.75, curve: Curves.fastOutSlowIn);

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

  static Widget _buildPage(
      BuildContext context, String imageName, String description) {
    return Container(
      color: Theme.of(context).canvasColor,
      child: Center(
        child: Card(
          elevation: 8.0,
          child: Column(
            mainAxisSize: MainAxisSize.min,
            children: [
              SizedBox(
                width: kMaxRadius * 2.0,
                height: kMaxRadius * 2.0,
                child: Hero(
                  createRectTween: _createRectTween,
                  tag: imageName,
                  child: RadialExpansion(
                    maxRadius: kMaxRadius,
                    child: Photo(
                      photo: imageName,
                      onTap: () {
                        Navigator.of(context).pop();
                      },
                    ),
                  ),
                ),
              ),
              Text(
                description,
                style: const TextStyle(fontWeight: FontWeight.bold),
                textScaleFactor: 3.0,
              ),
              const SizedBox(height: 16.0),
            ],
          ),
        ),
      ),
    );
  }

  Widget _buildHero(
      BuildContext context, String imageName, String description) {
    return SizedBox(
      width: kMinRadius * 2.0,
      height: kMinRadius * 2.0,
      child: Hero(
        createRectTween: _createRectTween,
        tag: imageName,
        child: RadialExpansion(
          maxRadius: kMaxRadius,
          child: Photo(
            photo: imageName,
            onTap: () {
              Navigator.of(context).push(
                PageRouteBuilder<void>(
                  pageBuilder: (BuildContext context,
                      Animation<double> animation,
                      Animation<double> secondaryAnimation) {
                    return AnimatedBuilder(
                        animation: animation,
                        builder: (BuildContext context, Widget? child) {
                          return Opacity(
                            opacity: opacityCurve.transform(animation.value),
                            child: _buildPage(context, imageName, description),
                          );
                        });
                  },
                ),
              );
            },
          ),
        ),
      ),
    );
  }

  @override
  Widget build(BuildContext context) {
    timeDilation = 5.0; // 1.0 is normal animation speed.

    return Scaffold(
      appBar: AppBar(
        title: const Text('Radial Transition Demo'),
      ),
      body: Container(
        padding: const EdgeInsets.all(32.0),
        alignment: FractionalOffset.bottomLeft,
        child: Row(
          mainAxisAlignment: MainAxisAlignment.spaceBetween,
          children: [
            _buildHero(context, 'images/chair-alpha.png', 'Chair'),
            _buildHero(context, 'images/binoculars-alpha.png', 'Binoculars'),
            _buildHero(context, 'images/beachball-alpha.png', 'Beach ball'),
          ],
        ),
      ),
    );
  }
}

void main() {
  runApp(
    const MaterialApp(
      home: RadialExpansionDemo(),
    ),
  );
}