How to Create a Simple Loading Animation in React Native (TypeScript) without Libraries and with Tests
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.
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.
Looking into the code a little closer, we can note a couple of things.
- 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.
- 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. - 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. - 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:
To make them overlap, use position: 'absolute'
inside the progress stylesheet.
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
.
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()
.
To have the animation loop, wrap the animation in an Animated.loop
:
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!