Building AdaptUI Circular Progress

Karthik Balasubramanian
Timeless
7 min readSep 30, 2023

--

Hey all.

We at Timeless released a Design System Adapt, a couple of months back.

Later, we released an Alpha Version of the components in React Native.

The major dependencies with which the component system was built were:

A lot of inspiration was taken from this beautiful video by William.

This blog will discuss how I developed the Circular Progress component with Reanimated and React Native SVG.

Blog cover by Fayas fs

As I build the component as part of the Open-Source Library, the code would have a set of props, primitive components which I will be using.

I will go about explaining the important code blocks from the component file.

Let us look at the props first, we will get an idea of what are requirements to get our circular progress.

export type CircularProgressSizes = "sm" | "md" | "lg" | "xl";

export type CircularProgressTheme = "base" | "primary";

export interface CircularProgressProps extends BoxProps {
/**
* The size of the Circle
* @default md
*/
size: CircularProgressSizes;
/**
* The size of the Circle
* @default base
*/
themeColor: CircularProgressTheme;
/**
* Color of Progress value
*/
progressTrackColor: string;
/**
* The Circle Base Track color
*/
trackColor: string;
/**
* The `value` of the progress indicator.
* If `null` the circular progress will be in `indeterminate` state
* @default null
*/
value?: number | null;
/**
* The minimum value of the progress
* @default 0
*/
min: number;
/**
* The maximum value of the
* @default 100
*/
max: number;
/**
* Hint for the Meter
*/
hint: string;
}

So first we need to decide if this circular progress should run indefinitely or based on a value. So we do an Indeterminate Check!

// Indeterminate Check
const isIndeterminate = React.useMemo(
() => value === null || value === undefined,
[value],
);

We will be using react-native-svg for <Circle /> and react-native-reanimated for our Animation.

As we said, it is Circular progress, we will set the parameters of the Circle.

// Circle parameters
const radius = 44;
const circleCircumference = 2 * Math.PI * radius;

Now, there are two properties of the <Circle /> which we will be manipulating to get our Circular Progress.

  1. strokeDasharray
  2. strokeDashoffset

Let me quickly tell you what both are.

The stroke-dasharray property in CSS sets the length of dashes in the stroke of SVG shapes. More specifically, it sets the length of a pattern of alternating dashes and the gaps between them.

Lear more here.

The stroke-dashoffset property in CSS defines the location along an SVG path where the dash of a stroke will begin. The higher the number, the further along the path the dashes will begin.

Learn more here.

Let us code our Circular Progress component.

The render code:

<AnimatedBox
ref={ref}
style={[circularProgressBoxDimensions, animatedSvgStyle]}
{...otherProps}>
<Svg width="100%" height="100%" viewBox={'0 0 100 100'}>
<G rotation={'-90'} origin="50, 50">
<Circle
stroke={"#e5e7eb"}
strokeWidth={strokeWidth}
fill="transparent"
r={radius}
cx={50}
cy={50}
/>
{isIndeterminate && (
<AnimatedCircle
stroke={'#000000'}
strokeWidth={strokeWidth}
fill="transparent"
r={radius}
cx={50}
cy={50}
strokeLinecap="round"
strokeDasharray={`${circleCircumference} ${circleCircumference}`}
animatedProps={indeterminateAnimatedCircularProgress}
/>
)}
{!isIndeterminate && (
<AnimatedCircle
stroke={'#202020'}
strokeWidth={strokeWidth}
fill="transparent"
r={radius}
cx={50}
cy={50}
strokeLinecap="round"
strokeDasharray={`${circleCircumference} ${circleCircumference}`}
animatedProps={animatedCircleProps}
/>
)}
</G>
</Svg>
{!isIndeterminate && hint && (
<View
style={[
StyleSheet.absoluteFillObject,
{
justifyContent: 'center',
alignContent: 'center',
backgroundColor: 'transparent',
},
]}>
<Text style={styles.text}>{hint}</Text>
</View>
)}
</AnimatedBox>

Let us break it down.

Starting with <AnimatedBox /> a wrapper component, which is similar to <Animated.View />.

Next comes the <Svg /> component from the react-native-svg library which has the height and width of “100%” and the dimensions are set in the wrapper component with circularProgressBoxDimensions.

const circularProgressBoxDimensions = {
width: hint ? 80 : 28,
height: hint ? 80 : 28,
};

The next is the <G />, which represents a group element inside the SVG. It's used to group multiple SVG elements together. Here, it's rotated by -90 degrees around the point (50, 50).

And next is <Circle />, which represents the track of our progress indicator, with stroke color of “#e5e7eb”, a specified strokeWidth, and is centered at (50, 50) and radius which is 44.

Now comes up the split of two cases to handle, Indeterminate and Determinate (which is based on progress value). Let us take a look at both.

Determinate Circular Progress

The code is something like this:

<AnimatedCircle
stroke={
progressTrackColor
? progressTrackColor
: gc(
cx(
circularProgressTheme.themeColor[themeColor]
?.progressTrackColor,
),
)
}
strokeWidth={strokeWidth}
fill="transparent"
r={radius}
cx={50}
cy={50}
strokeLinecap="round"
strokeDasharray={`${circleCircumference} ${circleCircumference}`}
animatedProps={animatedCircleProps}
/>

It is again a <Circle />, placed on top of the track. Avoid the prop stroke, which comes from the library/theme of AdaptUI.

<AnimatedCircle /> is created using the createAnimatedComponent() from Reanimated.

const AnimatedCircle = Animated.createAnimatedComponent(Circle);

We set the radius, center points and the strokeLinecap (it is how the starting and ending points of a border are on the SVG shapes, get to know more here).

We set the strokeDasharray to the circumference of the circle and animate the strokeDashoffset to get the feel that the circle track value is being filled.

We use one of reanimated’s hooks, useAnimatedProps() which allows us to create animated properties that can be passed to the components. Here we will be changing the strokeDashoffset.

This progress is based on a value, so we write a certain code which converts a value in terms of 100, which is percent. We do that by getting the max-min values from the user, which is 100 and 0 by default.

If not, we convert and calculate the valuePercentage

valuePercentage = ((progressValue.value - min) * 100) / (max - min);

And use this to calculate our strokeDashoffset value,

strokeDashoffset = circleCircumference - (circleCircumference * valuePercentage) / 100;

This is the length of dash offset, which represents the percentage of completion, and we use withSpring() to give it a slight bounce while animating.

const animatedCircleProps = useAnimatedProps(() => {
/**
* Converting a value to percentage based on lower and upper bound values from props
*
* value => the value in number
* min => the minimum value
* max => the maximum value
*/
const valuePercentage = ((progressValue.value - min) * 100) / (max - min);
const strokeDashoffset =
circleCircumference - (circleCircumference * valuePercentage) / 100;
return {
strokeDashoffset: withSpring(strokeDashoffset, SPRING_CONFIG),
};
});

This is our determinate circular progress.

Value-based circular progress

In-Determinate Circular Progress

The render code for the same looks as this:

<AnimatedCircle
stroke={'#000000'}
strokeWidth={strokeWidth}
fill="transparent"
r={radius}
cx={50}
cy={50}
strokeLinecap="round"
strokeDasharray={`${circleCircumference} ${circleCircumference}`}
animatedProps={indeterminateAnimatedCircularProgress}
/>

Similar to the determinate animation, we manipulate, the strokeDashoffset of the <Circle /> element.

const indeterminateAnimatedCircularProgress = useAnimatedProps(() => {
return {
strokeDashoffset: interpolate(
progress.value,
[0, 0.5, 1],
[0, -circleCircumference, -(circleCircumference * 2)]
),
};
});

The above code is used to interpolate the strokeDashoffset value based on a SharedValue variable, progress with input range [0, 0.5, 1] and output range to [0, -circleCircumference, -(circleCircumference * 2)].

When progress.value is 0, the strokeDashoffset is 0, when progress.value is 0.5, the strokeDashoffset is -circleCircumference (which likely represents half the circumference of the circle), and when progress.value is 1, the strokeDashoffset is -(circleCircumference * 2), indicating a full circle.

And we use the withRepeat() to loop our animation.

progress.value = withRepeat(
withTiming(1, {
duration: 1500,
easing: Easing.linear,
}),
-1,
false,
(finished) => {
if (!finished) {
progress.value = 0;
}
}
);

In the above code, we do a loop in animation where the progress value goes from 0 to 1 indefinitely using a withTiming(), with a reverse option set to false.

We call this inside the useEffect() and only when isIndeterminate is true.

Let us see how our animation looks.

InDeterminate Animation

Well, something is missing.

Let us rotate the whole container similar to animating the progress value, creating a rotate SharedValue variable.

rotate.value = withRepeat(
withTiming(1, {
duration: 1000,
easing: Easing.bezier(0.4, 0, 0.2, 1),
}),
-1,
false,
(finished) => {
if (!finished) {
rotate.value = 0;
}
}
);

And use this to rotate the full SVG wrapper container, by setting it in styles using useAnimatedStyle() from Reanimated.

const animatedSvgStyle = useAnimatedStyle(() => {
const rotateValue = interpolate(rotate.value, [0, 1], [0, 360]);
return {
transform: [
{
rotate: `${rotateValue}deg`,
},
],
};
});

Now, with this set to our <AnimatedBox />, let's see how our animation looks.

Well, well! This is neat.

I hope you would have got an understanding how to animate SVG components in React Native using Reanimated.

Take a look at the component here in AdaptUI Open-Source library, which is still in its alpha version, a WIP.

There are some minute changes I did to the component which will updated, and a new alpha version will be released soon. Writing the blog has made me refactor the component in certain aspects.

This is Karthik from Timeless

I hope you found this blog post helpful and informative. If you have any feedback or suggestions, please leave a comment below. I’d love to hear your thoughts and opinions on the topic.

And if you have any ideas for future blog posts, please let me know! I’m constantly looking for new and interesting topics to explore.

Thank you for reading, and I look forward to hearing from you. :)

--

--