1.动画简介
Animation、Curve、Controller、Tween这四个角色,它们一起配合来完成一个完整动画
- Animation
Animation是抽象类,和UI渲染没有关系,功能是保存动画的插值和状态;比较常用的是Animation
addListener:帧监听器中最常见的行为是改变状态后调用setState()来触发UI重建
addStatusListener:动画开始、结束、正向或反向(见AnimationStatus定义)时会调用状态改变的监听器。 - Curve
动画过程可以是匀速的、匀加速的或者先加速后减速等。Flutter中通过Curve(曲线)来描述动画过程,我们把匀速动画称为线性的(Curves.linear),而非匀速动画称为非线性的。
final CurvedAnimation curve =
CurvedAnimation(parent: controller, curve: Curves.easeIn);
Curves曲线 动画过程
linear 匀速的
decelerate 匀减速
ease 开始加速,后面减速
easeIn 开始慢,后面快
easeOut 开始快,后面慢
easeInOut 开始慢,然后加速,最后再减速
也可以自定义一个正弦曲线:
class ShakeCurve extends Curve {
@override
double transform(double t) {
return math.sin(t * math.PI * 2);
}
}
- AnimationController
AnimationController用于控制动画,它包含动画的启动forward()、停止stop() 、反向播放 reverse()等
final AnimationController controller = AnimationController(
duration: const Duration(milliseconds: 2000),
lowerBound: 10.0,
upperBound: 20.0,
vsync: this
);
- Tween
默认AnimationController对象值的范围是[0.0,1.0],但可以使用Tween来改变范围
例如,像下面示例,Tween生成[-200.0,0.0]的值
final Tween doubleTween = Tween<double>(begin: -200.0, end: 0.0);
完整示例:
以下示例构建了一个控制器、一条曲线和一个 Tween:
final AnimationController controller = AnimationController(
duration: const Duration(milliseconds: 500),
vsync: this,
);
final Animation curve = CurvedAnimation(parent: controller, curve: Curves.easeOut);
Animation<int> alpha = IntTween(begin: 0, end: 255).animate(curve);
线性插值lerp函数:
//a 为起始颜色,b为终止颜色,t为当前动画的进度[0,1]
Color.lerp(a, b, t);
2.动画实现和监听
AnimatedBuilder可以封装常见的过渡效果来复用动画
class GrowTransition extends StatelessWidget {
const GrowTransition({Key? key,
required this.animation,
this.child,
}) : super(key: key);
final Widget? child;
final Animation<double> animation;
@override
Widget build(BuildContext context) {
return Center(
child: AnimatedBuilder(
animation: animation,
builder: (BuildContext context, child) {
return SizedBox(
height: animation.value,
width: animation.value,
child: child,
);
},
child: child,
),
);
}
}
...
Widget build(BuildContext context) {
return GrowTransition(
child: Image.asset("images/avatar.png"),
animation: animation,
);
}
Flutter中正是通过这种方式封装了很多动画,如:FadeTransition、ScaleTransition、SizeTransition等,很多时候都可以复用这些预置的过渡类
Animation的addStatusListener()方法来添加动画状态改变监听器。Flutter中,有四种动画状态,在AnimationStatus枚举类中定义
dismissed 动画在起始点停止
forward 动画正在正向执行
reverse 动画正在反向执行
completed 动画在终点停止
3. 自定义路由切换动画
无论是MaterialPageRoute、CupertinoPageRoute,还是PageRouteBuilder,它们都继承自PageRoute
MaterialPageRoute组件,它可以使用和平台风格一致的路由切换动画,如在iOS上会左右滑动切换,而在Android上会上下滑动切换
CupertinoPageRoute是Cupertino组件库提供的iOS风格的路由切换组件,它实现的就是左右滑动切换。
自定义切换动画优先考虑使用PageRouteBuilder
Navigator.push(
context,
PageRouteBuilder(
transitionDuration: Duration(milliseconds: 500), //动画时间为500毫秒
pageBuilder: (BuildContext context, Animation animation,
Animation secondaryAnimation) {
return FadeTransition(
//使用渐隐渐入过渡,
opacity: animation,
child: PageB(), //路由B
);
},
),
);
但是有些时候PageRouteBuilder是不能满足需求的,例如在应用过渡动画时我们需要读取当前路由的一些属性,这时就只能通过继承PageRoute的方式了
@override
Widget buildTransitions(BuildContext context, Animation<double> animation,
Animation<double> secondaryAnimation, Widget child) {
//当前路由被激活,是打开新路由
if(isActive) {
return FadeTransition(
opacity: animation,
child: builder(context),
);
}else{
//是返回,则不应用过渡动画
return Padding(padding: EdgeInsets.zero);
}
}
4. Hero动画
在Flutter中将图片从一个路由“飞”到另一个路由称为hero动画
例如A路由有一个圆形用户头像,点击后跳到B路由,可以查看大图
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("点击头像"),
)
],
),
);
}
}
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 即可
5.交织动画
比如:有一个柱状图,需要在高度增长的同时改变颜色,等到增长到最大高度后,我们需要在X轴上平移一段距离。可以发现上述场景在不同阶段包含了多种动画,要实现这种效果,使用交织动画(Stagger Animation)会非常简单
实现步骤:
1.要创建交织动画,需要使用多个动画对象(Animation)。
2.一个AnimationController控制所有的动画对象。
3.给每一个动画对象指定时间间隔(Interval)
class StaggerAnimation extends StatelessWidget {
StaggerAnimation({
Key? key,
required this.controller,
}) : super(key: key) {
//高度动画
height = Tween<double>(
begin: .0,
end: 300.0,
).animate(
CurvedAnimation(
parent: controller,
curve: const Interval(
0.0, 0.6, //间隔,前60%的动画时间
curve: Curves.ease,
),
),
);
color = ColorTween(
begin: Colors.green,
end: Colors.red,
).animate(
CurvedAnimation(
parent: controller,
curve: const Interval(
0.0, 0.6, //间隔,前60%的动画时间
curve: Curves.ease,
),
),
);
padding = Tween<EdgeInsets>(
begin: const EdgeInsets.only(left: .0),
end: const EdgeInsets.only(left: 100.0),
).animate(
CurvedAnimation(
parent: controller,
curve: const Interval(
0.6, 1.0, //间隔,后40%的动画时间
curve: Curves.ease,
),
),
);
}
late final Animation<double> controller;
late final Animation<double> height;
late final Animation<EdgeInsets> padding;
late final Animation<Color?> color;
Widget _buildAnimation(BuildContext context, child) {
return Container(
alignment: Alignment.bottomCenter,
padding: padding.value,
child: Container(
color: color.value,
width: 50.0,
height: height.value,
),
);
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
builder: _buildAnimation,
animation: controller,
);
}
}
StaggerAnimation中定义了三个动画,分别是对Container的height、color、padding属性设置的动画,然后通过Interval来为每个动画指定在整个动画过程中的起始点和终点
使用:
class StaggerRoute extends StatefulWidget {
@override
_StaggerRouteState createState() => _StaggerRouteState();
}
class _StaggerRouteState extends State<StaggerRoute>
with TickerProviderStateMixin {
late AnimationController _controller;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: const Duration(milliseconds: 2000),
vsync: this,
);
}
_playAnimation() async {
try {
//先正向执行动画
await _controller.forward().orCancel;
//再反向执行动画
await _controller.reverse().orCancel;
} on TickerCanceled {
//捕获异常。可能发生在组件销毁时,计时器会被取消。
}
}
@override
Widget build(BuildContext context) {
return Center(
child: Column(
children: [
ElevatedButton(
onPressed: () => _playAnimation(),
child: Text("start animation"),
),
Container(
width: 300.0,
height: 300.0,
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.1),
border: Border.all(
color: Colors.black.withOpacity(0.5),
),
),
//调用我们定义的交错动画Widget
child: StaggerAnimation(controller: _controller),
),
],
),
);
}
}
6.动画切换
AnimatedSwitcher组件,它定义了一种通用的UI切换抽象
const AnimatedSwitcher({
Key? key,
this.child,
required this.duration, // 新child显示动画时长
this.reverseDuration,// 旧child隐藏的动画时长
this.switchInCurve = Curves.linear, // 新child显示的动画曲线
this.switchOutCurve = Curves.linear,// 旧child隐藏的动画曲线
this.transitionBuilder = AnimatedSwitcher.defaultTransitionBuilder, // 动画构建器
this.layoutBuilder = AnimatedSwitcher.defaultLayoutBuilder, //布局构建器
})
当AnimatedSwitcher的 child 发生变化时(类型或 Key 不同),旧 child 会执行隐藏动画,新 child 会执行执行显示动画。究竟执行何种动画效果则由transitionBuilder参数决定,该参数接受一个AnimatedSwitcherTransitionBuilder类型的 builder
typedef AnimatedSwitcherTransitionBuilder =
Widget Function(Widget child, Animation<double> animation);
defaultTransitionBuilder :默认AnimatedSwitcher会对新旧child执行“渐隐”和“渐显”动画
现一个计数器,然后在每一次自增的过程中,旧数字执行缩小动画隐藏,新数字执行放大动画显示
import 'package:flutter/material.dart';
class AnimatedSwitcherCounterRoute extends StatefulWidget {
const AnimatedSwitcherCounterRoute({Key key}) : super(key: key);
@override
_AnimatedSwitcherCounterRouteState createState() => _AnimatedSwitcherCounterRouteState();
}
class _AnimatedSwitcherCounterRouteState extends State<AnimatedSwitcherCounterRoute> {
int _count = 0;
@override
Widget build(BuildContext context) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
AnimatedSwitcher(
duration: const Duration(milliseconds: 500),
transitionBuilder: (Widget child, Animation<double> animation) {
//执行缩放动画
return ScaleTransition(child: child, scale: animation);
},
child: Text(
'$_count',
//显示指定key,不同的key会被认为是不同的Text,这样才能执行动画
key: ValueKey<int>(_count),
style: Theme.of(context).textTheme.headline4,
),
),
ElevatedButton(
child: const Text('+1',),
onPressed: () {
setState(() {
_count += 1;
});
},
),
],
),
);
}
}
Flutter SDK中还提供了一个AnimatedCrossFade组件,它也可以切换两个子元素,切换过程执行渐隐渐显的动画,和AnimatedSwitcher不同的是AnimatedCrossFade是针对两个子元素,而AnimatedSwitcher是在一个子元素的新旧值之间切换
示例:实现各种“滑动出入动画”便非常容易,只需给direction传递不同的方向值即可
class SlideTransitionX extends AnimatedWidget {
SlideTransitionX({
Key? key,
required Animation<double> position,
this.transformHitTests = true,
this.direction = AxisDirection.down,
required this.child,
}) : super(key: key, listenable: position) {
switch (direction) {
case AxisDirection.up:
_tween = Tween(begin: const Offset(0, 1), end: const Offset(0, 0));
break;
case AxisDirection.right:
_tween = Tween(begin: const Offset(-1, 0), end: const Offset(0, 0));
break;
case AxisDirection.down:
_tween = Tween(begin: const Offset(0, -1), end: const Offset(0, 0));
break;
case AxisDirection.left:
_tween = Tween(begin: const Offset(1, 0), end: const Offset(0, 0));
break;
}
}
final bool transformHitTests;
final Widget child;
final AxisDirection direction;
late final Tween<Offset> _tween;
@override
Widget build(BuildContext context) {
final position = listenable as Animation<double>;
Offset offset = _tween.evaluate(position);
if (position.status == AnimationStatus.reverse) {
switch (direction) {
case AxisDirection.up:
offset = Offset(offset.dx, -offset.dy);
break;
case AxisDirection.right:
offset = Offset(-offset.dx, offset.dy);
break;
case AxisDirection.down:
offset = Offset(offset.dx, -offset.dy);
break;
case AxisDirection.left:
offset = Offset(-offset.dx, offset.dy);
break;
}
}
return FractionalTranslation(
translation: offset,
transformHitTests: transformHitTests,
child: child,
);
}
}
AnimatedSwitcher(
duration: Duration(milliseconds: 200),
transitionBuilder: (Widget child, Animation<double> animation) {
var tween=Tween<Offset>(begin: Offset(1, 0), end: Offset(0, 0))
return SlideTransitionX(
child: child,
direction: AxisDirection.down, //上入下出
position: animation,
);
},
...//省略其余代码
)
7.Flutter预置的动画过渡组件
AnimatedPadding 在padding发生变化时会执行过渡动画到新状态
AnimatedPositioned 配合Stack一起使用,当定位状态发生变化时会执行过渡动画到新的状态。
AnimatedOpacity 在透明度opacity发生变化时执行过渡动画到新状态
AnimatedAlign 当alignment发生变化时会执行过渡动画到新的状态。
AnimatedContainer 当Container属性发生变化时会执行过渡动画到新的状态。
AnimatedDefaultTextStyle 当字体样式发生变化时,子组件中继承了该样式的文本组件会动态过渡到新样式。
import 'package:flutter/material.dart';
class AnimatedWidgetsTest extends StatefulWidget {
const AnimatedWidgetsTest({Key? key}) : super(key: key);
@override
_AnimatedWidgetsTestState createState() => _AnimatedWidgetsTestState();
}
class _AnimatedWidgetsTestState extends State<AnimatedWidgetsTest> {
double _padding = 10;
var _align = Alignment.topRight;
double _height = 100;
double _left = 0;
Color _color = Colors.red;
TextStyle _style = const TextStyle(color: Colors.black);
Color _decorationColor = Colors.blue;
double _opacity = 1;
@override
Widget build(BuildContext context) {
var duration = const Duration(milliseconds: 400);
return SingleChildScrollView(
child: Column(
children: <Widget>[
ElevatedButton(
onPressed: () {
setState(() {
_padding = 20;
});
},
child: AnimatedPadding(
duration: duration,
padding: EdgeInsets.all(_padding),
child: const Text("AnimatedPadding"),
),
),
SizedBox(
height: 50,
child: Stack(
children: <Widget>[
AnimatedPositioned(
duration: duration,
left: _left,
child: ElevatedButton(
onPressed: () {
setState(() {
_left = 100;
});
},
child: const Text("AnimatedPositioned"),
),
)
],
),
),
Container(
height: 100,
color: Colors.grey,
child: AnimatedAlign(
duration: duration,
alignment: _align,
child: ElevatedButton(
onPressed: () {
setState(() {
_align = Alignment.center;
});
},
child: const Text("AnimatedAlign"),
),
),
),
AnimatedContainer(
duration: duration,
height: _height,
color: _color,
child: TextButton(
onPressed: () {
setState(() {
_height = 150;
_color = Colors.blue;
});
},
child: const Text(
"AnimatedContainer",
style: TextStyle(color: Colors.white),
),
),
),
AnimatedDefaultTextStyle(
child: GestureDetector(
child: const Text("hello world"),
onTap: () {
setState(() {
_style = const TextStyle(
color: Colors.blue,
decorationStyle: TextDecorationStyle.solid,
decorationColor: Colors.blue,
);
});
},
),
style: _style,
duration: duration,
),
AnimatedOpacity(
opacity: _opacity,
duration: duration,
child: TextButton(
style: ButtonStyle(
backgroundColor: MaterialStateProperty.all(Colors.blue)),
onPressed: () {
setState(() {
_opacity = 0.2;
});
},
child: const Text(
"AnimatedOpacity",
style: TextStyle(color: Colors.white),
),
),
),
AnimatedDecoratedBox1(
duration: Duration(
milliseconds: _decorationColor == Colors.red ? 400 : 2000),
decoration: BoxDecoration(color: _decorationColor),
child: Builder(builder: (context) {
return TextButton(
onPressed: () {
setState(() {
_decorationColor = _decorationColor == Colors.blue
? Colors.red
: Colors.blue;
});
},
child: const Text(
"AnimatedDecoratedBox toggle",
style: TextStyle(color: Colors.white),
),
);
}),
)
].map((e) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 16),
child: e,
);
}).toList(),
),
);
}
}