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
1class Particle extends StatefulWidget {
2 Particle({
3 this.duration = const Duration(milliseconds: 200)
4 });
5
6 final Duration duration;
7 @override
8 State<StatefulWidget> createState() {
9 return _ParticleState();
10 }
11}
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.
1class _ParticleState extends State<Particle> with SingleTickerProviderStateMixin {
2 AnimationController _controller;
3
4 @override
5 void initState() {
6 super.initState();
7
8 this._controller = new AnimationController(
9 vsync: this,
10 duration: widget.duration
11 );
12
13 _startNextAnimation();
14
15 _controller.addStatusListener((status) {
16 if (status == AnimationStatus.completed) {
17 _startNextAnimation();
18 }
19 });
20
21 this._controller.addListener(() {
22 setState(() {});
23 });
24 }
25
26 void _startNextAnimation([Duration after]) {
27 Future.delayed(Duration(seconds: 1), () {
28 _controller.forward(from: 0.0);
29 });
30 }
31
32 @override
33 dispose() {
34 _controller.dispose();
35 super.dispose();
36 }
37
38 @override
39 Widget build(BuildContext context) {
40 return SizedBox(
41 width: 1,
42 height: 80,
43 child: CustomPaint(
44 painter: ParticlePainter(
45 currentLifetime: _controller.value,
46 )
47 )
48 );
49 }
50}
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.
1class ParticlePainter extends CustomPainter {
2 ParticlePainter({
3 @required this.currentLifetime,
4 });
5
6 final double currentLifetime;
7
8 @override
9 void paint(Canvas canvas, Size size) {
10 Paint paint = Paint();
11
12 Rect rect = Rect.fromLTWH(
13 0,
14 0,
15 size.width,
16 -currentLifetime * size.height
17 );
18
19 LinearGradient gradient = LinearGradient(
20 colors: [Colors.yellowAccent, Colors.orangeAccent, Color.fromARGB(30, 255, 255, 255), Color.fromARGB(30, 255, 255, 255)],
21 stops: [0, 0.3, 0.9, 1.0],
22 begin: Alignment.topCenter,
23 end: Alignment.bottomCenter
24 );
25 paint.shader = gradient.createShader(rect);
26
27 Path path = Path()
28 ..addRect(
29 rect
30 );
31 canvas.drawPath(path, paint);
32 }
33
34 @override
35 bool shouldRepaint(CustomPainter oldDelegate) {
36 return true;
37 }
38}
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
1class Sparkler extends StatefulWidget {
2 @override
3 _SparklerState createState() => _SparklerState();
4}
5
6class _SparklerState extends State<Sparkler> {
7 final double width = 300;
8
9 @override
10 void initState() {
11 super.initState();
12 }
13
14 @override
15 Widget build(BuildContext context) {
16 return Center(
17 child: Container(
18 width: width,
19 child: SizedBox(
20 height: 100,
21 child: Stack(
22 children: getParticles(),
23 )
24 ),
25 )
26 );
27 }
28
29 List<Widget> getParticles() {
30 List<Widget> particles = List();
31
32 int maxParticles = 160;
33 for (var i = 1; i <= maxParticles; i++) {
34 particles.add(
35 Padding(
36 padding: EdgeInsets.only(left: 0.5 * width, top: 20),
37 child: Transform.rotate(
38 angle: maxParticles / i * pi,
39 child: Padding(
40 padding: EdgeInsets.only(top: 40),
41 child: Particle()
42 )
43 )
44 )
45 );
46 }
47
48 return particles;
49 }
50}
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
1class _ParticleState extends State<Particle> with SingleTickerProviderStateMixin {
2 AnimationController _controller;
3 double randomSpawnDelay;
4 double randomSize;
5 bool visible = true;
6
7 @override
8 void initState() {
9 super.initState();
10 randomSpawnDelay = Random().nextDouble();
11 randomSize = Random().nextDouble();
12
13 this._controller = new AnimationController(
14 vsync: this,
15 duration: widget.duration,
16 );
17
18 _startNextAnimation(
19 Duration(milliseconds: (Random().nextDouble() * 1000).toInt())
20 );
21
22 _controller.addStatusListener((status) {
23 if (status == AnimationStatus.completed) {
24 visible = false;
25 _startNextAnimation();
26 }
27 });
28
29 this._controller.addListener(() {
30 setState(() {});
31 });
32 }
33
34 void _startNextAnimation([Duration after]) {
35 if (after == null) {
36 int millis = (randomSpawnDelay * 300).toInt();
37 after = Duration(milliseconds: millis);
38 }
39
40 Future.delayed(after, () {
41 setState(() {
42 randomSpawnDelay = Random().nextDouble();
43 randomSize = Random().nextDouble();
44 visible = true;
45 });
46
47 _controller.forward(from: 0.0);
48 });
49 }
50
51 @override
52 dispose() {
53 _controller.dispose();
54 super.dispose();
55 }
56
57 @override
58 Widget build(BuildContext context) {
59 return SizedBox(
60 width: 1.5,
61 height: 100,
62 child: Opacity(
63 opacity: visible ? 1.0 : 0.0,
64 child: CustomPaint(
65 painter: ParticlePainter(
66 currentLifetime: _controller.value,
67 randomSize: randomSize,
68 )
69 )
70 )
71 );
72 }
73}
74class ParticlePainter extends CustomPainter {
75 ParticlePainter({
76 @required this.currentLifetime,
77 @required this.randomSize,
78 });
79
80 final double currentLifetime;
81 final double randomSize;
82
83 @override
84 void paint(Canvas canvas, Size size) {
85 Paint paint = Paint();
86 double width = size.width;
87
88 Rect rect = Rect.fromLTWH(
89 0,
90 0,
91 width,
92 -currentLifetime * size.height * randomSize
93 );
94
95 LinearGradient gradient = LinearGradient(
96 colors: [Colors.yellowAccent, Colors.orangeAccent, Color.fromARGB(30, 255, 255, 255), Color.fromARGB(30, 255, 255, 255)],
97 stops: [0, 0.3, 0.9, 1.0],
98 begin: Alignment.topCenter,
99 end: Alignment.bottomCenter
100 );
101 paint.shader = gradient.createShader(rect);
102 Path path = Path()
103 ..addRect(
104 rect
105 );
106 canvas.drawPath(path, paint);
107 }
108
109 @override
110 bool shouldRepaint(CustomPainter oldDelegate) {
111 return true;
112 }
113}
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.
1class _ParticleState extends State<Particle> with SingleTickerProviderStateMixin {
2 …
3 double arcImpact;
4 …
5
6 @override
7 void initState() {
8 super.initState();
9 …
10 arcImpact = Random().nextDouble() * 2 - 1;
11 …
12
13 void _startNextAnimation([Duration after]) {
14 …
15 Future.delayed(after, () {
16 setState(() {
17 …
18 arcImpact = Random().nextDouble() * 2 - 1;
19 …
20 });
21 …
22 });
23 }
24
25 …
26
27 @override
28 Widget build(BuildContext context) {
29 return SizedBox(
30 …
31 child: CustomPaint(
32 painter: ParticlePainter(
33 …
34 arcImpact: arcImpact
35 )
36 )
37 )
38 );
39 }
40}
1class ParticlePainter extends CustomPainter {
2 ParticlePainter({
3 @required this.currentLifetime,
4 @required this.randomSize,
5 @required this.arcImpact
6 });
7
8 final double currentLifetime;
9 final double randomSize;
10 final double arcImpact;
11
12 @override
13 void paint(Canvas canvas, Size size) {
14 Paint paint = Paint();
15 double width = size.width;
16 double height = size.height * randomSize * currentLifetime;
17
18 Rect rect = Rect.fromLTWH(
19 0,
20 0,
21 width,
22 height
23 );
24
25 Path path = Path();
26 LinearGradient gradient = LinearGradient(
27 colors: [Color.fromRGBO(255, 255, 160, 1.0), Color.fromRGBO(255, 255, 160, 0.7), Color.fromRGBO(255, 180, 120, 0.7)],
28 stops: [0, 0.6, 1.0],
29 begin: Alignment.topCenter,
30 end: Alignment.bottomCenter
31 );
32 paint.shader = gradient.createShader(rect);
33 paint.style = PaintingStyle.stroke;
34 paint.strokeWidth = width;
35 path.cubicTo(0, 0, width * 4 * arcImpact, height * 0.5, width, height);
36
37 canvas.drawPath(path, paint);
38
39 }
40
41 @override
42 bool shouldRepaint(CustomPainter oldDelegate) {
43 return true;
44 }
45}
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!
1 final bool isStar;
2 final double starPosition;
3 …
4 isStar = Random().nextDouble() > 0.3;
5 starPosition = Random().nextDouble() + 0.5;
1double height = size.height * randomSize * currentLifetime * 2;
2
3 if (isStar) {
4 Path path = Path();
5 paint.style = PaintingStyle.stroke;
6 paint.strokeWidth = width * 0.25;
7 paint.color = Color.fromRGBO(255, 255, 160, 1.0);
8
9 double starSize = size.width * 2.5;
10 double starBottom = height * starPosition;
11
12 path.moveTo(0, starBottom - starSize);
13 path.lineTo(starSize, starBottom);
14 path.moveTo(starSize, starBottom - starSize);
15 path.lineTo(0, starBottom);
16
17 canvas.drawPath(path, paint);
18 }
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:
1LinearGradient gradient = LinearGradient(
2 colors: [Colors.transparent, Color.fromRGBO(255, 255, 160, 1.0), Color.fromRGBO(255, 255, 160, 0.7), Color.fromRGBO(255, 180, 120, 0.7)],
3 stops: [0, size.height * currentLifetime / 30, 0.6, 1.0],
4 begin: Alignment.topCenter,
5 end: Alignment.bottomCenter
6);
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.
1class StickPainter extends CustomPainter {
2 StickPainter({
3 @required this.progress,
4 this.height = 4
5 });
6
7 final double progress;
8 final double height;
9
10 @override
11 void paint(Canvas canvas, Size size) {
12
13 double burntStickHeight = height * 0.75;
14 double burntStickWidth = progress * size.width;
15
16 _drawBurntStick(burntStickHeight, burntStickWidth, size, canvas);
17 _drawIntactStick(burntStickWidth, size, canvas);
18 }
19
20 void _drawBurntStick(double burntStickHeight, double burntStickWidth, Size size, Canvas canvas) {
21 double startHeat = progress - 0.1 <= 0 ? 0 : progress - 0.1;
22 double endHeat = progress + 0.05 >= 1 ? 1 : progress + 0.05;
23
24 LinearGradient gradient = LinearGradient(
25 colors: [
26 Color.fromARGB(255, 80, 80, 80),
27 Color.fromARGB(255, 100, 80, 80),
28 Colors.red, Color.fromARGB(255, 130, 100, 100),
29 Color.fromARGB(255, 130, 100, 100)
30 ],
31 stops: [0, startHeat, progress, endHeat, 1.0]
32 );
33
34 Paint paint = Paint();
35 Rect rect = Rect.fromLTWH(
36 0,
37 size.height / 2 - burntStickHeight / 2,
38 size.width,
39 burntStickHeight
40 );
41 paint.shader = gradient.createShader(rect);
42
43 Path path = Path()
44 ..addRect(rect);
45
46 canvas.drawPath(path, paint);
47 }
48
49 void _drawIntactStick(double burntStickWidth, Size size, Canvas canvas) {
50 Paint paint = Paint()
51 ..color = Color.fromARGB(255, 100, 100, 100);
52
53 Path path = Path()
54 ..addRRect(
55 RRect.fromRectAndRadius(
56 Rect.fromLTWH(
57 burntStickWidth,
58 size.height / 2 - height / 2,
59 size.width - burntStickWidth,
60 height
61 ),
62 Radius.circular(3)
63 )
64 );
65
66 canvas.drawPath(path, paint);
67 }
68 @override
69 bool shouldRepaint(CustomPainter oldDelegate) {
70 return true;
71 }
72}
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.
1particles.add(
2 CustomPaint(
3 painter: StickPainter(
4 progress: progress
5 ),
6 child: Container()
7 )
8 );
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.
Comment this 🤌