Usually, most of my posts focus on presenting a specific topic about SwiftUI or other programming chapters. Unlike them, however, in this one I’m going to take you through the implementation of a small and funny project, yet, quite educational. And that because we are going to get bites of several different SwiftUI, and not only, concepts that could be considered quite interesting and useful, especially by new developers.
What I’m really going to demonstrate in this post, is all the steps from start to finish that build an animated checkmark shape inside a surrounding shape. Here is a first sample:
My purpose though is not to just show how to make what you see above. Once we have the initial project “up and running”, my goal is to turn the focus on how to make everything customizable, so almost all displayable elements can be parameterized. This will enable us to end up with different flavors of the same view, simply by providing different arguments at the initialization time. To give you a first taste, customizations will include the animation duration, whether there will be a scale effect or not, the colors, the outer shape, the size, the stroke style, and more. Here are some more samples with different configurations:
In a rather slightly long post, get ready to read about built-in and custom shapes, animations, and other interesting user interface concepts, as well as some important Swift techniques and topics, such as type erasure and callback closures.
Getting started with a circle
In a new SwiftUI-based app in Xcode, and in a brand new source file, which you may name AnimatedCheckmarkView if you prefer so, start by deleting the default view content. Then, add the main container view, a ZStack. Along with that, also specify an initial arbitrary frame:
1 2 3 4 5 6 7 8 9 10 |
struct AnimatedCheckmarkView: View { var body: some View { ZStack { } .frame(width: 300, height: 300) } } |
Inside the ZStack now, let’s create our first shape; the surrounding of the checkmark, which is going to be a circle initially. On that shape we’ll apply three view modifiers that you can see next:
1 2 3 4 5 6 |
Circle() .trim(from: 0.0, to: 1.0) .stroke(Color.blue, lineWidth: 24) .rotationEffect(.degrees(–90)) |
Let me explain shortly what the purpose of each modifier is:
- The
trim(from:to:)
will trim the circle, allowing to be visible only the fraction defined by the parameter values in a clockwise direction. The temporary arguments provided above will make the entire shape of the circle to be visible. We’ll change that soon. - The
stroke(_:lineWidth:)
modifier will design a stroke around the circle using the given color and line width size. That’s the only visual attribute that will make the circle visible, as it’s not going to have a background color. We’ll change both arguments later as well. - The starting point for the circle is the right side as we look at the view by default, or the X-axis in other words. However, I would like to make the circle start animating from the top side, the Y-axis. To achieve that, the
rotationEffect(_:)
modifier helps rotate the circle by 90 degrees in a counter-clockwise direction. Note that the expected value is radians. Thedegrees(_:)
method of the Angle type makes the job easy for us, as we provide just degrees and we don’t bother about radians. The minus symbol indicates that the rotation will occur in counter-clockwise direction.
Bringing circle to life
Let’s give a breath of life to the above now, so add the following properties to the view struct:
1 2 3 4 5 6 7 8 |
struct AnimatedCheckmarkView: View { var animationDuration: Double = 0.75 @State private var outerTrimEnd: CGFloat = 0 … } |
The first one is the entire duration of all animations that will be performed in the view. It will remain unchanged, so it’s a regular stored property; we don’t need to annotate it with any property wrapper. On the other hand, the value of the outerTrimEnd
is going to be changed during the animation, so it has to be marked with the @State
property wrapper in order to make this possible to happen.
Next, in the trim(from:to:)
view modifier, replace the initial to
argument with the outerTrimEnd
:
1 2 3 |
.trim(from: 0.0, to: outerTrimEnd) |
To trigger the animation, we’ll use two view modifiers on the ZStack container; onAppear(_:)
and onTapGesture(_:)
. The latter will be useful to press and see the animation working repeatedly, without having to reload the view:
1 2 3 4 5 6 7 8 9 10 11 12 |
ZStack { … } .frame(width: 300, height: 300) .onAppear() { animate() } .onTapGesture { animate() } |
The animate()
method called in both of these new view modifiers does not exist yet, and we’re going to implement it right now. This is the place where we’ll be dealing with all the animations in the view:
1 2 3 4 5 6 7 8 9 10 11 |
struct AnimatedCheckmarkView: View { … func animate() { withAnimation(.linear(duration: animationDuration)) { outerTrimEnd = 1.0 } } } |
The above is the first revision of the animate()
method, as we’re going to change it quite a few times as we’ll be moving on. In it, we use an explicit linear animation (withAnimation(_:body:)
), which you may change to a different animation if you wish so. For now, the above will take the entire available animation time.
In the animation’s body we set the desired final value of the outerTrimEnd
property. The change from the initial 0.0 to the final 1.0 will take place gradually while the animation is running, and it will result to a nice drawing of the stroke around the circle.
In fact, we are ready to see it happening at this point, so either press the Resume button in the SwiftUI canvas and then on the run button, or run in the Simulator (or even in a real device if you want so). You will see the following:
However, if you try to tap and re-run the animation, nothing will happen. Why?
The reason is that the outerTrimEnd
has already taken its final value, so there is nothing to change during the next animation that the tap will cause. To fix that, go to the onTapGesture(_:)
view modifier and assign the initial value to the outerTrimEnd
right before calling the animate()
method:
1 2 3 4 5 6 |
.onTapGesture { outerTrimEnd = 0 animate() } |
The animation will be working fine from now on every time you tap on the circle.
Creating the checkmark shape
Drawing the circle didn’t require much effort, as it is already provided as a built-in shape in SwiftUI. However, things are not the same with the checkmark. This is a custom shape, and we must programmatically describe the path that forms it.
In order to do that, we must create a new type. We will name it Checkmark and it will be a struct. There is a quite particular requirement to follow; it must be conforming to the Shape
protocol; that’s the route to implement a custom shape.
Right after the closing of the view’s struct (or in a different file if you prefer so), add the following:
1 2 3 4 5 |
struct Checkmark: Shape { } |
Every type that adopts the Shape
protocol must implement a required method named path(in:)
. This is where we define and return the path of the shape that we are making. The parameter value in this method is the imaginary frame that surrounds the shape, and it’s going to be quite useful for us here; it will supply us with the size of the shape, which we will refer to while specifying the path of the checkmark.
In order to make it easy to continue, let’s get to the implementation of the path(in:)
method:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
struct Checkmark: Shape { func path(in rect: CGRect) -> Path { let width = rect.size.width let height = rect.size.height var path = Path() path.move(to: .init(x: 0 * width, y: 0.5 * height)) path.addLine(to: .init(x: 0.4 * width, y: 1.0 * height)) path.addLine(to: .init(x: 1.0 * width, y: 0 * height)) return path } } |
For reasons of convenience only, both the width and height of the shape’s frame are assigned to two local constants. After that we initialize a new Path object and we start defining the path. In particular:
- We start from the far left side and the half of the shape’s height (
path.move(to:)
). - We draw a line up to the 40% of the shape’s width and towards bottom (
path.addLine(to:)
). - We draw another line towards top and to the far right side of the shape.
Eventually, we return the path object from the path(in:)
method. Note that the arguments passed in the methods mentioned above are CGPoint objects, and each x
and y
value is calculated based on the shape’s width and height respectively. Obviously, this is not mandatory for all shapes, it’s just needed here because the checkmark’s size won’t be constant.
The checkmark shape is ready. Although it’s simple enough, it clearly demonstrates how to create custom shapes in SwiftUI if you need them. Of course, there are more to read about shapes, so you may want to have a look here for more information.
Using the checkmark shape
Time to display the checkmark shape we just implemented, but first let’s declare the following new state property in the view struct:
1 2 3 4 5 6 7 |
struct AnimatedCheckmarkView: View { @State private var innerTrimEnd: CGFloat = 0 … } |
Similarly to the circle, we are going to show the checkmark shape animated as well, therefore we’ll use the trim(from:to:)
view modifier here too. Additionally, we will also set a stroke color, and we’ll specify an explicit width and height using the frame()
view modifier. If we avoid doing so, the checkmark will become as big as the ZStack is, and this will probably look quite awful; it would be better for the checkmark to be smaller than the circle, so it looks like it’s really surrounded by it. For that, we’ll set its size to one third of the ZStack’s size.
In the view struct, go right after the circle’s implementation and add the following:
1 2 3 4 5 6 7 8 9 10 11 12 |
ZStack { Circle() … Checkmark() .trim(from: 0, to: innerTrimEnd) .stroke(Color.blue, lineWidth: 24) .frame(width: 100, height: 100) } … |
In order to make things appear correctly, we also have to update the animate()
method and add another explicit animation for the checkmark. In this new visit that we’ll pay to animate()
, we’ll share the animation duration equally to the appearance of the two shapes we now have. But that’s going to be temporary, as we’ll add more animations in a while:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
func animate() { withAnimation(.linear(duration: animationDuration/2)) { outerTrimEnd = 1.0 } withAnimation( .linear(duration: animationDuration/2) .delay(animationDuration/2) ) { innerTrimEnd = 1.0 } } |
See that in order to show the circle animated, we change the value of the outerTrimEnd
property, and for the checkmark, we change the value of the innerTrimEnd
property respectively. Also notice that the second animation starts with a delay that is equal to the amount of time the first animation needs to finish. That means that the checkmark will start animating right after the circle has finished doing so.
Lastly, and before we go to run what we’ve done so far, let’s update the onTapGesture(_:)
view modifier as shown next, so it resets the value of the innerTrimEnd
property:
1 2 3 4 5 6 7 |
.onTapGesture { outerTrimEnd = 0 innerTrimEnd = 0 animate() } |
You can now run again; you will see the custom checkmark shape appearing animated right after the circle has animation has ended.
Animating the stroke color
Right now the stroke color in both shapes is remaining constant all the time. Let’s change that, and let’s apply another color when both shapes have appeared animated. We’ll do that using an animation as well, but there are a couple of steps before we get there.
At first, let’s declare the next state property in the view struct:
1 2 3 4 5 6 7 |
struct AnimatedCheckmarkView: View { @State private var strokeColor = Color.blue … } |
Next, we have to replace the hardcoded color in both occurrences of the stroke(_:lineWidth:)
view modifier. So, change this…
1 2 3 |
.stroke(Color.blue, lineWidth: 24) |
… to this:
1 2 3 |
.stroke(strokeColor, lineWidth: 24) |
Note that we should do that twice, as the above view modifier is used in both shapes.
Now, let’s change the stroke color using another explicit animation. In the animate()
method let’s share the animationDuration
time in three animations like so:
- The circle animation will last for the 50% of the total animation time.
- The checkmark animation will last for the 30% of the total animation time.
- The color change animation will last for the remaining 20% of the total animation time.
Keep in mind that it’s necessary to specify a delay before starting any new animation. That delay will be equal to the total animation time of the previous animations. With that said, here’s the new version of the animate()
method:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
func animate() { withAnimation(.linear(duration: 0.5 * animationDuration)) { outerTrimEnd = 1.0 } withAnimation( .linear(duration: 0.3 * animationDuration) .delay(0.5 * animationDuration) ) { innerTrimEnd = 1.0 } withAnimation( .linear(duration: 0.2 * animationDuration) .delay(0.8 * animationDuration) ) { strokeColor = .green } } |
We should not forget to update the onTapGesture(_:)
so it resets the value of the strokeColor
property:
1 2 3 4 5 6 7 8 |
.onTapGesture { outerTrimEnd = 0 innerTrimEnd = 0 strokeColor = .blue animate() } |
Time to run again, and this time let’s see the blue color changing to green right before the animation ends.
Adding a scale effect
Let’s add one more step to the animation process, and this time let’s make the set of shapes scale up a bit in the end, and then scale down again. For this purpose, add the next new state property to the view:
1 2 3 4 5 6 7 |
struct AnimatedCheckmarkView: View { @State private var scale = 1.0 … } |
Even though we are implementing scaling to make things more interesting here, it might not always be a desirable effect. So, let’s add the next stored property that will be working as a flag that indicates whether the scale animation should take place or not; by default we’ll set it to true
:
1 2 3 4 5 6 7 |
struct AnimatedCheckmarkView: View { var shouldScale = true … } |
Before we get to the animation part, we should not forget the most important step; to apply the scaleEffect(_:)
view modifier in the ZStack container view:
1 2 3 4 5 6 7 |
ZStack { … } … .scaleEffect(scale) |
Going to the animate()
method now, let’s add the following condition:
1 2 3 4 5 6 7 8 9 |
func animate() { if shouldScale { } else { } } |
Let’s focus on the else
clause first, where the scaling should not occur. In this case, we keep the animations as we have them already:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
func animate() { if shouldScale { } else { withAnimation(.linear(duration: 0.5 * animationDuration)) { outerTrimEnd = 1.0 } withAnimation( .linear(duration: 0.3 * animationDuration) .delay(0.5 * animationDuration) ) { innerTrimEnd = 1.0 } withAnimation( .linear(duration: 0.2 * animationDuration) .delay(0.8 * animationDuration) ) { strokeColor = .green } } } |
In the opposite case, there should be an additional explicit animation to scale down the ZStack container, so we will end up with four animations in total. The scale up will happen in the third animation along with the color change. We’ll split the total animation duration like so:
- The circle animation will take the 40% of the total animation time.
- The checkmark animation will take the next 30% of the total animation time.
- The color change and scale up will take together the 20% of the total animation time.
- The scale down will take the remaining 10% of the total remaining time.
Taking into account all the necessary delays among animations, let’s complete the missing if
clause in the animate()
method:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 |
func animate() { if shouldScale { withAnimation(.linear(duration: 0.4 * animationDuration)) { outerTrimEnd = 1.0 } withAnimation( .linear(duration: 0.3 * animationDuration) .delay(0.4 * animationDuration) ) { innerTrimEnd = 1.0 } withAnimation( .linear(duration: 0.2 * animationDuration) .delay(0.7 * animationDuration) ) { strokeColor = .green scale = 1.1 } withAnimation( .linear(duration: 0.1 * animationDuration) .delay(0.9 * animationDuration) ) { scale = 1 } } else { … } |
As we did for other properties, let’s reset the scale
value as well in the onTapGesture(_:)
view modifier:
1 2 3 4 5 6 7 8 9 |
.onTapGesture { outerTrimEnd = 0 innerTrimEnd = 0 strokeColor = .blue scale = 1 animate() } |
Run again, and this time you’ll see the circled checkmark scale up and then down right before the animation finishes. Once you try that out, change the shouldScale
value to false
and run again; the scale effect won’t be there.
Making the view customizable
There are several values currently that are hardcoded and specific to the implementation we are performing. With a few moves, however, we may totally transform what we’ve done so far into a flexible and customizable view. In order to achieve that, we’ll use stored properties to replace each hardcoded value that we want to make dynamic, but we’ll set initial (default) values to these properties so the view can be used without any additional configuration. The goal is to provide custom values if needed as arguments at the initialization of the view, for any of those new stored properties that follow next, as well for the couple of them that we have already declared previously.
The container view and checkmark size
Let’s focus on the size the container view first, where the ZStack size has been set to (300, 300) for now. Most probably this size won’t be the most desirable one in actual projects, so let’s make it possible to be provided when this view will be initialized.
Initially, declare the following stored property in the view struct:
1 2 3 4 5 6 7 |
struct AnimatedCheckmarkView: View { var size: CGSize = .init(width: 300, height: 300) … } |
You may change the initial width and height values to anything else you consider to be more suitable than the above. After that, update the frame view modifier in the ZStack container, so it uses the above size from now on:
1 2 3 4 5 6 7 |
ZStack { … } .frame(width: size.width, height: size.height) … // other view modifiers |
The AnimatedCheckmarkView
can now be initialized with any size we want it to have. But that’s not all yet; the size of the checkmark shape has also a hardcoded size, which right now is the one third of the container’s size.
This ratio between the two sizes might be good enough in many cases, but in some others it might not. It would be really nice if we were also able to provide a custom ratio on demand; so, let’s do that.
In the view struct add the next stored property:
1 2 3 4 5 6 7 |
struct AnimatedCheckmarkView: View { var innerShapeSizeRatio: CGFloat = 1/3 … } |
Now, update the frame of the checkmark shape to this:
1 2 3 4 5 |
Checkmark() … .frame(width: size.width * innerShapeSizeRatio, height: size.height * innerShapeSizeRatio) |
The size of both the container view and the inner custom shape can be specified at the initialization time of the view from now on if necessary. The following code demonstrates that:
1 2 3 4 |
AnimatedCheckmarkView(size: .init(width: 50, height: 50), innerShapeSizeRatio: 1/2) |
The start and end colors
Another set of visual elements that we can make dynamic regards the colors of the shapes. Right now, the initial color is blue, which changes to green in the end. Let’s make both of these parameterized, so let’s add the next two stored properties:
1 2 3 4 5 6 7 8 |
struct AnimatedCheckmarkView: View { var fromColor: Color = .blue var toColor: Color = .green … } |
Next, go to the onAppear(_:)
view modifier, and make the strokeColor
state property get its initial value from the fromColor
:
1 2 3 4 5 6 |
.onAppear() { strokeColor = fromColor animate() } |
Do the same in the onTapGesture(_:)
modifier as well:
1 2 3 4 5 6 7 |
.onTapGesture { strokeColor = fromColor … } |
Next, go to the animate()
method, and replace the two occurrences of strokeColor = .green
with this:
1 2 3 |
strokeColor = toColor |
Let’s give it a try now, providing the desired colors as arguments to the view:
1 2 3 |
AnimatedCheckmarkView(fromColor: .red, toColor: .mint) |
The stroke style
The next thing we’re going to make customizable is the stroke style. At the time being the stroke has a fixed line width of 24pts, and that is a quite large value when the view has a small size. Also, stroke is too sharp, and there is no way to change that right now.
Similarly to all previous cases, let’s start by declaring the next stored property in the view:
1 2 3 4 5 6 7 |
struct AnimatedCheckmarkView: View { var strokeStyle: StrokeStyle = .init(lineWidth: 24, lineCap: .round, lineJoin: .round) … } |
See that we are using a different initializer for the stroke style here; besides the line width that can now be changed, we also make the stroke rounded by default, supplying the respective round
value to both the lineCap
and lineJoin
parameters. The StrokeStyle
type can be initialized with more arguments, but all of them, including those shown above, are optional. Read more about that here. I invite you to play with the various arguments that the StrokeStyle
initializer can get, and see all possible configuration that you can do.
A stroke color is applied to both the circle and the checkmark shape. To update it with the strokeStyle
we just declared, first find the two occurrences of this:
1 2 3 |
.stroke(strokeColor, lineWidth: 24) |
Then replace it with the following:
1 2 3 |
.stroke(strokeColor, style: strokeStyle) |
Here’s the default stroke style for our shape now:
Conditional animation on tap
The animation in our view is triggered in two different ways at the moment; when the view appears, and when we tap on it. However, the latter is something that we will rarely want to happen, if not ever, so we should probably get rid of it. But instead of disabling at all, why not turning it on and off conditionally?
To manage that, add the next stored property to the view:
1 2 3 4 5 6 7 |
struct AnimatedCheckmarkView: View { var animateOnTap = true … } |
Next, go to the onTapGesture(_:)
view modifier, and move all the code currently exists there in the following if
statement:
1 2 3 4 5 6 7 8 9 10 11 |
.onTapGesture { if animateOnTap { outerTrimEnd = 0 innerTrimEnd = 0 strokeColor = fromColor scale = 1 animate() } } |
By setting false
to animateOnTap
, the animation won’t be repeated when we’ll be tapping on any of the shapes.
Surrounding checkmark with different shapes
Even though we started with the circle as the shape that surrounds the checkmark, it would be really nice if we could go one step further and allow to provide a different shape, even a custom one, as an alternative. But how easily can we do that?
Let’s begin with the obvious; to declare a stored property like the next one in the view struct:
1 2 3 4 5 6 7 |
struct AnimatedCheckmarkView: View { var outerShape: Shape? … } |
The above would be perfect if it didn’t cause Xcode to show the following error:
“Protocol ‘Shape’ can only be used as a generic constraint because it has Self or associated type requirements”
In two words, we cannot use the Shape
protocol as a data type; we need a concrete type instead (class or struct that adopts the protocol). In order to create such a concrete type for a protocol, we will put in motion a technique that’s called type erasure.
The first move is to declare a new struct that will be conforming to Shape
protocol, exactly as we did for the custom checkmark shape. Let’s call it AnyShape
:
1 2 3 4 5 |
struct AnyShape: Shape { } |
As we have already seen, any type that adopts Shape
must implement the path(in:)
method. But before we get to that, let’s fill in some other gaps first. The purpose of this type is to get initialized with a shape (for example, Circle), to store the path of the given shape, and then to return that path through the path(in:)
method when necessary. We need two things for this to work; the first is the following property:
1 2 3 4 5 |
struct AnyShape: Shape { private var path: (CGRect) -> Path } |
See that the type of the property is not Path
, as you might have anticipated, but a closure that accepts a CGRect value as argument, returning a Path
object. But why a closure and not a Path
?
The answer comes in the implementation of the following initializer which is a bit particular. Starting with its signature, it will be accepting a shape object as argument. Given though that the Shape
protocol cannot be used as a data type, we will make the initializer a generic one, and the generic type will be satisfying a condition; it will be conforming to the Shape
protocol. The data type of the initializer’s parameter value will be of that generic type.
1 2 3 4 5 6 7 8 9 |
struct AnyShape: Shape { private var path: (CGRect) -> Path init<S>(_ shape: S) where S: Shape { } } |
Inside the initializer we’ll perform one thing only; we’ll assign the path(in:)
method of the given shape to the path
property declared above, so we can call it when we need it:
1 2 3 4 5 6 7 8 9 |
struct AnyShape: Shape { private var path: (CGRect) -> Path init<S>(_ shape: S) where S: Shape { path = shape.path(in:) } } |
We can now implement the required path(in:)
method in the AnyShape
type. It must be returning a Path
object, so we’ll just call the path
closure, supplying it with the rect
parameter value:
1 2 3 4 5 6 7 8 9 |
struct AnyShape: Shape { … func path(in rect: CGRect) -> Path { return path(rect) } } |
The concrete type we need is ready, so let’s go back to the view struct to declare the following stored property, specifying the circle as the default shape:
1 2 3 4 5 6 7 |
struct AnimatedCheckmarkView: View { var outerShape: AnyShape = .init(Circle()) … } |
Then, inside the ZStack body we can replace the Circle()
instance with the outerShape
:
1 2 3 4 5 6 7 8 9 |
ZStack { outerShape … // view modifiers Checkmark() … } |
We can now initialize our view with a different shape and surround the checkmark with something else besides the circle. For instance, here is an instance using a rounded rectangle:
1 2 3 |
AnimatedCheckmarkView(outerShape: AnyShape(RoundedRectangle(cornerRadius: 12))) |
Reacting on animation end
There is one last thing that I would like to add to the simple project we are building here. That is a callback closure, or in other words a closure that will be called when all animations have finished. It might be often useful to know when the animation is over when using the AnimatedCheckmarkView
, so we can trigger additional actions after that.
For one last time let’s add a new stored property in the view struct:
1 2 3 4 5 6 7 |
struct AnimatedCheckmarkView: View { var onAnimationFinish: (() -> Void)? … } |
The type of the onAnimationFinish
is a closure, and to be precise, an optional one. It won’t be always necessary to be aware of the animation’s end event, so an initial nil value is a good choice here.
There is a specific place to call the above closure; the animate()
method. Right after the if-else
conditional statement that currently exists there, we’ll add this:
1 2 3 4 5 6 7 8 9 |
func animate() { … DispatchQueue.main.asyncAfter(deadline: .now() + animationDuration) { onAnimationFinish?() } } |
Note that it’s important to call onAnimationFinish
with a delay that equals to the animation duration. If we don’t do that, the closure will be called before the animation has ended.
We can now provide a closure when initializing an AnimatedCheckmarkView
instance as argument, and act as necessary in its body:
1 2 3 4 5 6 |
AnimatedCheckmarkView(onAnimationFinish: { print(“Animation has finished!”) // Perform additional actions… }) |
Summary
In this (admittedly long) post we went through the implementation of a project that embeds various concepts and techniques in SwiftUI and Swift. Regardless of whether the final result is impressive or not, my goal has been to demonstrate the process of building a SwiftUI view that not only will be performing something meaningful, but it will be capable of getting customized at initialization time. Now that you have seen how to achieve that, feel free to change what we did here as you see that fits your needs, and of course, proceed in making your own customizable views. In any case, I hope that you’ve found some interesting and educational content that will be proved helpful in your programming journey.
Thank you for reading, take care! ????
???? Download the final project