自实现Hero动画
实现一个头像组件,初始是一个圆形的小图片,点击后查看大图,小图片变大图和大图变回小图片分别执行过渡动画:
实现方案一:
class CustomHeroAnimation extends StatefulWidget {
const CustomHeroAnimation({Key? key}) : super(key: key);
@override
_CustomHeroAnimationState createState() => _CustomHeroAnimationState();
}
class _CustomHeroAnimationState extends State<CustomHeroAnimation>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
bool _animating = false;
AnimationStatus? _lastAnimationStatus;
late Animation _animation;
//两个组件在Stack中所占的区域
Rect? child1Rect;
Rect? child2Rect;
@override
void initState() {
_controller =
AnimationController(vsync: this, duration: Duration(milliseconds: 200));
//应用curve
_animation = CurvedAnimation(
parent: _controller,
curve: Curves.easeIn,
);
_controller.addListener(() {
if (_controller.isCompleted || _controller.isDismissed) {
if (_animating) {
setState(() {
_animating = false;
});
}
} else {
_lastAnimationStatus = _controller.status;
}
});
super.initState();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
//小头像
final Widget child1 = wChild1();
//大头像
final Widget child2 = wChild2();
//是否展示小头像;只有在动画执行时、初始状态或者刚从大图变为小图时才应该显示小头像
bool showChild1 =
!_animating && _lastAnimationStatus != AnimationStatus.forward;
// 执行动画时的目标组件;如果是从小图变为大图,则目标组件是大图;反之则是小图
Widget targetWidget;
if (showChild1 || _controller.status == AnimationStatus.reverse) {
targetWidget = child1;
} else {
targetWidget = child2;
}
return LayoutBuilder(builder: (context, constraints) {
return SizedBox(
//我们让Stack 填满屏幕剩余空间
width: constraints.maxWidth,
height: constraints.maxHeight,
child: Stack(
alignment: AlignmentDirectional.topCenter,
children: [
if (showChild1)
AfterLayout(
//获取小图在Stack中占用的Rect信息
callback: (value) => child1Rect = _getRect(value),
child: child1,
),
if (!showChild1)
AnimatedBuilder(
animation: _animation,
builder: (context, child) {
//求出 rect 插值
final rect = Rect.lerp(
child1Rect,
child2Rect,
_animation.value,
);
// 通过 Positioned 设置组件大小和位置
return Positioned.fromRect(rect: rect!, child: child!);
},
child: targetWidget,
),
// 用于测量 child2 的大小,设置为全透明并且不能响应事件
IgnorePointer(
child: Center(
child: Opacity(
opacity: 0,
child: AfterLayout(
//获取大图在Stack中占用的Rect信息
callback: (value) => child2Rect = _getRect(value),
child: child2,
),
),
),
),
],
),
);
});
}
Widget wChild1() {
//点击后执行正向动画
return GestureDetector(
onTap: () {
setState(() {
_animating = true;
_controller.forward();
});
},
child: SizedBox(
width: 50,
child: ClipOval(child: Image.asset("imgs/avatar.png")),
),
);
}
Widget wChild2() {
// 点击后执行反向动画
return GestureDetector(
onTap: () {
setState(() {
_animating = true;
_controller.reverse();
});
},
child: Image.asset("imgs/avatar.png", width: 400),
);
}
Rect _getRect(RenderAfterLayout renderAfterLayout) {
//我们需要获取的是AfterLayout子组件相对于Stack的Rect
return renderAfterLayout.localToGlobal(
Offset.zero,
//找到Stack对应的 RenderObject 对象
ancestor: context.findRenderObject(),
) &
renderAfterLayout.size;
}
}
Hero动画
Hero指可以在路由页面之间飞行的Widget,简单来说Hero动画就是在路由切换时有一个共享的Widget可以在新旧路由间切换,由于共享的Widget在新旧路由页面上的位置、外观可能有差异,所以在路由切换会从旧路逐渐过渡到新路由中指定的位置,这样会产生一个Hero动画。在Flutter中将图片从一个路由飞到另外一个路由称为hero动画,尽管相同的动作有时也称为共享元素转换。
示例:两个路由A和B
A:包含一个用户头像、圆形,点击后跳转到B路由,查看大图
B:显示用户头像原图,矩形。
在AB两个路由之间跳转的时候,用户头像会逐渐过渡到目标路由页的头像上。
路由A:
class HeroAnimationRouteA extends StatelessWidget {
const HeroAnimationRouteA({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return Container(
alignment: Alignment.topCenter,
child: Column(
children: <Widget>[
InkWell(
child: Hero(
tag: "avatar", //唯一标记,前后两个路由页Hero的tag必须相同
child: ClipOval(
child: Image.asset(
"imgs/avatar.png",
width: 50.0,
),
),
),
onTap: () {
//打开B路由
Navigator.push(context, PageRouteBuilder(
pageBuilder: (
BuildContext context,
animation,
secondaryAnimation,
) {
return FadeTransition(
opacity: animation,
child: Scaffold(
appBar: AppBar(
title: const Text("原图"),
),
body: const HeroAnimationRouteB(),
),
);
},
));
},
),
const Padding(
padding: EdgeInsets.only(top: 8.0),
child: Text("点击头像"),
)
],
),
);
}
}
路由B:
class HeroAnimationRouteB extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Center(
child: Hero(
tag: "avatar", //唯一标记,前后两个路由页Hero的tag必须相同
child: Image.asset("imgs/avatar.png"),
),
);
}
}
Hero动画只需要用Hero组件将需要共享的widget包装起来,并提供一个相同的tag即可,中间过渡帧都是Flutter框架自动完成的。需要注意的是,前后路由页的共享Hero的tag必须相同,Flutter框架内不正是通过tag来确定新旧路由页widget的对应关系的。
Hero动画的原理比较简单,Flutter框架知道新旧路由页中共享元素的位置和大小,所以根据这两个端点,在动画执行过程中求出过渡时动画的插值(中间态)即可。