How to Create a Simple Loading Animation in React Native (TypeScript) without Libraries and with Tests

Chloe Mcmullan
easyfundraising.org.uk
6 min readMay 31, 2022

Recently, I was tasked with creating a spinner that would look the same on both iOS and Android. The idea was simple; a translucent hollow circle with an opaque quarter that travelled around. Unfortunately, the default Progress Bar from React Native could not be adjusted to allow for this design on both operating systems, meaning we had to go custom. Thankfully, Saurabh Gour already covered how to create a more complex progress circle without external libraries. This guide will be a simplified implementation of his Progress Circle, along with the jest test file.

A pink background with four hollow white circles on top. Each circle has a filled in quarter to indicate a loading spinner.
The design for the spinner we wanted.
A pink hollow circle with a red quarter that travels around the circle, indicating loading.
The completed spinner, with a little extra width for visual clarity!

I’m going to go through the process of creating this, and explain why each part of code was required. However, if you just want the finished code, for both the component and the tests, then scroll to the bottom!

The Process

Making the Circles

So let’s start simple, and get the ‘background’ or path for the spinner.

A circle with a red border and no fill colour.
A solid ring as the background

Looking into the code a little closer, we can note a couple of things.

  1. We have a container for the background ring. At the moment, this isn’t necessary, but I found that it helped later on when keeping the foreground and background parts of the spinner aligned.
  2. I’ve given the container an accessibilityRole. This is useful for users who require screen readers, and can be read about in the official documentation here.
  3. The spinner border colour is currently the colour we pass in. This isn’t quite right, because we want the spinner to always be a translucent variation of foreground spinner. This can be fixed by adding opacity: 0.25 into the container stylesheet. Alternatively, you might want to be able to provide a different colour for the background.
  4. You might have noticed that the border radius is half of the height: borderRadius: height / 2
    This allows you to get a circle from your view, which would otherwise have been square.

Now that we have a background, let’s look at adding in the progress view (without an animation).

First, add a second child to the container.

Start by using the same style for progress as you did for background, with the exception of the opacity. You should get something like this:

Two hollow circles, one on top of the other. The upper circle is a pale pink (red with 0.25 opacity) and the bottom circle is red.
The two circles align vertically

To make them overlap, use position: 'absolute' inside the progress stylesheet.

A red hallow circle, hiding a pink hallow circle behind it.
Like we’re back to step one!

However, we don’t want the progress indicator to be a full circle, just part of the circle. An easy way to fix this is to only set borderTopColor to be the colour, and to set the remaining border colours to transparent.

A hallow pink circle, with the top quarter filled in red.
Looking much better!

Animating

Now that we’ve got the basic building blocks down, let’s look into actually making the spinner…spin. To do this, I’m using Animated.View, which can be read about here. I won’t be going into a lot of detail about how this works, so you may want to read more about it there.

Let’s start by adding a number that represents the degree of rotation to the component:
const rotationDegree = useRef(new Animated.Value(0)).current

Persisting the value through useRef ensures that we won’t restart the animation on re-renders (unless we intend for that to happen).

We want to attach that rotation to the progress view, so we should convert the progress view into an Animated.View and add a transform style:

You may have noticed that we don’t just directly pass the rotationDegree to the style like this rotateZ: rotationDegree. This is because rotateZ takes a string value in the format '0deg’ instead of 0 . Thankfully, animated values have a function built in called interpolate which can map an inputRange into an output range. For us, that means mapping 0 -> 360 into '0deg' -> '360deg'. I could have chosen to map 0 -> 1 instead, but I like the readability of directly having the degree when debugging. If you want to read more about interpolate, you can check here.

At the moment, our animated value stays at the initial value of 0 because we haven’t provided any logic to actually change it. This can be done using an Animated.timing.

The breakdown of this code is that we want an animation to apply to the rotationDegree value, which animates it to 360 linearly, taking durationMs long to complete the animation. This will run using the internal clock, and can be started using .start().

A hallow pink circle with a red quarter which moves around the circle once before stopping
It spins, but only once

To have the animation loop, wrap the animation in an Animated.loop:

A pink hollow circle with a red quarter that travels around the circle, indicating loading.

All that’s left is to make sure we don’t restart that animation on every re-render! That can be done by extracting the animation code out of the functional component, and calling it inside a useEffect. The useEffect should have dependencies on the durationMs (because we want the animation to change if the duration changes!) and the rotationDegree reference.

Testing the Component

Testing something that relies on the internal clock can be quite difficult. Thankfully, there was someone out there who had worked out a simple enough way to do this, so thanks Benjamin Johnson for this guide!

First, add a testID to the progress part of the loading spinner so we can easily access the rotateZ property to confirm some spinning is actually happening.

Now let’s add a simple test, ignoring the animations, just to confirm that this actually renders.

Great, we’ve done the equivalent of a null check. Following the guide by Benjamin Johnson above, with some renames, I ended up with a timeExpediter class and a jestSetup class that look like this:

Don’t forget to add the setup file to your config.ts.

const config: Config.InitialOptions = {
preset: 'react-native',
testPathIgnorePatterns: ['/node_modules/', 'Web', '/dist/'],
rootDir: '../',
coverageDirectory: './coverage/app/',
setupFiles: ['./jest/jestSetup.ts'],
...

Now that we’ve got all the magic time manipulation code in, we can set it up in the beforeEach:

beforeEach(setupTimeTravel)
afterEach(jest.restoreAllMocks)

Yay, now we’re ready to actually get these tests working!

I figured an easy test would be to confirm that at half the duration, the rotation is '180deg' (a half rotation). I chose a duration of 100ms because it’s easy to split into whatever percentile I wanted to test.

This is where adding the testId comes in extra handy, because we can directly access that rotating view to check the rotation style is applied, like so:

Then, I wanted to confirm that it loops. To do that, I first expedite it a full rotation and check that it’s '360deg'. Then I expedited it another 50ms and confirmed that it was back around to '180deg'.

And that’s it! Congratulations, you now have yourself a fully tested loading spinner!

Finished Code

The completed spinner, in all its glory!

--

--

Chloe Mcmullan
easyfundraising.org.uk

React Native (TypeScript) and Android Native (Kotlin) Developer. Greyhound enthusiast.