注册

不知不觉到了 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 ,具体的实现是 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(
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(
pageBuilder: (BuildContext context,
Animationanimation,
AnimationsecondaryAnimation) {
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(),
),
);
}

作者:Time_sun
链接:https://juejin.cn/post/7051933760702382094
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

0 个评论

要回复文章请先登录注册