内容简介:Let’s be creative and implement a sparkler that could replace the usual loading indicator by burning from left to right.The animation we aim for looks like this: a sparkler that has been lighted with sparks coming out of the focus, moving along.Let’s handl
Let’s be creative and implement a sparkler that could replace the usual loading indicator by burning from left to right.
The goal
The animation we aim for looks like this: a sparkler that has been lighted with sparks coming out of the focus, moving along.
The implementation
The spark
Let’s handle the crucial part first: the spark. Before we implement, let’s think for a second what’s actually happening in the focus of a sparkler. A huge amount of sparks are shot in many directions. Because of their speed it occurs to the human eye that the spark creates a ray. They don’t always fly a linear trajectory, it can be curvy sometimes. The sparks change their color towards the end and sometimes there are tiny “stars” appearing somewhere near the rays.
So let’s go step by step and try to fulfill the following requirements when creating our spark:
- It starts as a small point and grows into a line
- The line moves in a certain direction
- The color is a gradient from yellow to red
- The trajectory is mostly straight but sometimes curvy
- Every spark has a unique length
- There are raondmly spread “stars”
Make something grow
class Particle extends StatefulWidget { Particle({ this.duration = const Duration(milliseconds: 200) }); final Duration duration; @override State<StatefulWidget> createState() { return _ParticleState(); } }
In order to create our first iteration we only need a single argument for the constructor of the widget. That is the duration from the appearance of the spark to the moment it disappears. We don’t know yet what is a good value for that, but we know for sure that in reality this is much less than a second. Let’s start with a default value of 200 milliseconds.
class _ParticleState extends State<Particle> with SingleTickerProviderStateMixin { AnimationController _controller; @override void initState() { super.initState(); this._controller = new AnimationController( vsync: this, duration: widget.duration ); _startNextAnimation(); _controller.addStatusListener((status) { if (status == AnimationStatus.completed) { _startNextAnimation(); } }); this._controller.addListener(() { setState(() {}); }); } void _startNextAnimation([Duration after]) { Future.delayed(Duration(seconds: 1), () { _controller.forward(from: 0.0); }); } @override dispose() { _controller.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return SizedBox( width: 1, height: 80, child: CustomPaint( painter: ParticlePainter( currentLifetime: _controller.value, ) ) ); } }
Now we have a basic setup: the widget uses the SingleTickerProvider and has an AnimationController that uses the duration we set as the constructor argument. After an animation is completed, the next animation starts. In the widget tree we have a SizedBox
with a fix size of 80. It contains a CustomPaint
widget with a painter named ParticlePainter
that is still to be defined. The crucial thing is the only parameter we provide to the painter: currentLifetime: _controller.value
. This gives our painter the progress value of the animation (at the beginning 0.0 and at the end 1.0). That enables us to decide what to draw dependent upon the time.
class ParticlePainter extends CustomPainter { ParticlePainter({ @required this.currentLifetime, }); final double currentLifetime; @override void paint(Canvas canvas, Size size) { Paint paint = Paint(); Rect rect = Rect.fromLTWH( 0, 0, size.width, -currentLifetime * size.height ); LinearGradient gradient = LinearGradient( colors: [Colors.yellowAccent, Colors.orangeAccent, Color.fromARGB(30, 255, 255, 255), Color.fromARGB(30, 255, 255, 255)], stops: [0, 0.3, 0.9, 1.0], begin: Alignment.topCenter, end: Alignment.bottomCenter ); paint.shader = gradient.createShader(rect); Path path = Path() ..addRect( rect ); canvas.drawPath(path, paint); } @override bool shouldRepaint(CustomPainter oldDelegate) { return true; } }
In the first iteration we just want to draw a tiny dot that transforms into a long line over time. For this, we paint a rectangle that has its upper left on the top left corner of the given size and fills the whole width. The height is crucial here: it’s the negative currentLifetime
times the size. That leads to the rectangle growing upwards starting with 0 * size.height
and ending with -1.0 * size.height
. So it’s a linear growth from the beginning to the end of the animation.
We also give the rectangle a gradient that linearly transforms from yellow to orange and from ornage to a semi-transparent white.
To measure what it looks like, we quickly setup a widget to display a bunch of sparks in a circle like we want it to be animated later on in the context of the sparkler
class Sparkler extends StatefulWidget { @override _SparklerState createState() => _SparklerState(); } class _SparklerState extends State<Sparkler> { final double width = 300; @override void initState() { super.initState(); } @override Widget build(BuildContext context) { return Center( child: Container( width: width, child: SizedBox( height: 100, child: Stack( children: getParticles(), ) ), ) ); } List<Widget> getParticles() { List<Widget> particles = List(); int maxParticles = 160; for (var i = 1; i <= maxParticles; i++) { particles.add( Padding( padding: EdgeInsets.only(left: 0.5 * width, top: 20), child: Transform.rotate( angle: maxParticles / i * pi, child: Padding( padding: EdgeInsets.only(top: 40), child: Particle() ) ) ) ); } return particles; } }
We display 160 of the particles we have just created and display them clockwise in a circle. The angle at which the Particle
is rotated via Transform.rotate
is from zero to pi
with 160 even gaps.
A single spark looks okay, but if we display the whole bunch of sparks it does not look a lot like a real spark. The main reason is that every spark appears at almost the same time and then performs exactly the same growth for the same duration. We need a little bit of randomness!
Adding randomness
There are some things we need to randomize in order to make it look more realistic:
- The delay after the first animation of every individual particle starts
- The delay between an animation and the next one
- The final size (length of the ray) of each particle
class _ParticleState extends State<Particle> with SingleTickerProviderStateMixin { AnimationController _controller; double randomSpawnDelay; double randomSize; bool visible = true; @override void initState() { super.initState(); randomSpawnDelay = Random().nextDouble(); randomSize = Random().nextDouble(); this._controller = new AnimationController( vsync: this, duration: widget.duration, ); _startNextAnimation( Duration(milliseconds: (Random().nextDouble() * 1000).toInt()) ); _controller.addStatusListener((status) { if (status == AnimationStatus.completed) { visible = false; _startNextAnimation(); } }); this._controller.addListener(() { setState(() {}); }); } void _startNextAnimation([Duration after]) { if (after == null) { int millis = (randomSpawnDelay * 300).toInt(); after = Duration(milliseconds: millis); } Future.delayed(after, () { setState(() { randomSpawnDelay = Random().nextDouble(); randomSize = Random().nextDouble(); visible = true; }); _controller.forward(from: 0.0); }); } @override dispose() { _controller.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return SizedBox( width: 1.5, height: 100, child: Opacity( opacity: visible ? 1.0 : 0.0, child: CustomPaint( painter: ParticlePainter( currentLifetime: _controller.value, randomSize: randomSize, ) ) ) ); } } class ParticlePainter extends CustomPainter { ParticlePainter({ @required this.currentLifetime, @required this.randomSize, }); final double currentLifetime; final double randomSize; @override void paint(Canvas canvas, Size size) { Paint paint = Paint(); double width = size.width; Rect rect = Rect.fromLTWH( 0, 0, width, -currentLifetime * size.height * randomSize ); LinearGradient gradient = LinearGradient( colors: [Colors.yellowAccent, Colors.orangeAccent, Color.fromARGB(30, 255, 255, 255), Color.fromARGB(30, 255, 255, 255)], stops: [0, 0.3, 0.9, 1.0], begin: Alignment.topCenter, end: Alignment.bottomCenter ); paint.shader = gradient.createShader(rect); Path path = Path() ..addRect( rect ); canvas.drawPath(path, paint); } @override bool shouldRepaint(CustomPainter oldDelegate) { return true; } }
For this, we create a new variable called randomSpawnDelay
that initially has a random double from 0.0 to 1.0. Every time the animation finishes, we reset the spawn delay to a new random value and make it the seconds until the next animation starts. Now since there is a delay between animations we have the problem that the particle stays there in the last animation state until the next animation starts. That’s why we create a bool variable called visible
which is initially true and set to false after the animation has finished. For a random ray length of the particles we add a random double called randomSize
which we multiply in the painter with the height we have calculated so far.
Curves
Far from perfect but a lot more realistic than the first iteration. Now what is missing? If we have a look at our list we made at the beginning, we notice, that we haven’t implemented that sometimes the trajectory of a spark should not be a line but rather a curve.
class _ParticleState extends State<Particle> with SingleTickerProviderStateMixin { … double arcImpact; … @override void initState() { super.initState(); … arcImpact = Random().nextDouble() * 2 - 1; … void _startNextAnimation([Duration after]) { … Future.delayed(after, () { setState(() { … arcImpact = Random().nextDouble() * 2 - 1; … }); … }); } … @override Widget build(BuildContext context) { return SizedBox( … child: CustomPaint( painter: ParticlePainter( … arcImpact: arcImpact ) ) ) ); } }
class ParticlePainter extends CustomPainter { ParticlePainter({ @required this.currentLifetime, @required this.randomSize, @required this.arcImpact }); final double currentLifetime; final double randomSize; final double arcImpact; @override void paint(Canvas canvas, Size size) { Paint paint = Paint(); double width = size.width; double height = size.height * randomSize * currentLifetime; Rect rect = Rect.fromLTWH( 0, 0, width, height ); Path path = Path(); LinearGradient gradient = LinearGradient( colors: [Color.fromRGBO(255, 255, 160, 1.0), Color.fromRGBO(255, 255, 160, 0.7), Color.fromRGBO(255, 180, 120, 0.7)], stops: [0, 0.6, 1.0], begin: Alignment.topCenter, end: Alignment.bottomCenter ); paint.shader = gradient.createShader(rect); paint.style = PaintingStyle.stroke; paint.strokeWidth = width; path.cubicTo(0, 0, width * 4 * arcImpact, height * 0.5, width, height); canvas.drawPath(path, paint); } @override bool shouldRepaint(CustomPainter oldDelegate) { return true; } }
To do that, we add a new parameter arcImpact
. It describes how far the curve should lean across the side. It should range from -1 to +1 where -1 means: lean left, 0: don’t lean and 1: lean to the right.
Stars
The curves look okay like that, but there are still a few things that could be more realistic. The first thing: the length of the rays compared to the whole size is a little bit too low. The other thing is something from our initial list: we wanted to have random spread stars!
final bool isStar; final double starPosition; … isStar = Random().nextDouble() > 0.3; starPosition = Random().nextDouble() + 0.5;
double height = size.height * randomSize * currentLifetime * 2; if (isStar) { Path path = Path(); paint.style = PaintingStyle.stroke; paint.strokeWidth = width * 0.25; paint.color = Color.fromRGBO(255, 255, 160, 1.0); double starSize = size.width * 2.5; double starBottom = height * starPosition; path.moveTo(0, starBottom - starSize); path.lineTo(starSize, starBottom); path.moveTo(starSize, starBottom - starSize); path.lineTo(0, starBottom); canvas.drawPath(path, paint); }
We introduce two new variables to our widget: isStar
and starPosition
. The first one is a bool
type that determines whether this spark has a star or not. The second one determines the position of the star alongside the trajectory of the spark. It ranges from 0.5 to 1.5 at it is sometimes off in the reality. The height issue is solved by multiplying the height with 2.
We are almost there! But there is one last thing we should fix. The rays are now bound to the focus of the sparkler. It does not really create the illusion of them flying outwards. We can do a simple trick to solve that problem:
LinearGradient gradient = LinearGradient( colors: [Colors.transparent, Color.fromRGBO(255, 255, 160, 1.0), Color.fromRGBO(255, 255, 160, 0.7), Color.fromRGBO(255, 180, 120, 0.7)], stops: [0, size.height * currentLifetime / 30, 0.6, 1.0], begin: Alignment.topCenter, end: Alignment.bottomCenter );
We change the gradient a little bit so that the first part of the path is always transparent. The stop value of that color grows as the time flies by. We ensure that by muliplying the size by our growing currentLifetime
value and divide everything by 30 because we only want the first bit to move. We also slightly change the other colors in the gradient.
The sparkler
Now that we have taken care of the flying sparks, we now want to implement the sparkler this thing is going to run on so we can use it e. g. as a progress indicator. The progress in this case is supposed to be indicated by the burnt part of the sparkler which is accompanied by our sparks moving from left to right.
class StickPainter extends CustomPainter { StickPainter({ @required this.progress, this.height = 4 }); final double progress; final double height; @override void paint(Canvas canvas, Size size) { double burntStickHeight = height * 0.75; double burntStickWidth = progress * size.width; _drawBurntStick(burntStickHeight, burntStickWidth, size, canvas); _drawIntactStick(burntStickWidth, size, canvas); } void _drawBurntStick(double burntStickHeight, double burntStickWidth, Size size, Canvas canvas) { double startHeat = progress - 0.1 <= 0 ? 0 : progress - 0.1; double endHeat = progress + 0.05 >= 1 ? 1 : progress + 0.05; LinearGradient gradient = LinearGradient( colors: [ Color.fromARGB(255, 80, 80, 80), Color.fromARGB(255, 100, 80, 80), Colors.red, Color.fromARGB(255, 130, 100, 100), Color.fromARGB(255, 130, 100, 100) ], stops: [0, startHeat, progress, endHeat, 1.0] ); Paint paint = Paint(); Rect rect = Rect.fromLTWH( 0, size.height / 2 - burntStickHeight / 2, size.width, burntStickHeight ); paint.shader = gradient.createShader(rect); Path path = Path() ..addRect(rect); canvas.drawPath(path, paint); } void _drawIntactStick(double burntStickWidth, Size size, Canvas canvas) { Paint paint = Paint() ..color = Color.fromARGB(255, 100, 100, 100); Path path = Path() ..addRRect( RRect.fromRectAndRadius( Rect.fromLTWH( burntStickWidth, size.height / 2 - height / 2, size.width - burntStickWidth, height ), Radius.circular(3) ) ); canvas.drawPath(path, paint); } @override bool shouldRepaint(CustomPainter oldDelegate) { return true; } }
For the stick to be drawn we create a new CustomPainter
called StickPainter
. The StickPainter
needs a height and a progress. progress
is a value from 0 to 1 which indicates how far the stick has burnt yet. We use that information to draw two things: at first the burnt area that ranges from left to the point that is indicated by progress
. Secondly we draw the intact stick on top, which ranges from the point denoted by progress
to the right. We let the height of the burnt part be 75 % of the stick height. We create the illusion of the burnt part being hotter around the area it has just burned by implementing a gradient where the start and the end of the red color depend on the progress
variable, making it start 5 % before the burning part and 10 % after that. Luckily, it’s easy to implement because the stop values of a gradient expect values from 0 to 1 as well.
Now we need to add the StickPainter
to the widget tree of the Sparkler
. We take the Stack
in which the particles are drawn and draw the StickPainter
as the first object with the particles being drawn on top.
particles.add( CustomPaint( painter: StickPainter( progress: progress ), child: Container() ) );
Final words
Using a CustomPainter
and a brief list of requirements that is backed by observations of the reality, we were able to implement a sparkler animation.
If we wanted to use this as a progress indicator, we could give the Sparkler
widget a public method to increase the progress. This would immediately affect the animation and push the focus further to the right.
以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持 码农网
猜你喜欢:本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们。
数据分析技术白皮书
伍海凤、刘鹏、杨佳静、马师慧Sara、李博、Shirley Song、Zinc、李晓艳 / 2016-8-11 / 0
关于数据分析技术白皮书(Analytics Book 中文版),主要内容围绕: 1. 分析(Analytics):网站分析 & APP分析 2. 谷歌分析工具的原理、部署与使用 3. 开源网站分析工具的原理、部署与使用 4. Log日志分析原理 5. 网站分析的维度与指标定义 6. 如何炼成为一个互联网数据分析师 请访问书的数据分析技术白皮书官网“免费”阅......一起来看看 《数据分析技术白皮书》 这本书的介绍吧!
随机密码生成器
多种字符组合密码
HEX CMYK 转换工具
HEX CMYK 互转工具