Flutter 之 动画切换组件 (三十八)

实际开发中,我们经常会遇到切换UI元素的场景,比如Tab切换、路由切换。为了增强用户体验,通常在切换时都会指定一个动画,以使切换过程显得平滑。Flutter SDK组件库中已经提供了一些常用的切换组件,如PageView、TabView等,但是,这些组件并不能覆盖全部的需求场景,为此,Flutter SDK中提供了一个AnimatedSwitcher组件,它定义了一种通用的UI切换抽象

1. AnimatedSwitcher

AnimatedSwitcher 可以同时对其新、旧子元素添加显示、隐藏动画。也就是说在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绑定动画:
对旧child,绑定的动画会反向执行(reverse)
对新child,绑定的动画会正向指向(forward)

这样一下,便实现了对新、旧child的动画绑定。AnimatedSwitcher的默认值是AnimatedSwitcher.defaultTransitionBuilder :

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

可以看到,返回了FadeTransition对象,也就是说默认情况,AnimatedSwitcher会对新旧child执行“渐隐”和“渐显”动画

2. AnimatedSwitcher 示例

实现一个计数器,然后在每一次自增的过程中,旧数字执行缩小动画隐藏,新数字执行放大动画显示,代码如下


class MSAnimatedSwiterDemo extends StatefulWidget {
  const MSAnimatedSwiterDemo({Key? key}) : super(key: key);

  @override
  State<MSAnimatedSwiterDemo> createState() => _MSAnimatedSwiterDemoState();
}

class _MSAnimatedSwiterDemoState extends State<MSAnimatedSwiterDemo> {
  var _count = 0;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Container(
        alignment: Alignment.center,
        child: AnimatedSwitcher(
          child: Text(
            "$_count",
            textScaleFactor: 1.5,
            key: ValueKey<int>(_count), // 显示指定key,不同的key会被认为是不同的Text,这样才能执行动画
          ),
          duration: Duration(milliseconds: 500),
          transitionBuilder: (child, anim1) {
            return ScaleTransition(child: child, scale: anim1);
          },
        ),
      ),
      floatingActionButton: FloatingActionButton(
        child: Icon(Icons.add),
        onPressed: () {
          setState(() {
            _count += 1;
          });
        },
      ),
    );
  }
}
56.gif

3. AnimatedSwitcher实现原理

实际上,AnimatedSwitcher的实现原理是比较简单的,我们根据AnimatedSwitcher的使用方式也可以猜个大概。要想实现新旧 child 切换动画,只需要明确两个问题:

动画执行的时机是什么时候?
如何对新旧child执行动画?
从AnimatedSwitcher的使用方式我们可以看到:当child发生变化时(子 widget 的 key 或类型不同时则认为发生变化),则重新会重新执行build,然后动画开始执行。

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

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实现的核心逻辑,当然AnimatedSwitcher真正的实现比这个复杂,它可以自定义进退场过渡动画以及执行动画时的布局等。

4. AnimatedCrossFade

AnimatedCrossFade可以切换两个子元素,切换过程执行渐隐渐显的动画,和AnimatedSwitcher不同的是AnimatedCrossFade是针对两个子元素,而AnimatedSwitcher是在一个子元素的新旧值之间切换。AnimatedCrossFade实现原理也比较简单,和AnimatedSwitcher类似

当AnimatedCrossFade属性值不同时,动画会自动触发。


class MSAnimatedCrossFadeDemo extends StatefulWidget {
  const MSAnimatedCrossFadeDemo({Key? key}) : super(key: key);

  @override
  State<MSAnimatedCrossFadeDemo> createState() =>
      _MSAnimatedCrossFadeDemoState();
}

class _MSAnimatedCrossFadeDemoState extends State<MSAnimatedCrossFadeDemo> {
  var _first = true;
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(
        child: AnimatedCrossFade(
          firstChild: Image.asset(
            "assets/images/1.jpeg",
            width: 200,
            height: 200,
            fit: BoxFit.cover,
            key: ValueKey("first"),
          ),
          secondChild: Image.asset(
            "assets/images/2.jpeg",
            width: 300,
            height: 300,
            fit: BoxFit.cover,
            key: ValueKey("second"),
          ),
          crossFadeState:
              _first ? CrossFadeState.showFirst : CrossFadeState.showSecond,
          duration: Duration(milliseconds: 500),
        ),
      ),
      floatingActionButton: FloatingActionButton(
        child: Icon(Icons.change_circle),
        onPressed: () {
          setState(() {
            _first = !_first;
          });
        },
      ),
    );
  }
}

57.gif

5. AnimatedSwitcher高级用法

5.1 左出右入

假设现在我们想实现一个类似路由平移切换的动画:旧页面屏幕中向左侧平移退出,新页面从屏幕右侧平移进入

代码如下:


class MSAnimatedSwitcherDemo1 extends StatefulWidget {
  const MSAnimatedSwitcherDemo1({Key? key}) : super(key: key);

  @override
  State<MSAnimatedSwitcherDemo1> createState() =>
      _MSAnimatedSwitcherDemo1State();
}

class _MSAnimatedSwitcherDemo1State extends State<MSAnimatedSwitcherDemo1> {
  var _count = 0;
  @override
  Widget build(BuildContext context) {
    final _text = _count < 10 ? "0$_count" : "$_count";
    return Scaffold(
      body: Container(
        alignment: Alignment.center,
        child: AnimatedSwitcher(
          child: Text(
            _text,
            textScaleFactor: 1.5,
            key: ValueKey(_text),
          ),
          transitionBuilder: (child, anim) {
            var tween = Tween<Offset>(begin: Offset(1, 0), end: Offset(0, 0));
            return SlideTransition(
              position: tween.animate(anim),
              child: child,
            );
          },
          duration: Duration(milliseconds: 500),
        ),
      ),
      floatingActionButton: FloatingActionButton(
        child: Icon(Icons.add),
        onPressed: () {
          setState(() {
            _count++;
          });
        },
      ),
    );
  }
}

58.gif

很明显上述效果与我们所预想的有所差距

AnimatedSwitcher的 child 切换时会对新child执行正向动画(forward),而对旧child执行反向动画(reverse),所以真正的效果便是:新 child 确实从屏幕右侧平移进入了,但旧child却会从屏幕右侧(而不是左侧)退出

优化

究其原因,就是因为同一个Animation正向(forward)和反向(reverse)是对称的。所以如果我们可以打破这种对称性,那么便可以实现这个功能了。

下面我们来封装一个MSSlideTransition,它与SlideTransition唯一的不同就是对动画的反向执行进行了定制(从左边滑出隐藏)

自定义 MSSlideTransition


class MSSlideTransition extends AnimatedWidget {
  MSSlideTransition({
    required this.child,
    required Animation<Offset> position,
    this.transformHitTests = true,
  }) : super(listenable: position);

  final bool transformHitTests;
  final Widget child;

  @override
  Widget build(BuildContext context) {
    final position = listenable as Animation<Offset>;
    Offset offset = position.value;
    if (position.status == AnimationStatus.reverse) {
      offset = Offset(-offset.dx, offset.dy);
    }
    return FractionalTranslation(
      translation: offset,
      transformHitTests: transformHitTests,
      child: child,
    );
  }
}

调用时,将SlideTransition替换成MSSlideTransition即可:

...
AnimatedSwitcher(
  child: Text(
    _text,
    textScaleFactor: 1.5,
    key: ValueKey(_text),
  ),
  transitionBuilder: (child, anim1) {
    var tween = Tween(begin: Offset(1, 0), end: Offset(0, 0));
    return MSSlideTransition(
        child: child, position: tween.animate(anim1));
  },
  duration: Duration(milliseconds: 300),
),
...

完整代码


class MSAnimatedSwitcherDemo2 extends StatefulWidget {
  const MSAnimatedSwitcherDemo2({Key? key}) : super(key: key);

  @override
  State<MSAnimatedSwitcherDemo2> createState() =>
      _MSAnimatedSwitcherDemo2State();
}

class _MSAnimatedSwitcherDemo2State extends State<MSAnimatedSwitcherDemo2> {
  var _count = 0;
  @override
  Widget build(BuildContext context) {
    final _text = _count < 10 ? "0$_count" : "$_count";
    return Scaffold(
      body: Container(
        alignment: Alignment.center,
        child: AnimatedSwitcher(
          child: Text(
            _text,
            textScaleFactor: 1.5,
            key: ValueKey(_text),
          ),
          transitionBuilder: (child, anim1) {
            var tween = Tween(begin: Offset(1, 0), end: Offset(0, 0));
            return MSSlideTransition(
                child: child, position: tween.animate(anim1));
          },
          duration: Duration(milliseconds: 300),
        ),
      ),
      floatingActionButton: FloatingActionButton(
        child: Icon(Icons.add),
        onPressed: () {
          setState(() {
            _count++;
          });
        },
      ),
    );
  }
}

class MSSlideTransition extends AnimatedWidget {
  MSSlideTransition({
    required this.child,
    required Animation<Offset> position,
    this.transformHitTests = true,
  }) : super(listenable: position);

  final bool transformHitTests;
  final Widget child;

  @override
  Widget build(BuildContext context) {
    final position = listenable as Animation<Offset>;
    Offset offset = position.value;
    if (position.status == AnimationStatus.reverse) {
      offset = Offset(-offset.dx, offset.dy);
    }
    return FractionalTranslation(
      translation: offset,
      transformHitTests: transformHitTests,
      child: child,
    );
  }
}


59.gif

可以看到,我们通过这种巧妙的方式实现了类似路由进场切换的动画,实际上Flutter路由切换也正是通过AnimatedSwitcher来实现的

5.2 自定义 MSSlideTransitionX

封装一个SlideTransitionX,来实现 “左出右入”、“左入右出”、“上入下出”或者 “下入上出”动画

自定义 MSSlideTransitionX


class MSSlideTransitionX extends AnimatedWidget {
  MSSlideTransitionX({
    this.dir = AxisDirection.down,
    required Animation<double> position,
    required this.child,
    this.transformHitTests = true,
  }) : super(listenable: position) {
    switch (dir) {
      case AxisDirection.left:
        // 左出右进
        _tween = Tween<Offset>(begin: Offset(1, 0), end: Offset(0, 0));
        break;
      case AxisDirection.right:
        // 右出左进
        _tween = Tween<Offset>(begin: Offset(-1, 0), end: Offset(0, 0));
        break;
      case AxisDirection.up:
        // 上出下进
        _tween = Tween<Offset>(begin: Offset(0, 1), end: Offset(0, 0));
        break;
      case AxisDirection.down:
        // 下出上进
        _tween = Tween<Offset>(begin: Offset(0, -1), end: Offset(0, 0));
        break;
      default:
    }
  }

  final Widget child;
  final bool transformHitTests;
  final AxisDirection dir;
  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 (dir) {
        case AxisDirection.left:
          // 左出右进
          offset = Offset(-offset.dx, offset.dy);
          break;
        case AxisDirection.right:
          // 右出左进
          offset = Offset(-offset.dx, offset.dy);
          break;
        case AxisDirection.up:
          // 上出下进
          offset = Offset(offset.dx, -offset.dy);
          break;
        case AxisDirection.down:
          // 下出上进
          offset = Offset(offset.dx, -offset.dy);
          break;
        default:
      }
    }
    return FractionalTranslation(
      translation: offset,
      transformHitTests: transformHitTests,
      child: child,
    );
  }
}

示例


class MSAnimatedSwitcherDemo3 extends StatefulWidget {
  const MSAnimatedSwitcherDemo3({Key? key}) : super(key: key);

  @override
  State<MSAnimatedSwitcherDemo3> createState() =>
      _MSAnimatedSwitcherDemo3State();
}

class _MSAnimatedSwitcherDemo3State extends State<MSAnimatedSwitcherDemo3> {
  var _count = 0;
  @override
  Widget build(BuildContext context) {
    final _text = _count < 10 ? "0$_count" : "$_count";
    return Scaffold(
      body: Container(
        alignment: Alignment.center,
        child: AnimatedSwitcher(
          child: Text(
            _text,
            textScaleFactor: 1.5,
            key: ValueKey(_text),
          ),
          transitionBuilder: (child, anim1) {
            return MSSlideTransitionX(
              position: anim1,
              child: child,
              dir: AxisDirection.down,
            );
          },
          duration: Duration(milliseconds: 500),
        ),
      ),
      floatingActionButton: FloatingActionButton(
        child: Icon(Icons.add),
        onPressed: () {
          setState(() {
            _count++;
          });
        },
      ),
    );
  }
}

class MSSlideTransitionX extends AnimatedWidget {
  MSSlideTransitionX({
    this.dir = AxisDirection.down,
    required Animation<double> position,
    required this.child,
    this.transformHitTests = true,
  }) : super(listenable: position) {
    switch (dir) {
      case AxisDirection.left:
        // 左出右进
        _tween = Tween<Offset>(begin: Offset(1, 0), end: Offset(0, 0));
        break;
      case AxisDirection.right:
        // 右出左进
        _tween = Tween<Offset>(begin: Offset(-1, 0), end: Offset(0, 0));
        break;
      case AxisDirection.up:
        // 上出下进
        _tween = Tween<Offset>(begin: Offset(0, 1), end: Offset(0, 0));
        break;
      case AxisDirection.down:
        // 下出上进
        _tween = Tween<Offset>(begin: Offset(0, -1), end: Offset(0, 0));
        break;
      default:
    }
  }

  final Widget child;
  final bool transformHitTests;
  final AxisDirection dir;
  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 (dir) {
        case AxisDirection.left:
          // 左出右进
          offset = Offset(-offset.dx, offset.dy);
          break;
        case AxisDirection.right:
          // 右出左进
          offset = Offset(-offset.dx, offset.dy);
          break;
        case AxisDirection.up:
          // 上出下进
          offset = Offset(offset.dx, -offset.dy);
          break;
        case AxisDirection.down:
          // 下出上进
          offset = Offset(offset.dx, -offset.dy);
          break;
        default:
      }
    }
    return FractionalTranslation(
      translation: offset,
      transformHitTests: transformHitTests,
      child: child,
    );
  }
}

60.gif

https://book.flutterchina.club/chapter9/animated_switcher.html

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,362评论 5 477
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,330评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,247评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,560评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,580评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,569评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,929评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,587评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,840评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,596评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,678评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,366评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,945评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,929评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,165评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 43,271评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,403评论 2 342

推荐阅读更多精彩内容