【Flutter学习笔记】9.6 动画切换组件(AnimatedSwitcher)

参考资料:《Flutter实战·第二版》9.6 动画切换组件(AnimatedSwitcher)


9.6.1 AnimatedSwitcher

AnimatedSwitcher 可以同时对其新、旧子元素添加显示、隐藏动画,在需要切换新旧元素的场景广泛使用。也就是说在AnimatedSwitcher子元素 发生变化时,会对其旧元素和新元素做动画。这里的子元素"发生变化"指的就是child widget的类型或者key发生了改变,则旧的child会执行隐藏动画,而新的child会执行显示动画。下面是AnimatedSwitcher 的定义:

dart 复制代码
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, //布局构建器
})

假设现在有动画执行曲线A(switchOutCurve)和B(switchInCurve)【这里要仅定义动画播放的时间控制形式,例如加速、减速或者子弹时间等效果,并不决定动画的类型】,默认为线性,分别对应旧child和新child,其中animation的定义在transitionBuilder当中。AnimatedSwitcher的默认值是AnimatedSwitcher.defaultTransitionBuilder,其默认返回的是FadeTransition,也就是渐显渐隐动画【很可能是透明度发生改变的动画】。该builder在AnimatedSwitcherchild切换时会分别对新、旧child绑定动画【这里才决定动画的类型】,旧的child会反向(reverse)执行,新的child会正向(forward)执行。

示例

下面是一个简单计数器的例子,在每一次自增的过程中,旧数字执行缩小动画隐藏,新数字执行放大动画显示:

dart 复制代码
import 'dart:ui';

import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  // This widget is the root of your application.
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.teal),
        useMaterial3: true,
      ),
      home: const MyHomePage(title: 'TEAL WORLD'),
    );
  }
}

class MyHomePage extends StatefulWidget {
  const MyHomePage({super.key, required this.title});
  final String title;

  @override
  State<MyHomePage> createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        backgroundColor: Theme.of(context).colorScheme.inversePrimary,
        title: Text(
          widget.title,
          style: TextStyle(
              color: Colors.teal.shade800, fontWeight: FontWeight.w900),
        ),
        actions: [
          ElevatedButton(
            child: const Icon(Icons.refresh),
            onPressed: (){},
          )
        ],
      ),
      body: const AnimatedSwitcherCounterRoute(),
      floatingActionButton: FloatingActionButton(
        onPressed: () {},
        tooltip: 'Increment',
        child: Icon(
          Icons.add_box,
          size: 30,
          color: Colors.teal[400],
        ),
      ), // This trailing comma makes auto-formatting nicer for build methods.
    );
  }
}

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

  @override
  AnimatedSwitcherCounterRouteState createState() =>
      AnimatedSwitcherCounterRouteState();
}

//需要继承TickerProvider,如果有多个AnimationController,则应该使用TickerProviderStateMixin。
class AnimatedSwitcherCounterRouteState
    extends State<AnimatedSwitcherCounterRoute>
    with SingleTickerProviderStateMixin {
  int _count = 0; // 计数状态,记录当前的数字

  @override
  Widget build(BuildContext context) {
    return Center(
      child: Column(
        mainAxisAlignment: MainAxisAlignment.center,
        children: <Widget>[
          // 用AnimatedSwitcher包裹需要进行变化的部分
          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),  // 注意child变化后的type或者key必须不同
              style: Theme.of(context).textTheme.headline1,
            ),
          ),
          const SizedBox(height: 10,),  // 创造一点间隔
          ElevatedButton(
            onPressed: () {
              setState(() {
                _count += 1;  // 点击按钮更新状态
              });
            },
            child: const Text("+1", style: TextStyle(fontSize: 20),),
          )
        ],
      ),
    );
  }
}

运行效果如下图所示,点击按钮后旧的数字缩小,而新的数字放大:

如果想将效果改为渐变效果,只需要把transitionBuilder这个属性所返回的切换效果改为FadeTransition即可:

dart 复制代码
transitionBuilder: (Widget child, Animation<double> animation) {
    return FadeTransition(
    	opacity: animation,
    	child: child,
  );
},

效果如下:

AnimatedSwitcher实现原理

首先,当child子widget的key或类型不同时,会重新执行build()。可以通过继承StatefulWidget来实现AnimatedSwitcher,通过didUpdateWidget回调判断新旧child是否发生了变化。如果变化,则对旧的child执行反向动画,对新的child执行正向动画。下面是AnimatedSwitcher实现的伪代码,流程类似,但是很多实现细节,例如过渡动画是自定义的、执行动画时的布局等等:

dart 复制代码
Widget _widget; 
void didUpdateWidget(AnimatedSwitcher oldWidget) {
// 该周期回调能够带入旧的widget
  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; // 其实是对child中的组件进行了再次包装,实际上同时显示了两个widget
}

可以看出,相当于两个子widget叠加在一起,同时进行动画,只不过一个反向,一个正向,在动画结束时只展示最新的组件。controler在定义时提供整个动画的时长设置,而animation在定义时需要设置曲线、提供区间值、设置监听等。可以看出在AnimatedSwitcher组件中已经定义好了controler,分别控制新旧组件。实际上animation应该也是在组件中默认定义好的,其定义中还有animation这个参数,就是用来控制child属性的:

dart 复制代码
const FadeTransition({
    super.key,
    required this.opacity,
    this.alwaysIncludeSemantics = false,
    super.child,
  });

上面opacity的类型是Animation<double>,也就是封装好的可以根据变值设置child的透明度或者其它属性。

另外,Flutter SDK中还提供了一个AnimatedCrossFade组件,它也可以切换两个子元素,切换过程执行渐隐渐显的动画,和AnimatedSwitcher不同的是AnimatedCrossFade是针对两个子元素,而AnimatedSwitcher是在一个子元素的新旧值之间切换。

9.6.2 AnimatedSwitcher高级用法

AnimatedSwitcher的使用过程中可以看出一个明显的问题,就是新旧组件用的同一个切换动画,只能实现对等的镜面动画效果。如果想实现旧页面从右向左←退出,而新页面从右向左←出现,方向是相同时,就做不到了,因为上文中原理部分代码用的是forwardrewerse这两个函数,就是同一个动画效果的正反向。

由此,可以通过自定义SlideTransition的形式实现,因为不同的过渡动画实现都是根据animation的值进行的,所以在这个组件的内部是可控的。

还是以上面的场景为例,原来的offset值应该是固定的变化,animation通过controller指挥进行正反向操作。animation中有包装动画的状态,如AnimationStatus.reverseAnimationStatus.forward等,则可以根据不同的状态计算不同的offset以达到打破对称性的目的,但是这个方法并不是万能的,需要根据不同的效果需求设计细节:

dart 复制代码
class MySlideTransition extends AnimatedWidget {
  const MySlideTransition({
    Key? key,
    required Animation<Offset> position,
    this.transformHitTests = true,
    required this.child,
  }) : super(key: key, 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,
    );
  }
}

使用时,将transitionBuilder的返回设置为MySlideTransition即可,实现代码和效果如下所示:

dart 复制代码
class AnimatedSwitcherCounterRouteState
    extends State<AnimatedSwitcherCounterRoute>
    with SingleTickerProviderStateMixin {
  int _count = 0; // 计数状态,记录当前的数字

  @override
  Widget build(BuildContext context) {
    print('child build');
    return Center(
      child: Column(
        mainAxisAlignment: MainAxisAlignment.center,
        children: <Widget>[
          // 用AnimatedSwitcher包裹需要进行变化的部分
          AnimatedSwitcher(
            duration: const Duration(milliseconds: 500),
            transitionBuilder: (Widget child, Animation<double> animation) {
              var tween = Tween<Offset>(begin: Offset(1, 0), end: Offset(0, 0));  // 动画Tween的默认是double的[0, 1],务必要改成Offset
              return MySlideTransition(
                position: tween.animate(animation),
                child: child,
              );
            }, // 重新定义为大小改变动画
            child: Text(
              '$_count',
              key: ValueKey<int>(_count), // 注意child变化后的type或者key必须不同
              style: Theme.of(context).textTheme.headline1,
            ),
          ),
          const SizedBox(
            height: 10,
          ), // 创造一点间隔
          ElevatedButton(
            onPressed: () {
              setState(() {
                _count += 1; // 点击按钮更新状态
              });
            },
            child: const Text(
              "+1",
              style: TextStyle(fontSize: 20),
            ),
          )
        ],
      ),
    );
  }
}

补充内容,关于FractionalTranslationtransformHitTests参数的用法:

在Flutter框架中,FractionalTranslation是一个用于对其子widget应用一个平移变换的widget。这个平移可以是分数形式的,也就是说它可以是基于其自身大小的百分比。

第二个参数transformHitTests是一个布尔值,用于指示是否应该应用变换到点击测试(hit tests)中。点击测试是Flutter框架中用于确定哪个widget应该响应一个特定的触摸或点击事件的过程。
具体解释如下:

如果transformHitTests设置为true,则当进行点击测试时,会考虑FractionalTranslation的平移变换。这意味着,如果你平移了一个widget,并且用户点击了平移后的位置,那么该widget将会捕获这个点击事件,即使点击的位置在原始(未平移)的widget布局中是空的。

如果transformHitTests设置为false,则点击测试不会考虑FractionalTranslation的平移变换。这意味着,即使widget被平移了,点击测试仍然会在原始的widget布局上进行。

这个参数在处理平移动画或其他动态变换时非常有用,因为它可以帮助你确定用户与界面交互时应该响应哪个widget。例如,如果你有一个可以滑动的卡片,你可能希望在卡片滑动的过程中,点击测试也跟随卡片移动,这样用户可以在卡片移动时继续与其交互。在这种情况下,你会将transformHitTests设置为true。然而,在某些情况下,你可能希望点击测试保持在原始位置,不受平移的影响,那么你就应该将其设置为false。

9.6.3 SlideTransitionX

除了上面的实现方式,如果想要定制性更高一些,例如上入下出、下入上出、左入右出的效果,可以输入一个参数来定制不同的Tween值,代表不同的滑动方向的变化。当然这要配合build函数中对动画状态的判断进行,否则动画还是对称的。这类组件可以实现高度自由化的定制,以满足不同的效果需求:

dart 复制代码
class SlideTransitionX extends AnimatedWidget {
  SlideTransitionX({
    Key? key,
    required Animation<double> position,  // 外部传入的animation对象
    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;  // 根据入参设置Tween,而不是在外面直接定义animation
  
  @override
  Widget build(BuildContext context) {
    final position = listenable as Animation<double>;
    Offset offset = _tween.evaluate(position);  // 通过evaluate函数可以获得offset的对应值
    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,
    );
  }
}

上述代码Offset offset = _tween.evaluate(position);这句可以看出,Tween对象不是在外面直接定义再产生Animation值的,而是在build的过程中根据默认Animation<double>的值映射产生的。

使用时只要替换成SlideTransitionX组件过渡并设置想要的参数即可,下面是上入下出效果的代码实现和效果:

dart 复制代码
class AnimatedSwitcherCounterRouteState
    extends State<AnimatedSwitcherCounterRoute>
    with SingleTickerProviderStateMixin {
  int _count = 0; // 计数状态,记录当前的数字

  @override
  Widget build(BuildContext context) {
    print('child build');
    return Center(
      child: Column(
        mainAxisAlignment: MainAxisAlignment.center,
        children: <Widget>[
          // 用AnimatedSwitcher包裹需要进行变化的部分
          AnimatedSwitcher(
            duration: const Duration(milliseconds: 500),
            transitionBuilder: (Widget child, Animation<double> animation) {
              return SlideTransitionX(
                position: animation,
                direction: AxisDirection.down,
                child: child,
              );
            }, // 重新定义为大小改变动画
            child: Text(
              '$_count',
              key: ValueKey<int>(_count), // 注意child变化后的type或者key必须不同
              style: Theme.of(context).textTheme.headline1,
            ),
          ),
          const SizedBox(
            height: 10,
          ), // 创造一点间隔
          ElevatedButton(
            onPressed: () {
              setState(() {
                _count += 1; // 点击按钮更新状态
              });
            },
            child: const Text(
              "+1",
              style: TextStyle(fontSize: 20),
            ),
          )
        ],
      ),
    );
  }
}
相关推荐
LN-ZMOI18 分钟前
c++学习笔记1
c++·笔记·学习
五味香22 分钟前
C++学习,信号处理
android·c语言·开发语言·c++·学习·算法·信号处理
qq_421833671 小时前
计算机网络——应用层
笔记·计算机网络
云端奇趣1 小时前
探索 3 个有趣的 GitHub 学习资源库
经验分享·git·学习·github
我感觉。1 小时前
【信号与系统第五章】13、希尔伯特变换
学习·dsp开发
知识分享小能手1 小时前
mysql学习教程,从入门到精通,SQL 修改表(ALTER TABLE 语句)(29)
大数据·开发语言·数据库·sql·学习·mysql·数据分析
冰榫2 小时前
9.30学习记录(补)
学习
@qike2 小时前
【C++】—— 日期类的实现
c语言·c++·笔记·算法·学习方法
IG工程师2 小时前
关于 S7 - 1200 通过存储卡进行程序更新
经验分享·笔记·自动化
霸王蟹2 小时前
Vue3 项目中为啥不需要根标签了?
前端·javascript·vue.js·笔记·学习