Custom Progress Indicator in Jetpack Compose
Make your app stand out with custom widgets
In this short article we will learn how to create a custom progress indicator using the Layout composable and the animation tools provided by Jetpack Compose. The final result we are after is shown here:
The basic component, the Dot
We’ll start by defining a composable that represents our basic unit, the Dot. There are different ways we could go about drawing a circle, we could use Canvas, or we could use a Box with a clip modifier with a circle shape. The end result is the same, so it does not matter much how we choose to draw the circle, and in this case we’ll use the clip modifier:
This is as simple as it gets, we just have a Box with a circle shape and a background modifier, which gives us this:
Creating the main container
Next we are going to create the container to host the dots. To position the dots we will need to know the size of the container, so that we can then offset the dots accordingly. Like before, there are a few options available to us, some of the options are creating a composable using Layout — this is the equivalent of a ViewGroup in the view system, and the most flexible solution; using a BoxWithContraints — this is a quick way to get the size of the container, but BoxWithConstraints uses SubcomposeLayout which comes with some performance penalty; or using the modifier OnGloballyPositioed — this takes a lambda that gets called with the size and position of the container, but it introduces a 1 frame delay as the values are not know initially and we have to wait for the next frame to get the results.
For our solution we are going to use Layout which gives us the most flexibility and does not incur any penalty. The Layout composable takes 3 arguments:
The content, a composable lambda.
An optional Modifier.
A MeasurePolicy that dictates how to measure and position the content.
Placing the dots
We will start by placing the dots within our progress composable. We will use 5 dots for our progress indicator, so our content will be a set of 5 dots. We can set the content to the Layout composable like this:
Let’s go over this:
We create a lambda for our content.
In here we loop NumDots times (5 in our case).
At each loop we instantiate a Dot composable with a fixed size, 24dp.
Now that we have the content for the composable, let’s place the dots. When we use a Layout composable, there are 2 steps we need to perform, first we need to measure the content, which we receive in the MeasurePolicy lambda as measurables, and once we have those measured, we need to place them. We will initially place the dots, all of them, at the center of the container and later we will add the animation. Let’s see how we do that:
Let’s analyze this:
We define a scale factor for the dots, we want to draw them at different sizes — this factor is the size of the smallest dot relative to the largest.
We calculate the step by which we want to alter the size of each dot.
Here we calculate the size of the dot we are drawing — the first dot is a full scale (1f), while the following ones get increasingly small.
The first step to render the Layout content is to measure the measurables with the constraints for the Layout — this gets us a list of placeables.
Next we calculate the middle point of the container.
Finally we display the dots, looping over the placeables and placing them.
This gives us this result, with all the dots bunched together:
Adding the animation
Next we want to animate the dots. As we are using a Layout composable where we specify the x and y coordinates of each child, and as we want to animate the dots over a circle in clockwise direction, we will use a bit of trigonometry to get the coordinates for each dot.
As we are going around a circle, our animated value will be in the range 0 to 2π, which is a full circle in radians. Once we have that, the x coordinate will simply by the sin of the animated value times the radius, and the y coordinate will be the cos of the animated value times the radius.
For the animation, we will use the animate function; as we want to animate a set of dots which move in a staggered way, we will create one animate object for each dot and give each a different start delays so that they move at different times. Let’s see the animation code:
We loop over the number of dots we want to display.
For each dot, we create a remembered value that holds the animation value.
We use LaunchedEffect with a key of Unit to start the animation, as we want a fire-and-forget style animation that runs all the time.
Our animation is based on the animate function which takes a target value and animates towards it.
The initial value for our animation is 0.
And the final value is 2π — so we cover the whole circle.
For this animation we define an animationSpec using infiniteRepeatable so that it loops endlessly, with a duration of 2s and, when it reaches the end, it simply restarts. For each dot we want to animate we add a start delay, increasing with each subsequent dot.
Now that we have the animation, running from 0 to 2π it’s time to position the dots. Let’s see how we need to update the placement code to do that:
We get the animated value for the dot we are placing.
We calculate the x coordinate based on the position along the circle determined by the animated value — this uses the sin function.
We calculate the y coordinate the same way, but using the cos function instead.
With this, we get this result:
Improving the animation
The animation above looks good, but we can make it more engaging by having the dots move at different speeds along the circle, and pausing for a bit at the top. Like before, there are different ways to go about this. If we continue to use a tween we could define a custom easing curve for the animation, or we can use keyframes to specify the different points along the path at certain time intervals. Both would yield similar results, and for this exercise we will be using keyframes.
We want the animation to start a bit slow, then accelerate, then slow down again. For this we will define an animation section as a fraction of the animation duration and use that to specify how far along the path we want to be at those time intervals. Looking at the code will be simpler than trying to explain this, so let’s do that:
We replace the tween with a keyframes.
The duration remains the same.
Our first frame is at time 0, we want the animation to be at 0 (so at the top of the circle, 12 o’clock).
The next frame is at time 400ms (2 segments), here we want the animation to be at .5π, so at the 3 o’clock position.
Next, after just 1 more segment (200ms), we want the animation to be at position π — so at 6 o’clock. We have covered the same distance as the previous frame, but in half the time, so the animation will accelerate.
The next frame is at 1.5π or 9 o’clock, and we also take 1 segment to cover this distance.
Finally we reach the top of the circle again after another 2 segments (400ms).
The key frames only cover 60% of the animation time, so the animation will pause at the top for the remainder 40%. If we run this, we get this result:
Adding the alpha animation
To make the animation a bit more engaging we will add an alpha component to the dots as they move along the path. We want the dots to be at full alpha when they start, fade out slightly as they move towards the 6 o’clock position, then fade back in as they complete the circle.
To add alpha to the dots we will use the graphicsLayer modifier on the dots, and we will reuse the current animation that indicates the position along the 2π path to determine the alpha. As the alpha goes from 0 to 1 and our path goes from 0 to 2π we will need to map the values so that they are in the correct range.
With some simple math we can create a function that, given a value in the [0–2π] range gives us an alpha that follows the requirements we specified above:
We normalize the radians value so that it maps to the [0, 1] range.
We map that value so that we get an alpha of 1f at 0, decreasing to 0.5f at the mid point, and then increasing again to 1f.
Now we just need to apply the alpha to our dots:
We use the placeRelativeWithLayer method that accepts a lambda with a `GraphicsLayerScope` so that we can apply effects to the placeable.
In this lambda we set the alpha based on the path along the way, using our mapping function.
With this we get our final result:
This short article shows how to use the Layout composable to place children in a container, and how to use the powerful animations in Jetpack Compose to create a simple yet engaging progress animation. The principles shown here can be applied to create varied sets of animations to make loading screens more unique and engaging. The whole code for this progress indicator is available in this gist.