交织动画
有时候,我们可能会使用一下比较复杂的动画,这些动画由一个动画序列或者重叠的动画组成,例如一个图片,先旋转,在移动,或者同时进行移动和旋转。这种场景中包含了多种动画,要实现这种效果,我们可以使用交织动画( Stagger Animation) 会非常简单,
使用交织动画需要注意以下几点:
创建交织动画,需要多个动画对象(Animation)
一个 AnimationController 控制所有的动画对象
给每一个动画指定事件的间隔 (Interval)
所有的动画都是由同一个 AnimationController 驱动,无论动画需要持续多长时间,控制器必须在 0.0 到 0.1 之间,而每个动画间隔(Interval) 也必须介于 0.0 和 0.1 之间。对于间隔中设置动画的每个属性,需要分别创建 Tween 用于指定该属性的开始和结束值。也就是说 0.0 到 1.0 代表整个动画的过程,我们可以给不同的动画指定不同的起点和终点来决定它们开始的时间和终止的时间。
示例
实现一个柱状图增长的动画
将动画的 widget 分离出来
class StaggerAnimation extends StatelessWidget { final Animation controller; Animation<double> height; Animation<EdgeInsets> padding; Animation<Color> color; StaggerAnimation({Key key, this.controller}) : super(key: key) { //高度,Interval用来指定整个动画过程中的起点和终点,前60%的动画时间 height = Tween<double>(begin: .0, end: 300.0).animate(CurvedAnimation( parent: controller, curve: Interval(0.0, 0.6, curve: Curves.ease))); //颜色 color = ColorTween(begin: Colors.green, end: Colors.red).animate( CurvedAnimation( parent: controller, curve: Interval(0.0, 0.6, curve: Curves.ease))); //内边距 padding = Tween<EdgeInsets>( begin: EdgeInsets.only(left: .0), end: EdgeInsets.only(left: 100)) .animate(CurvedAnimation( parent: controller, curve: Interval(0.6, 1.0, curve: Curves.ease))); } Widget _buildAnimation(BuildContext context, Widget 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(animation: controller, builder: _buildAnimation); } }
其中,定义了三个动画,分别是,高度,颜色,和内边距,然后通过 Interval 来指定整个动画的起点和终点
使用该动画
class StaggerTest extends StatefulWidget { @override _StaggerTestState createState() => _StaggerTestState(); } class _StaggerTestState extends State<StaggerTest> with TickerProviderStateMixin { 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 GestureDetector( behavior: HitTestBehavior.opaque, onTap: () { _playAnimation(); }, child: Center( child: Container( width: 300.0, height: 300.0, decoration: BoxDecoration( color: Colors.black.withOpacity(0.1), border: Border.all(color: Colors.black.withOpacity(0.5))), child: StaggerAnimation(controller: _controller), ), ), ); } }
其实交织动画就是把多个动画放在一起使用一个 controller 进行控制;
通用动画组件
实际开发过程中,我们经常会遇到切换 UI 元素的场景,例如 Tab 切换,路由切换等。为了增强用户体验,通常在切换时都会指定一个动画,使得切换过程显得顺滑。Flutter SDK 中提供了一下常用的切换组件,如 PageView,TabView 等,但是,这些组件并不能覆盖全部的需求场景,为此 Flutter SDK 提供了一个 AnimatedSwitch 组件,它定义了一种通用的 UI 切换抽象。
AnimatedSwitch
AnimatedSwitch 可以同时对其新,旧子元素添加显示,隐藏动画。也就是说在 AnimatedSwitch 的子元素发生变化时,会对其旧元素和新元素。定义如下:
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, //布局构建器 })
当 AnimatedSwitch 的 child 发生变化时(类型或key不同),旧 child 会执行隐藏动画,新 child 会执行显示动画。动画效果则由 transitionBuilder 参数决定,即参数接收一个 AnimatedSwitchTransitionBUilder 类型的 builder,定义如下:
typedef AnimatedSwitcherTransitionBuilder = Widget Function(Widget child, Animation animation);
该 builder 在 AnimatedSwitch 的 child 切换时会分别对新,旧 child绑定动画
1,对旧 child,绑定的动画会反向执行(reverse)
2,对新 child,绑定的动画会正向执行(forward)
这样一来,便实现了对新,旧 child 动画的绑定。AnimatedSwitch 的默认值是 AnimatedSwitch.defaultTransitionBuilder:
可以看到返回了 FadeTransition 对象,也就是说,默认情况下,AnimatedSwitch 会对旧 child 执行渐隐和渐显动画。
栗子:
实现一个计数器,在每一次自增的过程中,旧数字缩小隐藏,新数字放大显示,如下:
class AnimatedSwitcherTest extends StatefulWidget { AnimatedSwitcherTest({Key key}) : super(key: key); @override _AnimatedSwitcherTestState createState() => _AnimatedSwitcherTestState(); } class _AnimatedSwitcherTestState extends State<AnimatedSwitcherTest> { int _count = 0; @override Widget build(BuildContext context) { return Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ AnimatedSwitcher( duration: const Duration(milliseconds: 500), transitionBuilder: (Widget child, Animation<double> animation) { //执行缩放动画 return ScaleTransition(scale: animation, child: child,); }, //显式的指定 key,不同的 key 会被认为是不同的 Text,这样才能执行动画 child: Text("$_count", key: ValueKey<int>(_count), style: Theme.of(context).textTheme.headline4), ), RaisedButton( child: const Text('+1'), onPressed: () { setState(() { _count += 1; }); }) ], ), ); } }
注意:AnimatedSwitcher 的新旧 child,如果类型相同,则 可以必须不相等,应为只有不同的 key 才会被认为是不同的 Text,这样才能执行动画
AnimatedSwitch实现原理
要实现新旧的 child 动画切换,只需要明确一个问题:动画执行的时机是如何对新旧 child 执行的动画
从 AnimatedSwitch 可以看到,当 child 发生变化时(子 widget 的 key 和类型不同时相等则认为是发生变化),则会重新执行 build,然后动画开始执行。我们可以通过继承 StatefulWidget 来实现 AnimatedSwitch ,具体做法是在 didUpdateWidget 中判断新旧 child 是否发生变化,如果发生变化,则对旧 child 执行反向退场(reverse)动画,对新的 child 执行正向(forward)入场动画即可。下面是 AnimatedSwitch 实现的部分核心伪代码:
Widget _widget; // void didUpdateWidget(AnimatedSwitcher oldWidget) { super.didUpdateWidget(oldWidget); // 检查新旧child是否发生变化(key和类型同时相等则返回true,认为没变化) if (Widget.canUpdate(widget.child, oldWidget.child)) { // child没变化,... } else { //child发生了变化,构建一个Stack来分别给新旧child执行动画 _widget= Stack( alignment: Alignment.center, children:[ //旧child应用FadeTransition FadeTransition( opacity: _controllerOldAnimation, child : oldWidget.child, ), //新child应用FadeTransition FadeTransition( opacity: _controllerNewAnimation, child : widget.child, ), ] ); // 给旧child执行反向退场动画 _controllerOldAnimation.reverse(); //给新child执行正向入场动画 _controllerNewAnimation.forward(); } } //build方法 Widget build(BuildContext context){ return _widget; }
上面的伪代码展示了 AnimatedSwitcher 的核心逻辑,当然真正的逻辑比这个更加复杂,他可以自定义退场过度动画已经执行动画的布局等,在此,我们通过伪代码主要是为了看到主要的实现思路;
另外,Flutter SDK 中还提供了一个 AnimatedCrossFade 的组件,它也可以切换两个子元素,切换过程中执行渐隐和渐显动画,和 AnimagedSwticher 不同的是 AnimatedCrossFade 是针对两个子元素,而 AnimatedSwitch 是在一个子元素的新旧值之间切换。
AnimatedSwitch高级用法
如果我们要实现一个类似路由平移的动画:旧页面屏幕中向左侧退出,新页面从屏幕右侧平移进入。我们很快就会发现,做不到,我们可能会写出下面的代码:
AnimatedSwitcher( duration: Duration(milliseconds: 200), transitionBuilder: (Widget child, Animation<double> animation) { var tween=Tween<Offset>(begin: Offset(1, 0), end: Offset(0, 0)) return SlideTransition( child: child, position: tween.animate(animation), ); }, ...//省略 )
上面代码的问题,我们前面说过 AnimatedSwitch 的 child 切换动画时会分别对新 child 执行正向动画,对旧 child 执行反向动画,所以正真的效果是新 child 从屏幕右侧平移进入了,但是旧 child 却会不会从左侧退出,而是右侧退出。因为在没有特殊处理的情况下,同一个动画的正向和逆向刚好是相反(对称)的。
那么问题就来了,我们不能使用 AnimatedSwitch 了吗?,答案是否定的,究其原因,就是因为 Animation 是对称的,所以只要打破这个规则就可以了,下面我们封装一个 MySlideTransition,他与 SlideTransition 唯一的不同就是对动画的反向执行进行了定制(从左边划出隐藏),代码如下:
class MySlideTransition extends AnimatedWidget { final bool transformHitTests; final Widget child; Animation<Offset> get position => listenable; MySlideTransition( {Key key, @required Animation<Offset> position, this.transformHitTests = true, this.child}) : assert(position != null), super(key: key, listenable: position); @override Widget build(BuildContext context) { Offset offset = position.value; if (position.status == AnimationStatus.reverse) { offset = Offset(-offset.dx, offset.dy); } return FractionalTranslation( translation: offset, transformHitTests: transformHitTests, child: child); } }
AnimatedSwitcher( duration: const Duration(milliseconds: 500), transitionBuilder: (Widget child, Animation<double> animation) { var tween = Tween(begin: Offset(1, 0), end: Offset(0, 0)); //执行缩放动画 return MySlideTransition( position: tween.animate(animation), child: child, ); }, //显式的指定 key,不同的 key 会被认为是不同的 Text,这样才能执行动画 child: Text("$_count", key: ValueKey<int>(_count), style: Theme.of(context).textTheme.headline4), )
效果如上图所示,实际上 Flutter 路由也是通过 AnimatedSwtcher 来实现的
SlideTransitionX
我们通过封装一个通用的 SlideTransitionX 来实现这种出入滑动的动画, 如下:
class SlideTransitionX extends AnimatedWidget { Animation<double> get position => listenable; final bool transformHitTests; final Widget child; //退场/出场 方向 final AxisDirection direction; Tween<Offset> _tween; SlideTransitionX( {Key key, @required Animation<double> position, this.transformHitTests = true, this.direction = AxisDirection.down, this.child}) : assert(position != null), super(key: key, listenable: position) { switch (direction) { case AxisDirection.up: _tween = Tween(begin: Offset(0, 1), end: Offset(0, 0)); break; case AxisDirection.right: _tween = Tween(begin: Offset(-1, 0), end: Offset(0, 0)); break; case AxisDirection.down: _tween = Tween(begin: Offset(0, -1), end: Offset(0, 0)); break; case AxisDirection.left: _tween = Tween(begin: Offset(1, 0), end: Offset(0, 0)); break; } } @override Widget build(BuildContext context) { 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: const Duration(milliseconds: 500), transitionBuilder: (Widget child, Animation<double> animation) { return SlideTransitionX( direction: AxisDirection.down, position: animation, child: child, ); }, //显式的指定 key,不同的 key 会被认为是不同的 Text,这样才能执行动画 child: Text("$_count", key: ValueKey<int>(_count), style: Theme.of(context).textTheme.headline4), ),
动画过度组件
为了方便表示,我们将 widget 属性发生变化时会执行过度动画的组件称为 “动画过度组件”,而动画过度最明显的一个特征就是他会在内部管理自己的 AnimationController。我们指定,为了方便使用者可以自定义动画的时长,曲线等,这些通常都是使用者自己提供的。但是如此一来,使用者就必须手动管理 AnimationController,这样会增加使用的复杂性。因此如果能将 AnimationController 进行封装,就会大大提高动画组件的易用性。
自定义动画过度组件
我们实现一个 AnimatedDecoratedBox ,他可以在 decorated 属性发生变化时,从旧状态变成新状态的过程中执行一个过度动画,根据上面学到的执行,我们写出如下代码:
class AnimatedDecoratedBox1 extends StatefulWidget { final BoxDecoration decoration; final Widget child; //执行时间 final Duration duration; //曲线 final Curve curve; //反向执行时间 final Duration reverseDuration; AnimatedDecoratedBox1( {Key key, @required this.decoration, this.child, @required this.duration, this.reverseDuration, this.curve = Curves.linear}); @override _AnimatedDecoratedBox1State createState() => _AnimatedDecoratedBox1State(); } class _AnimatedDecoratedBox1State extends State<AnimatedDecoratedBox1> with SingleTickerProviderStateMixin { AnimationController _controller; Animation<double> _animation; DecorationTween _tween; @protected AnimationController get controller => _controller; @protected Animation get animation => _animation; @override void initState() { super.initState(); _controller = AnimationController( duration: widget.duration, reverseDuration: widget.reverseDuration, vsync: this); _tween = DecorationTween(begin: widget.decoration); _updateCurve(); } void _updateCurve() { if (widget.curve != null) { _animation = CurvedAnimation(parent: _controller, curve: widget.curve); } else { _animation = _controller; } } @override void didUpdateWidget(covariant AnimatedDecoratedBox1 oldWidget) { super.didUpdateWidget(oldWidget); if (widget.curve != oldWidget.curve) _updateCurve(); _controller.duration = widget.duration; _controller.reverseDuration = widget.reverseDuration; if (widget.decoration != (_tween.end ?? _tween.begin)) { _tween ..begin = _tween.evaluate(_animation) ..end = widget.decoration; _controller ..value = 0.0 ..forward(); } } @override Widget build(BuildContext context) { return AnimatedBuilder( animation: _animation, builder: (context, child) { return DecoratedBox( decoration: _tween.animate(_animation).value, child: child); }, child: widget.child, ); } @override void dispose() { _controller.dispose(); super.dispose(); } }
上面代码虽然实现了我们期望的功能,但是代码却有些复杂,其实 AnimationController 的管理和 Tween 这部分代码都是可以抽出来的,如果将这部分代码封装为基类,那么要实现过渡组件只需要继承基类即可,然后自定义自身不同的diam即可。这样就会大大的简化代码;
在 Flutter SDK 中提供了一个 ImplicitlyAnimatedWidgetState 类,他继承自 StatefulState ,同时提供了一个对应的 ImplicitlyAnimatedWidgetState 类,AnimationController 的管理就在这个类中。开发者要封装动画,只需要继承 ImplicitlyAnimatedWidget 和 ImplicitlyAnimatedWidgetState 类即可;
我们需要分两步实现:
继承 ImplicitlyAnimatedWidget类
class AnimatedDecoratedBox extends ImplicitlyAnimatedWidget { final BoxDecoration decoration; final Widget child; AnimatedDecoratedBox( {Key key, @required this.decoration, this.child, Curve curve = Curves.linear, //动画曲线 @required Duration duration, //动画执行时长 Duration reverseDuration}) : super(key: key, curve: curve, duration: duration); @override ImplicitlyAnimatedWidgetState<ImplicitlyAnimatedWidget> createState() => _AnimatedDecoratedBoxState(); }
其中 curve,duration 属性在 父类中已定义,可以看到其实和普通继承自 StatefulWidget 的类没有什么不同。
State 类继承自 AnimatedWidgetBaseState (该类继承自 ImplicitlyAnimatedWidgetState 类)
class _AnimatedDecoratedBoxState extends AnimatedWidgetBaseState<AnimatedDecoratedBox> { DecorationTween _decorationTween; @override Widget build(BuildContext context) { return DecoratedBox( decoration: _decorationTween.evaluate(animation), child: widget.child, ); } @override void forEachTween(visitor) { //在需要更新 Tween 时,基类会调用此方法 _decorationTween = visitor(_decorationTween, widget.decoration, (value) => DecorationTween(begin: value)); } }
我们实现了 build 和 forEachTween 方法。
在动画的执行过程中,每一帧都会调用 build 方法(调用逻辑在父类中),所以在 build 方法中我们需要构建每一帧的 DecoratedBox 状态,因此需要算出每一帧 decoration 状态,这个我们可以通过 _decoraitionTween.evaluate(animation) 来算出,其中 animation 是 ImplicitlyAnimatedSidgetState 基类中定义的对象,_decoration 是我吗自定义的一个 DecoratioNTween 类型的对象,那么 _decorationTween 是在什么时候被赋值的呢? 我们知道 _decorateionTween 是一个 Tween,主要是定义动画的起始状态 begin 和终止状态 end ,对于 AnimatedDecoratedBox 来说,decoration 的终止状态就是用户传给他的值,而起始状态是不确定的,有两种情况:
AnimatedDecoratedBox 首次 build,此时直接将其 decoration 值设置为起始状态,即 _decorationTween 值为 DecorationTween(begin:decoration)。
AnimatedDecoratedBox 的 decoration 更新时,则起始状态为 _decoration.animate(animation),即 _decorationTween 值为 DecorationTween(begin:_decoration.animate(animation),end:decoration)。
现在 forEachTween 作用就很明显了,他正是用来更新 Tween 的初始值的。在上述两种情况下会被调用,而我们只需要重写该方法,并在此方法中更新 Tween 的起始状态值即可。而一些更新的逻辑被屏蔽在了 visitor 回调,我们只需要给他传递正确的参数即可,visitor 方法前面如下:
Tween visitor( Tween<dynamic> tween, //当前的tween,第一次调用为null dynamic targetValue, // 终止状态 TweenConstructor<dynamic> constructor,//Tween构造器,在上述三种情况下会被调用以更新tween );
Flutter 预置的动画过度组件
Flutter SDK 里面预置了很多过度组件,实现方式和大都和 AnimatedDecoratedBox 差不多,
示例:
class AnimatedWidgetsTest extends StatefulWidget { @override _AnimatedWidgetsTestState createState() => _AnimatedWidgetsTestState(); } class _AnimatedWidgetsTestState extends State<AnimatedWidgetsTest> { double _padding = 10; Alignment _align = Alignment.topRight; double _height = 100; double _left = 0; Color _color = Colors.red; TextStyle _style = TextStyle(color: Colors.black); Color _decorationColor = Colors.blue; @override Widget build(BuildContext context) { var duration = Duration(seconds: 5); return SingleChildScrollView( child: Column( children: [ RaisedButton( onPressed: () => setState(() => _padding = 20), child: AnimatedPadding( duration: duration, padding: EdgeInsets.all(_padding), child: Text("AnimatedPadding"), ), ), SizedBox( height: 50, child: Stack( children: [ AnimatedPositioned( child: RaisedButton( onPressed: () => setState( () => _left = 100, ), child: Text("AnimatedPositioned"), ), duration: duration, left: _left, ), ], ), ), Container( height: 100, color: Colors.grey, child: AnimatedAlign( duration: duration, alignment: _align, child: RaisedButton( onPressed: () => setState(() => _align = Alignment.center), child: Text("AnimatedAlign"), ), ), ), AnimatedContainer( duration: duration, height: _height, color: _color, child: FlatButton( onPressed: (() => setState(() => this .._height = 150 .._color = Colors.blue)), child: Text("AnimatedContainer", style: TextStyle(color: Colors.white)), ), ), AnimatedDefaultTextStyle( child: GestureDetector( child: Text("hello world"), onTap: () => _style = TextStyle( color: Colors.blue, decorationStyle: TextDecorationStyle.solid, decorationColor: Colors.blue)), style: _style, duration: duration), AnimatedDecoratedBox( decoration: BoxDecoration(color: _decorationColor), duration: duration, child: FlatButton( onPressed: () => setState(() => _decorationColor = _color), child: Text("AnimatedDecoratedBox", style: TextStyle(color: Colors.white)), ), ) ] .map((e) => Padding( padding: EdgeInsets.symmetric(vertical: 16), child: e, )) .toList(), ), ); } }