Flutter-动画切换组件(AnimatedSwitcher)

1,353 阅读2分钟

实际开发中,经常会遇到切换UI元素的场景,比如Tab切换,路由切换。为了增强用户体验,通常在切换时都会指定一个动画,以使切换过程显的平滑。为此Flutter SDK中提供了一个AnimatedSwitcher组件,定义了一种通用的UI切换抽象。

AniamtedSwitcher

简介
AnimatedSwitcher 可以同时对其新旧子元素添加显示、隐藏动画,也就是在AnimatedSwitcher的子元素发生变化时,会对其旧元素和新元素做动画:

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);

该builder在AnimatedSwitcher的child切换时会分别对新旧child绑定动画:

  1. 对旧child,绑定的动画会反向执行(reverse)。
  2. 对新child,绑定的动画会正向执行(forward)。

AnimatedSwitcher的默认值是:AnimatedSwitcher.defaultTransitionBuilder:

Widget defaultTransitionBuilder(Widget child, Animation<double> animation) {
  return FadeTransition(
    opacity: animation,
    child: child,
  );
}

返回一个FadeTransition对象,也就是默认情况AnimatedSwitcher会对新旧child执行渐隐渐显动画。
示例:

class SSLSwitcherAnimateRoute extends StatefulWidget{

  const SSLSwitcherAnimateRoute({Key? key}):super(key: key);
  @override
  State<StatefulWidget> createState() {
    // TODO: implement createState
    return SSLSwitcherAniamteRouteState();
  }
}

class SSLSwitcherAniamteRouteState extends State<SSLSwitcherAnimateRoute>{
  int count = 0;

  @override
  Widget build(BuildContext context) {
    // TODO: implement build
    return Center(
      child: Column(
        mainAxisAlignment: MainAxisAlignment.center,
        children: <Widget>[
          AnimatedSwitcher(
              duration: const Duration(milliseconds: 500),
            transitionBuilder: (Widget child, Animation<double> animation){
                return ScaleTransition(scale: animation, child: child,);
            },
            child: Text(
                "$count",
              key: ValueKey<int>(count),
              style: Theme.of(context).textTheme.headlineMedium,
            ),
          ),
          ElevatedButton(
              onPressed: (){
                setState(() {
                  count += 1;
                });
              },
              child: const Text("+1")
          ),
        ],
      ),
    );
  }
}

AnimatedSwitcher 原理

两个问题:

  1. 动画执行的时机?
  2. 如何对新旧动画执行动画?

从AnimatedSwitcher的使用可以看到:当child发生变化时(子widget的key类型不同时则默认发生变化),则重新执行build然后开始动画执行。

可以通过继承StatefulWidget来实现AnimatedSwitcher,具体做法是在didUpdateWidget回调中判断其新旧child是否发生变化,如果发生变化,则对旧child执行反向退场动画(reverse),对新动画执行正向动画(forward)。
核心伪代码:

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;
}

Flutter中还提供了一个AnimatedCrossFade组件,可以切换两个子元素,切换过程执行渐隐渐显动画,和Animated Switcher不同的是AnimatedCrossFade是针对两个子元素,而AnimatedSwitcher是在一个子元素的新旧值之间切换,AnimatedCrossFade的实现原理和AnimatedSwitcher类似。

AnimatedSwitcher高级用法

如果要实现一个路由平移切换的动画,旧页面从屏幕中左侧平移出去,新页面从屏幕右侧平移进入,用AnimatedSwitcher的话会出现问题,因为动画是一个正向一个反向进行,属于对称进行,而不是想要的平移进行。可以封装一下打破这种对称性。

class SSLSlideTransition extends AnimatedWidget{
  final bool transformHitTests;
  final Widget child;
  const SSLSlideTransition({
    Key? key,
    required Animation<Offset> position,
    this.transformHitTests = true,
    required this.child,
  }):super(key: key, listenable: position);

  @override
  Widget build(BuildContext context) {
    // TODO: implement build
    final position = listenable as Animation<Offset>;
    Offset offset = position.value;
    //从左向右平移
    if (position.status == AnimationStatus.forward){
      offset = Offset(-offset.dx, offset.dy);
    }
    //从右向左平移
    if (position.status == AnimationStatus.reverse){
      offset = Offset(-offset.dx, offset.dy);
    }
    return FractionalTranslation(
        translation: offset,
      transformHitTests: transformHitTests,
      child: child,
    );
  }
}
//使用
AnimatedSwitcher(
    duration: const Duration(milliseconds: 300),
  transitionBuilder: (Widget child, Animation<double> animation){
      // return ScaleTransition(scale: animation, child: child,);
    var tween = Tween<Offset>(
      begin: const Offset(1,0),
      end: const Offset(0,0),
    );
    return SSLSlideTransition(position: tween.animate(animation), child: child);
  },
  child: Text(
      "$count",
    key: ValueKey<int>(count),
    style: Theme.of(context).textTheme.headlineMedium,
  ),
),

SlideTransitionX

代码升级优化,实现可以上下左右平移:

class SSLSlideTransition extends AnimatedWidget{
  final bool transformHitTests;
  final Widget child;
  final AxisDirection direction;
  late final Tween<Offset> tween;
  SSLSlideTransition({
    Key? key,
    required Animation<double> position,
    this.transformHitTests = true,
    this.direction = AxisDirection.down,//将参数变为枚举类型
    required this.child,
  }):super(key: key, listenable: position){
  //构造函数中根据变量修改参数,此时不能使用const修饰构造函数否则会报错
    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;
    }
  }

  @override
  Widget build(BuildContext context) {
    // TODO: implement build
    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: const Duration(milliseconds: 300),
  transitionBuilder: (Widget child, Animation<double> animation){
      // return ScaleTransition(scale: animation, child: child,);
    // var tween = Tween<Offset>(
    //   begin: const Offset(1,0),
    //   end: const Offset(0,0),
    // );
    return SSLSlideTransition(direction: AxisDirection.up, position: animation, child: child);
  },
  child: Text(
      "$count",
    key: ValueKey<int>(count),
    style: Theme.of(context).textTheme.headlineMedium,
  ),
),