隐式动画
隐式动画指flutter全自动控制单个Widgte变化,不需要开发者关心,常用于控件动画效果,不需要管理AnimationController。
单Widget
单个组件讨论下常见的几个扩展出来的动画Widget,以及他们的属性:curve、
AnimatedContainer
Contianer对应动画组件是AnimatedContainer,热重载之后会看到代码改动之后的变化动画。注意,Container的color和decoration属性只能二选一,因为本身后者这就是前者的实现原理。
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))),
),
),
AnimatedPadding
Padding组件常用于控制Widget外边距,一样的有扩展组件AnimatedPadding实现隐式动画效果,这里我们顺便研究下这些扩展Widget共有的属性————曲线Curve。该属性可以控制动画的变化速率,默认的只是linear线形平均变化,这里只展示最常用3种,理解意思即可,弹入动画开始时先有回弹效果再线性变化,弹出动画先是线性变化再是结束时执行回弹效果,弹入弹出则是两者兼有,曲线变化常量有非常多,其他可以移步官方文档查看。
多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()
),
动画效果实际上由AnimatedSwitcher的transitionBuilder来控制的,默认不指定会自动实现了FadeTransition,从而有了渐隐效果。transitionBuilder实质上不是一个对象,是一个带2个参数的函数,RotationTransition、ScaleTransition等等,多个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),)
),
补间动画
补间动画区别于普通的隐式动画之处在于,隐式动画变化往往是线性的(默认实现),或者说只能跟随Curve常量指定效果,如果我们想要给动画打关键帧从而实现丰富自由的效果,隐式动画就非常不方便了,Flutter补间动画可以非常方便用TweenAnimationBuilder来实现,拥有比一般隐式动画更高的自由度,同时也不需要操心AnimationController的管理。
AnimatedOpacity
Opacity常用于控制Widget透明度,其扩展Widget——AnimatedOpacity与AnimatedContainer类似,只需要指定Duration,一样可以实现动画效果。
TweenAnimationBuilder
如果不知道AnimatedOpacity,可以用TweenAnimationBuilder来实现一样的动画效果。参数builder是一个回调函数,注意这里通过return返回下一级的Widget,而不是通过child属性。builder函数带有context、value、widget三个参数,value是动画tween属性begin到end时区间之中的变化值,参数类型为Object,不是必须是数值。例子里透明度参数必须为double,所以将变化值int改为double,或者将Tween强制指定泛型。
这时我们就可以在透明度变化中对value值处理,变相实现打关键帧了,比如变化到50%时直接变成不透明状态。
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.translate、Transform.rotate、Transform.scale,将value传入对应变换参数(offset、angle、scale),实现平移、旋转、缩放动画效果。
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),),
),
),
);
},
),
滚动计数器实例
该例子同时用上了Position、Opacity两个普通Widget,结合TweenAnimationBuilder的value实现了平移、透明度渐变的双重动画效果。
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与之搭配,RotationTransition、FadeTransition、ScaleTransition、SlideTransition等,这里结合使用SingleTickerProviderStateMixin、AnimationController展示显式动画基本用法。
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),
),
);
}
}
有几点需要提下,这里AnimationController的vsync属性vertical sync的缩写,意思是"垂直同步",是指显示器刷新时发出的信号(通常60Hz,每16.67ms一次),告诉动画"跟着屏幕刷新走,不要自己乱跑"。
SingleTickerProviderStateMixin是TickerProvider的实现,提供vsync信号的对象,只有混入了TickerProvider,State才有获得屏幕刷新信号的能力来控制动画生命周期。
可以简单理解为AnimationController搭配TickerProvider为固定的模板代码,避免了页面隐藏后动画仍在执行(浪费资源,动画与页面生命周期不一致)、屏幕不刷新时也在更新动画(刷新率不同步)这类问题,是安全机制,让动画智能地跟随页面生命周期,该走走该停停。
AnimationController实际上是一个Animation<double>,一个持有一系列double类型数据的对象,在动画执行过程中AnimationController的valve一直在持续变化,默认是0.0-1.0之间变化,_controller.value即可查看当前的变化值,这个和之前TweenAnimationBuilder的Tween对象类似,能够精确返回当前动画执行的进度值,如果我们想自定义数值变化范围,指定AnimationController的lowerBound和upperBound即可。
Tween
Tween是Flutter动画系统的"值映射器",区间值对象,本身是一个Animatable<T>对象,它定义动画的起始值和结束值,并能计算出中间值。它不仅用于TweenAnimationBuilder,也用于AnimationController(等同于lowerBound和upperBound作用)。
Tween绑定AnimationController再绑定Curve可以实现丰富的效果,返回Animation<T>给AnimatedWidget。实现绑定要么AnimationController.drive主动绑定Tween对象,要么Tween.animate主动绑定AnimationController。
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方法绑定多个Curve,ElasticInOutCurve实现弹性弹入弹出、Interval实现仅区间生效的动画效果,两者都是Curve的子类,包装成CurveTween,再由Tween串联起来,chain返回Animatable<T>再给到animate方法,再变成Animation<T>给到AnimatedWidget。
注意chain串联CurveTween时,等于h(g(f(x)))数学上的复合函数计算,必须清楚里层函数值域符合下一层函数定义域要求。
AnimationBuilder
类似TweenAnimationBuilder,AnimationBuilder也是通过回调函数构建子Widget的,不同的是,builder函数只有context、widget两个参数。
结合之前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串联一起使用,非常方便。
//_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子树时,直接复用AnimationBuilder的child,避免动画过程中重复创建的性能开销,这部分适合存放不需要动画控制的部分。
实例 478呼吸法
@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更理想。animExpand、animShrink在这里只是绑定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。
非常神奇丝滑!前后虽是不同的页面,即使在路由跳转之间也能实现相同元素平滑过渡。用法上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优秀的动画性能。因为需要循环播放,相比隐式动画,用显式动画实现比较方便。
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,每一帧都需要重画。