不知不觉到了 Hero 动画
其实在我们的开发过程中,我们可能已经看见过 Hero 动画了,比如像电商类 App 的一个典型场景,商品列表页到商品详情页,列表页的缩略图需要带到详情页,并且带的过程中可能有大小,位置等的变化。在 Flutter 中,这种页面简直共有元素的动画就叫做 Hero 动画 ,有的时候也叫做 共享元素动画。
Flutter 官方的每周组件也介绍了 Hero 组件:👉 Hero 组件介绍
本文就演示如何构建标准的 Hero 动画,以及在页面过渡过程中将图像从圆形转换为方形的 Hero 动画。
可以使用 Hero 组件来创建这种动画。随着Hero动画从源路由到目标路由,目标路由也会在这一过程中淡入到视图上。一般来说,Hero 组件是两个页面 UI 的一部分,比如图片等等。从用户体验的角度来说,Hero 组件是从源路由飞到了目标路由。我们就用代码实现下面的 Hero 效果。
Standard hero animations
标准的 Hero 动画是 Hero 元素从一个页面飞到另一个页面,并且一般情况下位置和尺寸会有变化。比如是这样的:
第一个页面图片是在中间的,到了第二个蓝色页面,图片的位置和大小都发生了变化。从第二个页面到第一个页面,图片又还原到最初的样子。
Radial hero animations
在 radial hero 动画中, 随着页面的过渡,Hero的形状会发生变化从圆形到矩形。比如下面的效果:
上面的效果就是一个radial hero 动画,底部的三个元素,依次展示到第二个页面的中间,并且形状从圆形到矩形。从第二个页面回到第一个页面,图片元素还原到最初的样子。
Hero 动画的基本结构
- 在不同的 Route 声明两个 Hero 组件,两个 Hero 组件的 tag 要一致。
- Navigator 管理应用的路由栈
- 路由的 Push 或者 Pop 触发 Hero 动画
- 边框效果是由
RectTween实现的,从源路由到目标路由的过程中,这个效果值会变化。也许你可能会有疑问,为啥第二个路由还没显示呢,作为页面的一部分的 Hero 却可以显示? 因为在过渡期间,Hero 是放在应用的 Overlay 上的,所以它才可以显示在所有的 Route 上。
Hero 动画是由两个 Hero 组件实现的,一个在源路由中,一个在目标路由中。虽然从用户体验的角度,两个 UI 是共享的,只是样子变化了。这都不重要,因为只需要我们程序知道怎么计算的就可以了😭。
这里注意一点,Hero 动画不能加到 Dialog 上
Hero 动画主要是下面几部分:
在源路由定义一个 Hero 组件,这个组件叫做 源 hero,需要给 源hero 设置两个参数,待添加动画的组件,比如图片等等,和动画的唯一标示 tag
在目标路由定义一个 Hero 组件,这个组件叫做目标 hero,这个目标Hero需要和源Hero的tag一样,这也是最重要的一点,并且目标Hero也需要包裹一个带添加动画的组件。为了动画的效果达到最佳,目标Hero和源Hero包裹的内容最好一样
创建一个包含 目标Hero 的路由,路由定义的树会在动画结束时渲染出来
Navigator 的 push 或者 pop 操作会触发 Hero 动画,会去匹配 Hero 动画的 tag
Flutter 会计算 Hero 动画从开始到结束的补间,补间就是效果比如尺寸大小和位置摆放。真正承载动画效果的是在 overlay 中,而不是源或者目标路由中。
幕后工作
下面我们就介绍 Flutter 是怎么执行 Hero 的。
在执行动画之前,源 Hero 在 源路由的 Widget 树上。目标路由还不存在,Overlay 也是空的。
我们使用 Navigator Push 一个 路由,就会触发动画的执行。在动画开始的时刻,也就是 t=0.0, Flutter 就会执行下面的动作:
现在 Flutter 已经知道 Hero 动画到哪里停止,它会计算 Hero 动画的路径,动画的效果是 Material 运动的设计规范,这里注意一点,动画是不依附任何页面的
把 目标Hero 和 源Hero 都放在 Overlay 上,他们的大小和尺寸都是我们给他设置的。在Overlay 上进行动画效果,所以可以在页面之上显示效果
页面之上进行动画
当 Hero 动画移动的时候,边框效果使用 Tween ,具体的实现是 Hero 刻的 createRectTween 方法。默认情况下,Flutter 使用的 MaterialRectArcTween 效果。
动画完成之后:
- 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 组件,让源路由和目标路由的手势添加变得简单了。- 代码中的
Material和Colors.transparent的效果是,当图片动画到目的地之后,图像可以从背景中 “pop out” (弹出来)。 SizedBox的含义是指定Hero的大小- Image 的
fit属性是为了让图片在容器内尽可能大,这个尽可能大是指不改变宽高比。可以看这里👉图文组件
PhotoHero 的树结构是:
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属性让动画变慢了
动画的效果:
Radial hero animations
关键点
radial 效果是把圆形的边框动画成方形边框
从源路由到目标路由,Hero 执行径向的转换。
MaterialRectCenterArcTween 定义了径向效果
使用
PageRouteBuilder定义目标路由
进行页面跳转的同时进行形状的变化,会让动画更加的流畅。为了实现这一效果,代码会动画两个形状的交集:圆形和正方形。在整个动画过程中,圆形的裁剪从 minRadius 到 maxRadius,方形的裁剪始终保持同一个大小。同时,图片也从源路由动画到目标路由的指定位置。
动画可能看起来很复杂(确实很复杂),但开发者可以根据需要定制所提供的示例。一般性的代码已经完成了。
继续写代码
下面的算法展示了图片的裁剪过程,从开始的(t = 0.0)到结束的(t = 1.0)。
蓝色的渐变代表图片,表示裁剪形状的交点。在动画的开始,相交的结果是一个圆形。在动画过程中,ClipOval 从 minRadius 缩放到 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
),
),
),
);
}
}
上面代码形成的节点树:
关键点:
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
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。