UI Challenge 4 – Electrons Counter App
Hello everyone. I have been busy and that’s why I couldn’t publish any posts.
There is an ongoing challenge in the community. It is #FlutterCounterChallenge2020.
The idea is simple: Turning the default counter app of Flutter into something fancy and exciting. I recommend you to follow the hashtag to see the amazing and stunning implementations from the community.
I decided to join the community to create something with that default boring counter app.
I came up with an idea about making something like an Atom, that Electrons revolves around. Then we can increase electrons by pressing the plus button.
BTW this is the final result. I’m going to explain how we can implement it:
As you remember, we have to split our challenge into small problems. Then we can implement them one by one.
1. Orbit
The first step is to create the orbit line. It merely is a canvas.drawOval().
Let’s try to draw it on the canvas:
class _OrbitPainter extends CustomPainter { @override void paint(Canvas canvas, Size size) { final center = Offset(size.width / 2, size.height / 2); final rect = Rect.fromCenter(center: center, width: size.width, height: size.height); canvas.drawOval( rect, Paint() ..color = Colors.white ..style = PaintingStyle.stroke ..strokeWidth = 1, ); } @override bool shouldRepaint(covariant CustomPainter oldDelegate) => true; }
2. Electron
Now we are going to place an Electron around the Orbit.
We can calculate the (x, y) position based on a degree using cos() and sin() functions.
class _OrbitPainter extends CustomPainter { final double electronDegree; _OrbitPainter(this.electronDegree); @override void paint(Canvas canvas, Size size) { final center = Offset(size.width / 2, size.height / 2); final rect = Rect.fromCenter(center: center, width: size.width, height: size.height); canvas.drawOval( rect, Paint() ..color = Colors.white ..style = PaintingStyle.stroke ..strokeWidth = 1, ); final degree = degreesToRads(electronDegree); final electronPos = center + Offset( math.cos(degree) * (size.width / 2), math.sin(degree) * (size.height / 2), ); canvas.drawCircle(electronPos, 8, Paint()..color = Colors.yellowAccent); } @override bool shouldRepaint(covariant CustomPainter oldDelegate) => true; }
3. Move Electron around the Orbit
Now we can increase the degree repeatedly to revolve the Electron around the Orbit. I used Timer.periodic() to call a function to calculate the new position of the Electron every 16ms (To have 60 frames/second). After that, we call the setState() method to reflect the new position.
(There might be other solutions to implement the animation)
class _HomePageState extends State<HomePage> { double degree = 0.0; Timer timer; @override void initState() { super.initState(); timer = new Timer.periodic( Duration(milliseconds: 16), (t) { setState(() { degree += 1; }); }, ); } Widget build(BuildContext context) { return Scaffold( backgroundColor: Color(0xFF191636), body: Center( child: CustomPaint( painter: _OrbitPainter(degree), size: Size(300, 100), ), ), ); } @override void dispose() { timer.cancel(); super.dispose(); } }
4. Assign speed to an Electron
Now it’s time to make a model for our Electron that holds the size, color, speed, and currentPosition (in degrees) of the Electron.
Then every frame, we can increase the currentPosition by the speed property.
In this way, we can assign different speeds for our Electrons.
We should iterate over all electrons every frame, increase their speed, and iterate over all of them for drawing them in our painter class.
class Electron { double speed; double size; Color color; double currentPositionDegree; Electron({ @required this.speed, @required this.size, @required this.color, @required this.currentPositionDegree, }); } class _HomePageState extends State<HomePage> { List<Electron> electrons = [ Electron(speed: 1, size: 10, color: Colors.yellowAccent, currentPositionDegree: 0), Electron(speed: 3, size: 6, color: Colors.purpleAccent, currentPositionDegree: 180), ]; Timer timer; @override void initState() { super.initState(); timer = new Timer.periodic( Duration(milliseconds: 16), (t) { setState(() { electrons.forEach((electron) { electron.currentPositionDegree += electron.speed; }); }); }, ); } Widget build(BuildContext context) { return Scaffold( backgroundColor: Color(0xFF191636), body: Center( child: CustomPaint( painter: _OrbitPainter(electrons), size: Size(300, 100), ), ), ); } } class _OrbitPainter extends CustomPainter { final List<Electron> electrons; _OrbitPainter(this.electrons); @override void paint(Canvas canvas, Size size) { final center = Offset(size.width / 2, size.height / 2); final rect = Rect.fromCenter(center: center, width: size.width, height: size.height); canvas.drawOval( rect, Paint() ..color = Colors.white ..style = PaintingStyle.stroke ..strokeWidth = 1, ); electrons.forEach((electron) { final degree = degreesToRads(electron.currentPositionDegree); final electronPos = center + Offset( math.cos(degree) * (size.width / 2), math.sin(degree) * (size.height / 2), ); canvas.drawCircle(electronPos, electron.size, Paint()..color = electron.color); }); } @override bool shouldRepaint(covariant CustomPainter oldDelegate) => true; }
5. Increase Orbits
We have an implemented Orbit, and our Electrons can revolve around it.
Now we can replicate two other Orbits with different angles using the Stack widget.
Widget build(BuildContext context) { return Scaffold( backgroundColor: Color(0xFF191636), body: Center( child: Stack( children: [ Transform.rotate( angle: degreesToRads(0), child: CustomPaint( painter: _OrbitPainter(electrons), size: Size(300, 100), ), ), Transform.rotate( angle: degreesToRads(60), child: CustomPaint( painter: _OrbitPainter(electrons), size: Size(300, 100), ), ), Transform.rotate( angle: degreesToRads(120), child: CustomPaint( painter: _OrbitPainter(electrons), size: Size(300, 100), ), ), ], ), ), ); }
6. Add Electrons using the plus button
First, let’s define a model class for Orbits to hold the electrons list.
Then we can add an Electron each time we press the plus button in a random Orbit with randomized size and color.
class Orbit { final List<Electron> electrons; final angle; Orbit({ @required this.electrons, @required this.angle, }); } class _HomePageState extends State<HomePage> { List<Orbit> orbits = [ Orbit(electrons: [], angle: 0), Orbit(electrons: [], angle: 60), Orbit(electrons: [], angle: 120), ]; Timer timer; @override void initState() { super.initState(); timer = new Timer.periodic( Duration(milliseconds: 16), (t) { setState(() { orbits.forEach((orbit) { orbit.electrons.forEach((electron) { electron.currentPositionDegree += electron.speed; }); }); }); }, ); } Widget build(BuildContext context) { return Scaffold( backgroundColor: Color(0xFF191636), body: Center( child: Stack( children: orbits .map( (orbit) => Transform.rotate( angle: degreesToRads(orbit.angle), child: CustomPaint(painter: _OrbitPainter(orbit.electrons), size: Size(300, 100)), ), ) .toList(), ), ), floatingActionButton: FloatingActionButton( onPressed: () { final randomOrbit = orbits[math.Random().nextInt(orbits.length)]; randomOrbit.electrons.add( Electron( speed: randomDoubleBetween(1, 3), size: randomDoubleBetween(4, 8), color: randomColor([ Colors.purpleAccent, Colors.yellowAccent, Colors.cyanAccent, ]), currentPositionDegree: 0, ), ); }, child: Icon(Icons.add), ), ); } }
7. Initial Animation
When we add an Electron (while they are a lot of existing electrons), we don’t understand where we placed it.
To solve this issue, let’s increase the Electron’s size at the beginning. Then we can decrease the size to the actual size gradually.
To do that, we rename the size
variable to currentSize
, And we define targetSize
variable in our Electron model.
class Electron { double speed; double currentSize; double targetSize; Color color; double currentPositionDegree; Electron({ @required this.speed, @required this.currentSize, @required this.targetSize, @required this.color, @required this.currentPositionDegree, }); }
In the beginning, we set targetSize
with a random number (this is our actual size), and for currentSize
we multiply the actual size by 10.
In each frame, we update the currentSize
to something closer to the targetSize
using lerpDouble(current, target, 0.07) function.
0.07 determines the speed of updating color.
@override void initState() { super.initState(); timer = new Timer.periodic( Duration(milliseconds: 16), (t) { setState(() { orbits.forEach((orbit) { orbit.electrons.forEach((electron) { electron.currentPositionDegree += electron.speed; electron.currentSize = lerpDouble(electron.currentSize, electron.targetSize, 0.07); }); }); }); }, ); } onPressed: () { final randomOrbit = orbits[math.Random().nextInt(orbits.length)]; final size = randomDoubleBetween(4, 8); randomOrbit.electrons.add( Electron( speed: randomDoubleBetween(1, 3), currentSize: size * 10, targetSize: size, color: randomColor([ Colors.purpleAccent, Colors.yellowAccent, Colors.cyanAccent, ]), currentPositionDegree: 0, ), ); },
I also did the same trick for the color of Electrons. In the beginning, they are transparent, I increase the opacity gradually using the Color.lerp() function.
I also tweaked some properties to have a better visual result.
You can find the source code here on Github, and also you can try the final result in the DartPad.
Update: You can check the LIVE DEMO right now on your browser!
Merry Christmas!
Great job. That was nice and clean. I enjoyed and learned a lot from it. Thanks
wow