Flutter回顾 #1 动画专题:隐式动画 · 补间动画 · 显式动画

87 阅读4分钟

截屏2026-03-17 17.20.59.png

隐式动画

隐式动画指flutter全自动控制单个Widgte变化,不需要开发者关心,常用于控件动画效果,不需要管理AnimationController

单Widget

单个组件讨论下常见的几个扩展出来的动画Widget,以及他们的属性:curve

AnimatedContainer

Contianer对应动画组件是AnimatedContainer,热重载之后会看到代码改动之后的变化动画。注意,Containercolordecoration属性只能二选一,因为本身后者这就是前者的实现原理。

body: Center(
        child: AnimatedContainer(
          duration: Duration(milliseconds: 100),
          width: 300,
          height: _height,
          decoration: BoxDecoration(
            gradient: LinearGradient(        //渐变组件
              begin: Alignment.bottomCenter, //渐变起始色位置
                end: Alignment.topCenter,    //渐变终止色位置
                stops: [0.1,0.3],            //渐变出现的区间
                colors: [Colors.red, Colors.white]), //起始色和终止色
            boxShadow: [BoxShadow(
                spreadRadius: 20, //边框颜色粗细度
                blurRadius: 20)], //边框模糊粗细度
            borderRadius: BorderRadius.circular(150) //边框圆角半径
          ),
          child: Center(child: Text("HI", style: TextStyle(fontSize: 50))),
        ),
      ),
截屏2026-03-17 18.29.12.png

AnimatedPadding

Padding组件常用于控制Widget外边距,一样的有扩展组件AnimatedPadding实现隐式动画效果,这里我们顺便研究下这些扩展Widget共有的属性————曲线Curve。该属性可以控制动画的变化速率,默认的只是linear线形平均变化,这里只展示最常用3种,理解意思即可,弹入动画开始时先有回弹效果再线性变化,弹出动画先是线性变化再是结束时执行回弹效果,弹入弹出则是两者兼有,曲线变化常量有非常多,其他可以移步官方文档查看。

Mar-19-2026 01-27-35.gif Mar-19-2026 01-26-17.gif Mar-19-2026 01-25-10.gif

多Widget

Widget树上下多层Widget切换要实现动画,需要用到特殊的动画组件,这里讨论下AnimatedSwitcher,及它的属性transitionBuilder

AnimatedSwitcher

在多个Widget之间实现动画,常见的父Widget切换子Widget类型时,AnimatedSwitcher可以实现平滑过渡的动画效果,但要注意,AnimatedSwitcher只能让他的直接child切换类型(或者Key变化)时产生动画效果,结构上隔一代不会有效果,同类型同key也不会有效果。不同类型直接生效,同类型先看key,不一样的话也会生效。

child: AnimatedSwitcher(
  duration: Duration(milliseconds: 3000),
  child: _height > 400 ? Container(color: Colors.blue, width: 200, height: 200,)
      : CircularProgressIndicator()
),
Mar-17-2026 22-14-51.gif

动画效果实际上由AnimatedSwitchertransitionBuilder来控制的,默认不指定会自动实现了FadeTransition,从而有了渐隐效果。transitionBuilder实质上不是一个对象,是一个带2个参数的函数,RotationTransitionScaleTransition等等,多个transitionBuilder可以嵌套组合实现复杂的多重效果。

child: AnimatedSwitcher(
            transitionBuilder: (child,animation){
              return FadeTransition(
                opacity: animation,
                child: ScaleTransition(scale: animation,
                  child: child,
                ),
              );
            },
              duration: Duration(milliseconds: 3000),
              child: Text(key: ValueKey(_height),"$_height",style: TextStyle(fontSize: 50),)
          ),
Mar-17-2026 22-39-28.gif

补间动画

补间动画区别于普通的隐式动画之处在于,隐式动画变化往往是线性的(默认实现),或者说只能跟随Curve常量指定效果,如果我们想要给动画打关键帧从而实现丰富自由的效果,隐式动画就非常不方便了,Flutter补间动画可以非常方便用TweenAnimationBuilder来实现,拥有比一般隐式动画更高的自由度,同时也不需要操心AnimationController的管理。

AnimatedOpacity

Opacity常用于控制Widget透明度,其扩展Widget——AnimatedOpacityAnimatedContainer类似,只需要指定Duration,一样可以实现动画效果。

TweenAnimationBuilder

如果不知道AnimatedOpacity,可以用TweenAnimationBuilder来实现一样的动画效果。参数builder是一个回调函数,注意这里通过return返回下一级的Widget,而不是通过child属性。builder函数带有contextvaluewidget三个参数,value是动画tween属性beginend时区间之中的变化值,参数类型为Object,不是必须是数值。例子里透明度参数必须为double,所以将变化值int改为double,或者将Tween强制指定泛型。

这时我们就可以在透明度变化中对value值处理,变相实现打关键帧了,比如变化到50%时直接变成不透明状态。

Mar-19-2026 02-42-14.gif
child: TweenAnimationBuilder(
  tween: Tween<double>(begin: 0, end: 1),
  duration: Duration(seconds: 5),
  builder: (BuildContext context, double value, Widget? child) {
    double opacity = value * 2 <= 1 ? value * 2 : 1;
    int progressInt = (value*100).round();
    int opacityInt = (opacity*100).round();
    String progress = "动画进度${progressInt}% \n不透明度${opacityInt}";
    return Opacity(
      opacity: opacity,
      child: Container(
        color: Colors.blue,
        width: 300,
        height: 300,
        child: Center(
          child: Text(progress, style: TextStyle(fontSize: 20),),
        ),
      ),
    );
  },
),

值得一提,TweenAnimationBuilder如果在动画执行中被打断,那么新动画会在被打断的value值处重新向着区间端值继续计算,并不是从端值从头来过。

另外,Tween属性begin默认不传值就和end保持一致,value没有任何变化,如果用value控制动画则不会有任何效果。同时热重载的时候只修改begin不改end也是不会有任何效果,value此时的值只会和end做对比。

Transform

总结思路,TweenAnimationBuilder内部子Widget利用变化的value实现了补间动画。Transfrom这个Widget非常适合结合value实现日常动画分别使用Transform.translateTransform.rotateTransform.scale,将value传入对应变换参数(offsetanglescale),实现平移、旋转、缩放动画效果。

Mar-19-2026 03-06-47.gif
child: TweenAnimationBuilder(
  tween: Tween<double>(begin: 1, end: _big ? 3 : 1),
  duration: Duration(seconds: 2),
  builder: (BuildContext context, double value, Widget? child) {
    return Container(
      color: Colors.yellow,
      width: 300,
      height: 300,
      child: Transform.scale(
        scale: value,
        child: Center(
          child: Text("Hi", style: TextStyle(fontSize: 70),),
        ),
      ),
    );
  },
),

滚动计数器实例

该例子同时用上了PositionOpacity两个普通Widget,结合TweenAnimationBuildervalue实现了平移、透明度渐变的双重动画效果。

Mar-19-2026 19-54-37.gif
class AnimatedCounter extends StatelessWidget {
  final Duration duration;
  const AnimatedCounter({super.key,required this.duration});

  @override
  Widget build(BuildContext context) {
    print("AnimatedCounter build");
    return Container(
      color: Colors.blue,
      width: 300,
      height: 100,
      child: TweenAnimationBuilder(
        tween: Tween<double>(begin: 7, end: 8),
        duration: duration,
        builder: (BuildContext context, double value, Widget? child) {
          final whole = value ~/ 1;
          final decimal = value - whole;
          return Stack(
            children: [
              Positioned(
                top: -100 * decimal, //0 -> -100
                child: Opacity(
                  opacity: 1.0 - decimal, //1.0 -> 0.0
                  child: Text("$whole", style: TextStyle(fontSize: 70)),
                ),
              ),
              Positioned(
                top: 100 - decimal * 100, //100 -> 0
                child: Opacity(
                  opacity: decimal, //0.0 -> 1.0
                  child: Text("${whole + 1}", style: TextStyle(fontSize: 70)),
                ),
              ),
            ],
          );
        },
      ),
    );
  }
}

显式动画

前面介绍的隐式动画都是状态A->B,要么位置A到位置B,要么透明度A到透明度B,然后就停止了。如果想实现无限循环的加载圈风格的动画效果,就需要用到显式动画。高自由度的同时要自己手动管理AnimationController,不然容易内存泄漏。

两者区别最明显在于,谁在控制动画,隐式动画由框架全自动控制,你只需要告诉Flutter最终状态是什么,框架自动计算中间帧并执行动画;显式动画由开发者手动控制并管理动画生命周期,使用AnimationController,可以暂停、反转、循环、组合一系列效果,如网络请求成功后停止循环动画。

隐式动画是你告诉Flutter要去哪里,Flutter开车带你过去;显式动画是你自己开车,控制方向盘、油门、刹车决定怎么开过去。前者简单不易出错,后者复杂、需要手动释放资源但自由度高。

AnimatedWidget

隐式动画中,自带动画效果的Widget都有Animated-前缀开头;显式动画中,都有-Transition后缀结尾的Widget与之搭配,RotationTransitionFadeTransitionScaleTransitionSlideTransition等,这里结合使用SingleTickerProviderStateMixinAnimationController展示显式动画基本用法。

Mar-20-2026 00-56-14.gif
class MyHomePage extends StatefulWidget {
  const MyHomePage({super.key, required this.title});

  final String title;

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

class _MyHomePageState extends State<MyHomePage>
    with SingleTickerProviderStateMixin {
  late AnimationController _controller;
  bool _loading = false;

  @override
  void initState() {
    _controller = AnimationController(
      vsync: this,
      duration: Duration(milliseconds: 1000),
    );
    super.initState();
  }

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        backgroundColor: Theme.of(context).colorScheme.inversePrimary,
        title: Text(widget.title),
      ),
      body: Center(
        child: RotationTransition(
          turns: _controller,
          child: Icon(Icons.refresh, size: 100),
        ),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () {
          if (_loading) {
            _controller.stop();
          } else {
            _controller.repeat();
          }
          _loading = !_loading;
        },
        tooltip: 'Increment',
        child: const Icon(Icons.add),
      ),
    );
  }
}

有几点需要提下,这里AnimationControllervsync属性vertical sync的缩写,意思是"垂直同步",是指显示器刷新时发出的信号(通常60Hz,每16.67ms一次),告诉动画"跟着屏幕刷新走,不要自己乱跑"。

SingleTickerProviderStateMixinTickerProvider的实现,提供vsync信号的对象,只有混入了TickerProviderState才有获得屏幕刷新信号的能力来控制动画生命周期。

可以简单理解为AnimationController搭配TickerProvider为固定的模板代码,避免了页面隐藏后动画仍在执行(浪费资源,动画与页面生命周期不一致)、屏幕不刷新时也在更新动画(刷新率不同步)这类问题,是安全机制,让动画智能地跟随页面生命周期,该走走该停停。

AnimationController实际上是一个Animation<double>,一个持有一系列double类型数据的对象,在动画执行过程中AnimationControllervalve一直在持续变化,默认是0.0-1.0之间变化,_controller.value即可查看当前的变化值,这个和之前TweenAnimationBuilderTween对象类似,能够精确返回当前动画执行的进度值,如果我们想自定义数值变化范围,指定AnimationControllerlowerBoundupperBound即可。

Tween

Tween是Flutter动画系统的"值映射器",区间值对象,本身是一个Animatable<T>对象,它定义动画的起始值和结束值,并能计算出中间值。它不仅用于TweenAnimationBuilder,也用于AnimationController(等同于lowerBoundupperBound作用)。

Tween绑定AnimationController再绑定Curve可以实现丰富的效果,返回Animation<T>AnimatedWidget。实现绑定要么AnimationController.drive主动绑定Tween对象,要么Tween.animate主动绑定AnimationController

Mar-20-2026 02-37-30.gif

body: SlideTransition(
        //position: _controller.drive(Tween(begin: Offset(0, 0), end: Offset(2, 0),),),  //AnimationController主动绑定Tween
        position: Tween(begin: Offset(0, 0), end: Offset(2, 0),)
            .chain(CurveTween(curve: Curves.elasticInOut,),) //ElasticInOutCurve添加弹入弹出变速效果
            .chain(CurveTween(curve: Interval(0.0, 0.5),),) //Interval严格控制动画在哪个阶段里执行
            .animate(_controller),//Tween主动绑定AnimationController
        child: Container(
          color: Colors.blue,
          width: 200,
          height: 200,
        ),
      ),

这里Tween不仅绑定AnimationController,同时链式调用chain方法绑定多个CurveElasticInOutCurve实现弹性弹入弹出、Interval实现仅区间生效的动画效果,两者都是Curve的子类,包装成CurveTween,再由Tween串联起来,chain返回Animatable<T>再给到animate方法,再变成Animation<T>给到AnimatedWidget

注意chain串联CurveTween时,等于h(g(f(x)))数学上的复合函数计算,必须清楚里层函数值域符合下一层函数定义域要求。

AnimationBuilder

类似TweenAnimationBuilderAnimationBuilder也是通过回调函数构建子Widget的,不同的是,builder函数只有contextwidget两个参数。

结合之前Tween的用法,作为Animatable<T>子类,直接用value属性就可以为值需要double基本类型的参数的普通Widget所用。如果只需要不透明度从[0.2,0.9]变化,就不需要另外手动指定opacity:0.2+(0.9-0.2)*_controller.value,用Tween提前声明,结构清晰、方便扩展:

final Animation o = Tween(begin: 0.2, end: 0.9).animate(_controller);
...
opacity: o.value,//使用Animation即可

如果对Curve还有要求,也可以扩展chain串联一起使用,非常方便。

Mar-20-2026 04-03-51.gif
    //_controller是[0,1]之间连续变化的数值,结合Tween
    //Animation.value=begin+(end-begin)*_controller.value
    final Animation opacityAnim = Tween(begin: 1.0, end: 0.0).animate(_controller);
    final Animation heightAnim = Tween(begin: 200.0, end: 100.0).animate(_controller);

     ...
      body: Center(
        child: AnimatedBuilder(
          animation: _controller,
          builder: (BuildContext context, Widget? child) {
            return Opacity(
              opacity: opacityAnim.value, //不支持Animation<double> 只能传double
              child: Container(
                color: Colors.blue,
                width: 200,
                height: heightAnim.value,//不支持Animation<double> 只能传double
                child: child, //拿到AnimatedBuilder无须跟随动画变化的child直接返回 优化性能
              ),
            );
          },
          child: Center(child: Text("",style: TextStyle(fontSize: 20),),),
        ),
      ),
     ...

注意,builder参数child有个极妙的用处,就是动画执行过程中,每次return返回新Widget子树时,直接复用AnimationBuilderchild,避免动画过程中重复创建的性能开销,这部分适合存放不需要动画控制的部分。

实例 478呼吸法

Mar-26-2026 11-31-41.gif
@override
  Widget build(BuildContext context) {
    // 共20s:4s由扩张 停顿7s 8s收缩 1s停顿
    final animExpand = Tween(begin: 0.0, end: 1.0)
        .chain(CurveTween(curve: Interval(0.0, 0.2))) //动画执行区间
        .animate(_controller);
    final animShrink = Tween(begin: 1.0, end: 0.0)
        .chain(CurveTween(curve: Interval(0.55, 0.95))) //动画执行区间
        .animate(_controller);

    final animOpacity = TweenSequence<double>([
      TweenSequenceItem<double>(
        tween: Tween<double>(begin: 1.0, end: 0.0),  // 第一次:淡出
        weight: 1.0,  // 权重1
      ),
      TweenSequenceItem<double>(
        tween: Tween<double>(begin: 0.0, end: 1.0),  // 第一次:淡入
        weight: 1.0,  // 权重1
      ),
      TweenSequenceItem<double>(
        tween: Tween<double>(begin: 1.0, end: 0.0),  // 第二次:淡出
        weight: 1.0,  // 权重1
      ),
      TweenSequenceItem<double>(
        tween: Tween<double>(begin: 0.0, end: 1.0),  // 第二次:淡入
        weight: 1.0,  // 权重1
      ),
    ]).animate(CurvedAnimation(
      parent: _controller,
      curve: Interval(0.2, 0.55),  // 整个序列限制在0.2-0.55
    ));

    return Scaffold(
      body: FadeTransition(
        opacity: animOpacity,
        child: AnimatedBuilder(
          animation: _controller,
          builder: (context, widget) {
            if(_controller.value >= 0.2 && !tag){
              print("4s到");
              tag=true;
            }
            return Center(
              child: Container(
                width: 300,
                height: 300,
                decoration: BoxDecoration(
                  shape: BoxShape.circle,
                  gradient: RadialGradient(
                    colors: [
                      ?Colors.blue[600], //最内层辐射渐变颜色
                      ?Colors.blue[100], //最外层辐射渐变颜色
                    ],
                    //数组为辐射半径起始点、结束点
                    //根据不同的controller.value选择不同的Tween.value控制渐变范围
                    stops: _controller.value <= 0.2
                        ? [animExpand.value, animExpand.value + 0.1]
                        : [animShrink.value, animShrink.value + 0.1],
                  ),
                ),
              ),
            );
          },
        ),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () {
          // _controller.repeat(reverse: false);
          _controller.forward();
        },
        child: const Icon(Icons.add),
      ),
    );
  }

这里用到了Tween序列TweenSequence实现正反透明度循环变化,两个来回,稍显重复,自定义Curve更理想。animExpandanimShrink在这里只是绑定controller用作当渐变值使用。从头到尾只使用一个controller。

下面是两个controller实例,一个控制渐变,一个控制透明度。因为有两个controller继续用SingleTickerProviderStateMixin,会报错: _MyHomePageState is a SingleTickerProviderStateMixin but multiple tickers were created.改用TickerProviderStateMixin才能正常编译通过。

与单controller方案划分多个动画区间思路不一样,这里动画改用了按钮事件阻塞触发,Future.delayed等待一段时长,到时间了再执行下一步动画。

//单控制器用SingleTickerProviderStateMixin,两个以上只能用TickerProviderStateMixin
class _MyHomePageState extends State<MyHomePage> with TickerProviderStateMixin {
  late AnimationController _expandController;
  late AnimationController _opacityController;

  @override
  void initState() {
    _expandController = AnimationController(vsync: this);
    _opacityController = AnimationController(vsync: this);
    super.initState();
  }

  @override
  void dispose() {
    _expandController.dispose();
    _opacityController.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    // 共20s:4s由扩张 停顿7s 8s收缩 1s停顿
    final animExpand = Tween(begin: 0.0, end: 1.0)
        .chain(CurveTween(curve: Interval(0.0, 0.2))) //动画执行区间
        .animate(_expandController);
    final animShrink = Tween(begin: 1.0, end: 0.0)
        .chain(CurveTween(curve: Interval(0.55, 0.95))) //动画执行区间
        .animate(_expandController);

    return Scaffold(
      body: FadeTransition(
        opacity: Tween(begin: 1.0, end: 0.5).animate(_opacityController),
        child: AnimatedBuilder(
          animation: _expandController,
          builder: (context, widget) {
            return Center(
              child: Container(
                width: 300,
                height: 300,
                decoration: BoxDecoration(
                  shape: BoxShape.circle,
                  gradient: RadialGradient(
                    colors: [
                      ?Colors.blue[600], //最内层辐射渐变颜色
                      ?Colors.blue[100], //最外层辐射渐变颜色
                    ],
                    //数组为辐射半径起始点、结束点
                    //根据不同的controller.value选择不同的Tween.value控制渐变范围
                    stops: [
                      _expandController.value,
                      _expandController.value + 0.1,
                    ],//这时候渐变区间完全由_expandController.value控制不另外设置Tween
                  ),
                ),
              ),
            );
          },
        ),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () async {
          _expandController.duration = Duration(milliseconds: 4000);
          _expandController.forward();
          await Future.delayed(Duration(milliseconds: 4000));

          _opacityController.duration=Duration(milliseconds: 1750);
          _opacityController.repeat(reverse: true);
          await Future.delayed(Duration(milliseconds: 7000));
          _opacityController.reset();

          _expandController.duration = Duration(milliseconds: 8000);
          _expandController.reverse();
        },
        child: const Icon(Icons.add),
      ),
    );
  }
}

Hero 动画

页面A跳转B时,前后页面如果有相同的内容元素就可以用Hero动画进行平滑过渡,指屏幕间飞跃的Widget。

Apr-08-2026 23-41-13.gif

非常神奇丝滑!前后虽是不同的页面,即使在路由跳转之间也能实现相同元素平滑过渡。用法上Hero直接child虽然支持不同类型,但最好保持一致,如果结构不一样会导致动画开始时元素就突变出现或消失,效果生硬。


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

  final String title;

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

class _MyHomePageState extends State<MyHomePage> with TickerProviderStateMixin {
  @override
  void initState() {
    super.initState();
    timeDilation = 5.0; //Dilation膨胀:动画速度放慢5倍
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: GridView.count(
        crossAxisCount: 5,
        children: List.generate(100, (index) {
          return GestureDetector(
            onTap: () {
              Navigator.push(
                context,
                MaterialPageRoute(
                  builder: (_) {
                    return DetailScreen(name: index);
                  },
                ),
              );
            },
            child: Hero(
              tag: index,
              child: Container(
                width: 100,
                height: 100,
                decoration: BoxDecoration(
                  border: BoxBorder.all(width: 1.0),
                  color: Colors.blue,
                ),
                child: Center(
                  child: Material(
                    color: Colors.transparent,
                    child: Text("$index", style: TextStyle(fontSize: 50)),
                  ),
                ),
              ),
              flightShuttleBuilder: (flightContext, animation, flightDirection,
                  fromContext, toContext) {
                // 创建 Tween
                final fontSizeTween = Tween<double>(begin: 50, end: 200);
                final widthTween = Tween<double>(begin: 100, end: 300);
                final heightTween = Tween<double>(begin: 100, end: 300);

                // ✅ 关键:使用 AnimatedBuilder
                return AnimatedBuilder(
                  animation: animation,  // 传递 Hero 的 animation
                  builder: (context, child) {
                    // 每次动画值变化时都会重建
                    return Container(
                      width: widthTween.evaluate(animation),
                      height: heightTween.evaluate(animation),
                      decoration: BoxDecoration(
                        border: BoxBorder.all(width: 1.0),
                        color: Colors.blue,
                      ),
                      child: Center(
                        child: Material(
                          color: Colors.transparent,
                          child: Text(
                            "$index",
                            style: TextStyle(fontSize: fontSizeTween.evaluate(animation)),
                          ),
                        ),
                      ),
                    );
                  },
                );
              },
            ),

          );
        }),
      ),
    );
  }
}

class DetailScreen extends StatelessWidget {
  final int name;

  const DetailScreen({super.key, required this.name});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: GestureDetector(
        child: Center(
          child: Hero(
            tag: name,
            child: Container(
              width: 300,
              height: 300,
              decoration: BoxDecoration(
                border: BoxBorder.all(width: 1.0),
                color: Colors.blue,
              ),
              child: Center(
                child: Material(
                  color: Colors.transparent,
                  child: Text("$name", style: TextStyle(fontSize: 200)),
                ),
              ),
            ),
          ),
        ),
        onTap: () {
          Navigator.pop(context);
        },
      ),
    );
  }
}


注意地方:

1.Text比较特殊会自动使用Material样式,Hero包裹的Text必须统一使用Scaffold或者Material组件包裹,这样会自动继承MaterialApp的样式,否则动画前后变化过程中会突变成红色下划线文本。

2.Text文字大小因为跟随动画前的数据不会自动平滑变大或者变小,要在flightShuttleBuilder中重新实现飞跃时的样式,其中就要用到上面的动画知识,创建Tween、使用AnimatedBuilder

3.timeDilation动画时长膨胀值,这里将动画速度放慢5倍。

实例 结合CustomPaint的下雪动画

这是一个结合底层画布+动画的综合应用实例,用的也是之前上面介绍过的知识,非常能体现Flutter优秀的动画性能。因为需要循环播放,相比隐式动画,用显式动画实现比较方便。

Apr-09-2026 01-32-20.gif


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

  final String title;

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

class _MyHomePageState extends State<MyHomePage>
    with SingleTickerProviderStateMixin {
  late AnimationController _controller;
  final List<SnowFlake> _snowFlakes = List.generate(
    1000,
    (index) => SnowFlake(),
  );

  @override
  void initState() {
    super.initState();
    _controller = AnimationController(
      duration: Duration(seconds: 1),
      vsync: this,
    )..repeat();
  }

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Container(
        constraints: BoxConstraints.expand(),
        decoration: BoxDecoration(
          gradient: LinearGradient(
            colors: [Colors.blue, Colors.lightBlue, Colors.white],
            stops: [0, 0.7, 0.95],
            begin: Alignment.topCenter,
            end: Alignment.bottomCenter,
          ),
        ),
        child: AnimatedBuilder(
          animation: _controller,
          builder: (_, _) {
            for (var e in _snowFlakes) {
              e.fall();
            }
            return CustomPaint(painter: MyPainter(_snowFlakes));
          },
        ),
      ),
    );
  }
}

class MyPainter extends CustomPainter {
  final whitePaint = Paint()..color = Colors.white;

  final List<SnowFlake> _snowFlakes;

  MyPainter(this._snowFlakes);

  @override
  void paint(Canvas canvas, Size size) {
    // print("$size");
    canvas.drawCircle(size.center(Offset(0, 60)), 60.0, whitePaint);
    canvas.drawOval(
      Rect.fromCenter(
        center: size.center(Offset(0, 220)),
        width: 200,
        height: 250,
      ),
      whitePaint,
    );
    for (var snowFlake in _snowFlakes) {
      canvas.drawCircle(
        Offset(snowFlake.x, snowFlake.y),
        snowFlake.radius,
        whitePaint,
      );
    }
  }

  @override
  bool shouldRepaint(covariant CustomPainter oldDelegate) => true;
}

class SnowFlake {
  double x = Random().nextDouble() * 800;
  double y = Random().nextDouble() * 600;
  double radius = Random().nextDouble() * 2 + 2;
  double velocity = Random().nextDouble() * 4 + 2;

  void fall() {
    y += velocity;
    if (y > 600) {
      x = Random().nextDouble() * 800;
      y = 0;
      radius = Random().nextDouble() * 2 + 2;
      velocity = Random().nextDouble() * 4 + 2;
    }
  }
}

这里也使用了基本的的CustomPainter用法,Size是画布本身大小,取决于父Widget大小。shouldRepaint默认返回true,每一帧都需要重画。