Creating a Loading Button in Jetpack Compose

Francesc Vilarino Guell
9 min readDec 30, 2021

--

In this tutorial we will implement a loading button, i.e., a button that replaces its content with a loading indicator to signify to the user that some operation is under way, and when that operation has completed, reverts to its original state. This GIF shows what we want to achieve:

Let’s see how we can achieve this, step by step.

Creating the loading indicator dots

In Jetpack Compose, as the name indicates, elements are composed of smaller units, we build our composables by creating the basic pieces that we later assemble to get the desired effect. For our animated button, the most basic element we need is the animating dot, so let’s start there.

To render a solid circle on Jetpack Compose we can use a Box and then apply the clip modifier with a circular shape, we don’t need anything fancier than this, so let’s see how we can render this dot:

For our LoadingDot we just need a couple of parametes, the color we want to use to fill the dot with, and a modifier that we will use to customize this composable.

Creating the bouncing loading composable

The row of dots

Next we are going to create the composable that will render the 3 dots and animate them. As we want to display these dots in a row, our composable will therefore use a Row as its root element. We will allow for a bit of customization, so besides the color that the dots already accept, we will also let users of this composable specify the spacing between the dots. We could take this a step further and also allow customizing the dot size, but for this exeercise we’ll limit it to the spacing.

With these requirements, we can display our composable as shown below:

There isn’t much to it, we just have a Row and we place the Dots in it, in our case we define NumIndicators as 3. The only thing worth of note is that we use the aspectRatio modifier to ensure our Dots are a circle.

As mentioned, our composable takes a color for the dots, and the spacing between them. As is customary, we also provide a Modifer so that this composable can be easily customized. Per Jetpack Compose guidelines, the Modifier is the first optional argument.

Animating the dots

Next we want to animate the dots. As shown in the animation at the beginning of this article, we want to stagger the dots so that they are offset from one another. In Jetpack Compose animations are built on top of Kotlin coroutines, so we can leverage that and use a delay to start the animation for the 2nd and 3rd dot, so that they are offset relative to the first dot.

To animate each invidual dot we willl use the animate function from Jetpack Compose. This function takes an initial value, a target value, an optional animation spec to control how the value animates from start to target and a lambda that gets called as the animation value is updated. In our case, we want to animate the dots up and down, and we want the overall translation to be equal to the height of the dots, in other words, we want to move the dots up by half their height, and then down below their rest position by half their height again. If we place the dots centered in their container initially, and we want to start the movement from bottom to top, this means our initial value will be the dot height divided by 2, and the target value will be minus the dot height divided by 2. Maybe seeing the code will make this clearer, so this is how our animation will be defined:

Let’s analyze this:

  1. As we want our dots to be staggered, we define an animation delay that determines how much delayed a dot animation is relative to the previous dot animation. Because we want the dots to animation in a harmonious way, we ensure that the delays between dots are all the same; we do that by dividing the animation duration by the number of dots.
  2. We define a remembered variable that holds the animation value that we will use to animate the dots.
  3. We use a LaunchedEffect to trigger the start of the animation. Because the key is Unit, this will start when the composable enters the composition and won’t be cancelled until the composable leaves the composition.
  4. Next we define the animation, with the start value being half the dot height (so that it initially starts at its downmost position), and the target as minus half the dot height, so that the end position is the topmost one. For our animation we use a tweeen that will linearly interpolate between the 2 values, and because we want the animation to revert and start over again, we specify a repeatMode of RepeatMode.Reverse.
  5. We delay each dot by the delay we calculated above, and we multiply it by the dot index (we will see where this comes from in a minute).
  6. Finally, we specify the animation callback that will simply udpate our remembered variable with the animation value.

Now that we have the animation defined, we can build on this and render the dots. Our animated dots composable will therefore be:

Let’s have a more detailed look at what we are doing here:

  1. Using the animation we described earlier, we create a list of those, one for each dot we want to render. This is where the index that we use to offset each animation comes from.
  2. In our Row, we iterate over the animations we just built and for each we add a Dot.
  3. Each of these Dots is offset vertically, by the amount specified by our remembered animation value.

If we run this animation, we get this result:

That’s starting to look pretty good, let’s move to the next step.

Creating the animating button

So our goal is to have a Button that displays its standard content when it’s idle, and the loading dots when it’’s busy (from now on I’ll refer to busy content to the loading dots, and idle content to the default button content). We don’t know how large the idle content is, and we don’t want our button to change sizes when we toggle between the idle and the busy contents, so we have to find a way to ensure the button is as wide and as tall as the widest and tallest of the 2 contents.

There are a few different ways to achieve this, but because I also want to crossfade the idle and busy contents, the solution I have chosen for this is to render both contents within a Box, which will ensure it’s large enough to fit both, and then crossfade the alphas between idle and busy content.

The problem with this approach is that our animating dots are always animating, so if we add the dots to a Box and set their alpha to 0, they won’t be visible, but the animation will still be running and wasting CPU resources for no good reason, so we will need to update our animating dots composable to stop animating when it’s not visible.

Our animating dots run the animations in a LaunchedEffect, which we have defined as taking a Unit for key, which means this launches when the composable enters the composition and runs for as long as the composable remains in the composition. To be able to stop the animation we will need to update our LaunchedEffect, so that we can leverage the key to stop the animation. The simplest solution is to provide a boolean to our composable that indicates if we are animating, and then restart the LaunchedEffect on that boolean. The code is probably going to be easier to understand than this description, so let’s see how we can update our animating dots composable to offer a start/stop control:

We only need to make 3 small changes to our composable:

  1. We pass an animating boolean that tells us if the dots are animating.
  2. Our animated value is remembered on the animating value, so that if we toggle the animating flag, we reset the dots to their resting position.
  3. The LaaunchedEffect is also using the animating flag as the key, so that when we toggle animating the previous LaunchedEffect will be cancelled (the animation will stop) and a new LaunchedEffect will trigger. Note that, if we are not animating, then we do not call animate and our animatedValue will be simply 0f all the time, so the dots will be at rest.

Now that we have our animating dots optimized to only animate when the animating flag indicates so, we can work on our Loading Button. As we said earlier, we will use a Box and render the 2 contents, idle and busy, and crossfade them. We don’t want to use the Crossfade animation because that won’t keep the size of the largest content, so we will manually crossfade the 2 contents. To animate the alpha of the idle and busy contents we will use animateFloatAsState. Let’s see how we do that:

  1. We define our LoadingButton composable mimicking the Button from Jetpack Compose. I used some of the attributes of Button and left others out, but we coud have copied all the attributes if we may expect to need them. Besides the Button attributes, we have the loading flag and the indicator spacing.
  2. Next we define the 2 alpha animations for the idle and busy contents, they run opposite one another (one runs from 1f to 0f, the other from 0f to 1f).
  3. We are leveraging the Button from Jetpack Compose as our root element.
  4. Inside our Button we have a Box — as described, this will ensure the Button is as wide and tall as needed and doesn’t change size when we toggle between idle and busy content.
  5. We specify a center alignment for the button content, so that both the idle and busy contents are nicely centered.
  6. Next we display the loading indicator we built earlier, passing the loading flag as the animating toggle.
  7. We add a graphicsLayer modifier to animate the alpha of the loading indicator.
  8. We wrap the idle content in a Box
  9. Because we want to control its alpha based on whether we are loading or not.
  10. Finally, we render the content.

And with this our composable is complete! We can add this to a screen with these few lines of code:

Additional animations

Now that we have our bouncing dots button, we can build on top of it and add other animations. Another one that comes to mind is a fade animation, where the dots fade in and out, also in a staggered way.

To specify which kind of animation we want for our button, we will define an enum class that we will add as an extra parameter to our LoadingButton composable, as shown below:

Next we need to pass this to our LoadingIndicator composable, so that we can choose which animation to run. For the fade animation we want to change our animation target values to be .2f and 1f for the alpha, and we will make our animation a bit longer as well.

One option would be to add these attributes as part of our enum above, but the enum is going to be part of our public API, and these attributes are internal to the composable, so we should not be exposing them. Instead, we will define a set of extension properties on the enum that will provide the start and target animation values, as well as the animation duration and the delay:

Now that we have this, we can update our LoadingIndicator composable to either bounce or fade the dots:

There are only a few changes we need to do for this:

  1. We pass our enum to indicate what kind of animation we want.
  2. The remembered animated value is keyd off the animation type, so that if we change it, the value resets back to 0.
  3. Likewise, the LaunchedEffect also keys off the animation type, so that we cancel and restart the animation if the value changes.
  4. We use the extension properties we just defined to get the animation attributes.
  5. Finally, based on the type of animation we want, we either use offset or alpha for the dots, using the then property of Modifier.

And here’s our final result:

The composable we have defined could be further enhanced with additional animations; the steps to do so are the same as those to add the fade animation, simply add a new entry to the enum, update the extension properties and finally animate the new property in LoadingIndicator. Other improvements we could add is preventing the button from triggering when we are in the busy state; adding these additional animations and enhancing the button behaviour is left as an exercise to the reader.

The full code is available here. If you made it all the way here, hope you found this useful, and I’ll see you on the next one!

--

--

Francesc Vilarino Guell
Francesc Vilarino Guell

Responses (2)