Welcome to one of the Dashtronaut app tutorials, in this one, we will create and animate the stars you see in the background of the game using Flutter’s CustomPainter. Here’s the final result:
TL, DR;
You can check out the code for this feature alone in this DartPad. We will talk in later tutorials about how it was integrated and used in the puzzle’s source code.
Let’s start some planning by considering the task and figuring out what we need to implement.
Okay, enough planning, time for some code!
In these cases I like to create helper classes that I can dump all the logic in. So we’ll create a StarsLayout class and give it the BuildContext as a required field. After all, we talked a lot in the planning about the “screen size”, so we will definitely need the context. So here’s the class with what we need for the painting part
class StarsLayout {
final BuildContext context;
StarsLayout(this.context);
Size get screenSize => MediaQuery.of(context).size;
// Stars count changes based on screen width
int get totalStarsCount {
if (screenSize.width > 576) {
return 600;
} else if (screenSize.width > 1200) {
return 800;
} else if (screenSize.width > 1440) {
return 1000;
} else {
return 400;
}
}
final Random random = Random();
int get starsMaxXOffset {
// Sometimes when the context is not accessible yet,
// the screen size might have a negative or 0 value,
// which throws an error if given to the Random.nextInt()'s max param
return screenSize.width.ceil() <= 0 ? 1 : screenSize.width.ceil();
}
int get starsMaxYOffset {
// Sometimes when the context is not accessible yet,
// the screen size might have a negative or 0 value,
// which throws an error if given to the Random.nextInt()'s max param
return screenSize.height.ceil() <= 0 ? 1 : screenSize.height.ceil();
}
List<int> _getRandomStarsOffsetsList(int max) {
return List.generate(totalStarsCount, (i) => random.nextInt(max));
}
List<int> get randomStarXOffsets {
return _getRandomStarsOffsetsList(starsMaxXOffset);
}
List<int> get randomStarYOffsets {
return _getRandomStarsOffsetsList(starsMaxYOffset);
}
List<double> get randomStarSizes {
return List.generate(totalStarsCount, (i) => random.nextDouble() + 0.7);
}
}
The code breakdown:
Now we have the necessary logic to create our CustomPainter like so:
class StarsPainter extends CustomPainter {
final List<int> xOffsets;
final List<int> yOffsets;
final List<double> sizes;
final int totalStarsCount;
StarsPainter({
required this.xOffsets,
required this.yOffsets,
required this.sizes,
required this.totalStarsCount,
});
final Paint _paint = Paint();
@override
void paint(Canvas canvas, Size size) {
_paint.color = Colors.white.withOpacity(0.5);
for (int i = 0; i < totalStarsCount; i++) {
canvas.drawCircle(
Offset(xOffsets[i].toDouble(), yOffsets[i].toDouble()),
sizes[i],
_paint,
);
}
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
}
We simply initialized a Paint class and gave it a temporary white color (this will be modified later with the animation). And with a for loop matching the totalStarsCount, we drew circles with the canvas.drawCircle() method and gave them their corresponding x and y offsets as well as sizes. These will be the lists we created in the StarsLayout class.
To be able to use these lists easily, we will add a getPainter() method to our StarsLayout class like so
class StarsLayout {
//...
CustomPainter getPainter() {
return StaticStarsPainter(
xOffsets: randomStarXOffsets,
yOffsets: randomStarYOffsets,
sizes: randomStarSizes,
totalStarsCount: totalStarsCount,
);
}
}
Now we can create our Stars widget like so
class Stars extends StatelessWidget {
@override
Widget build(BuildContext context) {
StarsLayout _starsLayer = StarsLayout(context);
return CustomPaint(
painter: _starsLayer.getPainter(),
);
}
}
Make sure that when you use the Stars widget, you put it in a Container with width and height values equal to the screen’s width and height. Here we’re adding a gradient as well for a better style:
class StarsContainer extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Container(
width: MediaQuery.of(context).size.width,
height: MediaQuery.of(context).size.height,
decoration: const BoxDecoration(
gradient: RadialGradient(
colors: [Color(0xff3f218d), Color(0xff180026)],
stops: [0, 1],
radius: 1.1,
center: Alignment.centerLeft,
),
),
child: const Stars(),
);
}
}
At this point the output should look like this:
If you remember from the planning, we decided that we needed 2 lists of indices of stars, one will be for the stars with a fade in animation, and the other for the stars with a fade out animation. We can define these lists and add them to our StarsLayout class as follows:
class StarsLayout {
//...
List<int> get fadeOutStarIndices {
List<int> _indices = [];
for (int i = 0; i <= totalStarsCount; i++) {
if (i % 5 == 0) {
_indices.add(i);
}
}
return _indices;
}
List<int> get fadeInStarIndices {
List<int> _indices = [];
for (int i = 0; i <= totalStarsCount; i++) {
if (i % 3 == 0) {
_indices.add(i);
}
}
return _indices;
}
//...
}
So now basically every (5 * n)th star is a fade-out star, and every (3 * n)rd star is a fade-in star.
We can now add those two lists to the CustomPainter class’s fields and then to the StarsLayout’s getPainter() method.
To create the animation, we need to convert the Stars widget to a StatefulWidget and add the animation code to it as follows:
class Stars extends StatefulWidget {
const Stars({Key? key}) : super(key: key);
@override
_StarsState createState() => _StarsState();
}
class _StarsState extends State<Stars> with SingleTickerProviderStateMixin {
late final AnimationController _animationController;
late final Animation<double> _opacity;
@override
void initState() {
_animationController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1000),
);
_animationController.repeat(reverse: true);
_opacity = Tween<double>(begin: 0.8, end: 0.1).animate(
CurvedAnimation(
parent: _animationController,
curve: Curves.easeInOut,
),
);
super.initState();
}
@override
void dispose() {
_animationController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
StarsLayout _starsLayer = StarsLayout(context);
return CustomPaint(
painter: _starsLayer.getPainter(
opacity: _opacity,
),
);
}
}
The code breakdown:
First thing you need to do when you want to create your own animation, is define an AnimationController (line 14). Here we defined our AnimationController, and by adding the SingleTickerProviderStateMixin (line 8) we were able to give `this` as the required `vsync` value of the controller. You don’t need to worry about why we did this, basically the vsync keeps track of the screen so that the animation doesn't run when it's not visible.
We also added a duration to our AnimationController (line 16), and made it repeat with reverse set to true (line 18), so now instead of animating the opacity for example from 0 to 1, then from 0 to 1, …etc, it will animate it 0 => 1 => 0 => 1, …etc.
Next thing we did is create our custom opacity animation using a Tween(line 20), which basically gives us the ability to animate between 2 values of any type, in our case, double. We considered only the fade-out animation now by giving the tween a begin value of 0.8 and end value of 0.1. You’ll see later how we create the fade-in.
We linked our animation with the AnimationController by calling the animate() method on the Tween and giving it a CurvedAnimation with an easeInOut curve for a smooth nonlinear animation.
Of course, with an AnimationController, we should make sure to dispose() it in the widget’s dispose lifecycle method (line 32).
Then we simply passed our _opacity animation into our CustomPainter by passing it to the StarsLayout’s getPainter() method (line 42). That method now should be like so:
class StarsLayout {
//...
CustomPainter getPainter({required Animation<double> opacity}) {
return StarsPainter(
xOffsets: randomStarXOffsets,
yOffsets: randomStarYOffsets,
sizes: randomStarSizes,
fadeOutStarIndices: fadeOutStarIndices,
fadeInStarIndices: fadeInStarIndices,
totalStarsCount: totalStarsCount,
opacityAnimation: opacity,
);
}
}
Now let’s see how we used this animation in our CustomPainter:
class StarsPainter extends CustomPainter {
final List<int> xOffsets;
final List<int> yOffsets;
final List<int> fadeOutStarIndices;
final List<int> fadeInStarIndices;
final List<double> sizes;
final Animation<double> opacityAnimation;
final int totalStarsCount;
StarsPainter({
required this.xOffsets,
required this.yOffsets,
required this.fadeOutStarIndices,
required this.fadeInStarIndices,
required this.sizes,
required this.opacityAnimation,
required this.totalStarsCount,
}) : super(repaint: opacityAnimation);
final Paint _paint = Paint();
double _getStarOpacity(int i) {
if (fadeOutStarIndices.contains(i)) {
return opacityAnimation.value;
} else if (fadeInStarIndices.contains(i)) {
return 1 - opacityAnimation.value;
} else {
return 0.5;
}
}
@override
void paint(Canvas canvas, Size size) {
for (int i = 0; i < totalStarsCount; i++) {
_paint.color = Colors.white.withOpacity(_getStarOpacity(i));
canvas.drawCircle(
Offset(xOffsets[i].toDouble(), yOffsets[i].toDouble()),
sizes[i],
_paint,
);
}
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
}
The code breakdown:
Conveniently, the CustomPainter class takes a Listenable field `repaint` that triggers repainting the CustomPainter whenever that listenable receives a new value. This works great for us since our opacity Animation class extends a Listenable. And that’s why we passed our opacity animation to our StarsPainter and then to the super class (line 18).
Notice that our shouldRepaint method still returns false (line 45), because it doesn't need to return true when we’ve already assigned a Listenable to the super class’s repaint parameter.
To make the opacity animation actually work, we created the _getStarOpacity function that takes an index, if that index exists in the fadeOutStarsIndices list, then it should return the opacityAnimation.value as an opacity (line 24). And if that index exists in the fadeInStarsIndices, then it should return (1 - opacityAnimation.value) as an opacity (line 26). This allows the animation to animate between 0.2 and 0.9 (a fade-in) instead of between 0.8 and 0.1 (the original fade-out). If that index doesn't belong to either lists, return a fixed opacity.
Now we simply call this function when we assign color to our Paint before drawing each circle (line 35)
And that’s it! You now have cool animated stars!
Checkout the DartPad and see the code above in action!
Stay tuned for more tutorials for features in the app! Me and Dashtronaut are working on them right now 💙