I'm working on a React Native project where I need to display a progress indicator that outlines a non-circular view.
I tried using react-native-svg with the Circle component to create a circular progress indicator, but it didn't work as I wanted.
I need the progress indicator to fit an elliptical or rounded-rectangular shape.
Here’s a simplified version of my current approach using basic React Native components: https://snack.expo.dev/@audn/progress-border
What I'm trying to make:
What I have so far:
import { TouchableOpacity, Text, View, StyleSheet } from 'react-native';
import moment from 'moment';
import Svg, { Circle } from 'react-native-svg';
const DateComponent = () => {
const date = moment(new Date());
const dayName = date.format('dd').charAt(0);
const dayNumber = date.format('D');
const isFutureDate = date.isAfter(moment(), 'day');
const progress = 0.75;
const radius = 35;
const strokeWidth = 2;
const circumference = 2 * Math.PI * radius;
return (
<TouchableOpacity style={styles.container}>
<View style={styles.wrapper}>
<Svg height="70" width="70" viewBox="0 0 70 70">
<Circle
cx="35"
cy="35"
r={radius}
stroke="gray"
strokeWidth={strokeWidth}
fill="none"
opacity={0.2}
/>
<Circle
cx="35"
cy="35"
r={radius}
stroke="green"
strokeWidth={strokeWidth}
fill="none"
strokeDasharray={`${circumference} ${circumference}`}
strokeDashoffset={(1 - progress) * circumference}
strokeLinecap="round"
transform="rotate(-90, 35, 35)"
/>
</Svg>
<View style={styles.card}>
<Text style={styles.dayText}>{dayName}</Text>
<Text style={[styles.dateText, { color: isFutureDate ? '#757575' : 'black' }]}>
{dayNumber}
</Text>
</View>
</View>
</TouchableOpacity>
);
};
const styles = StyleSheet.create({
container: {
justifyContent: 'center',
alignItems: 'center',
},
wrapper: {
justifyContent: 'center',
alignItems: 'center',
},
card: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
justifyContent: 'center',
alignItems: 'center',
borderRadius: 35,
height: 70,
width: 70,
},
dayText: {
fontSize: 14,
color: '#757575',
},
dateText: {
fontSize: 18,
fontWeight: 'bold',
},
});
export default DateComponent;
To animate the drawing of a border you'll need the view's width,height, position and border style. With this you can draw the path of the border and clip how much of the path is drawn base upon progress
. There are probably ways to do this in react-native-svg but I have found skia is far easier.
BorderView component:
import { Canvas, Path, Skia } from '@shopify/react-native-skia';
import { ReactNode, useMemo, useState } from 'react';
import { LayoutRectangle, StyleSheet, View, ViewStyle } from 'react-native';
import { useDerivedValue, withTiming } from 'react-native-reanimated';
export default function BorderView({
progress,
// borderRadius styles
contentContainerStyle,
children,
borderWidth = 2,
color = 'orange',
}) {
// store children layout properties
const [layout, setLayout] = useState({
width: 0,
height: 0,
x: 0,
y: 0,
});
// store border as path
const path = useMemo(() => {
// tweaked https://github.com/Shopify/react-native-skia/discussions/1066#discussioncomment-4106234
let tl = contentContainerStyle?.borderRadius ||
contentContainerStyle?.borderTopLeftRadius ||
0;
if (tl > layout.width / 2) tl = layout.width / 2;
let tr = contentContainerStyle?.borderRadius ||
contentContainerStyle?.borderTopRightRadius ||
0;
if (tr > layout.width / 2) tr = layout.width / 2;
let bl = contentContainerStyle?.borderRadius ||
contentContainerStyle?.borderBottomLeftRadius ||
0;
if (bl > layout.width / 2) bl = layout.height / 2;
let br = contentContainerStyle?.borderRadius ||
contentContainerStyle?.borderBottomRightRadius ||
0;
if (br > layout.width / 2) br = layout.height / 2;
const p = Skia.Path.Make();
p.moveTo(0, tl);
// add rounded corner
if (tl > 0) {
p.rArcTo(tl, tl, 0, true, false, tl, -tl);
}
p.lineTo(layout.width - tr, 0);
// // add rounded corner
if (tr > 0) {
p.rArcTo(tr, tr, 0, true, false, tr, tr);
}
p.lineTo(layout.width, layout.height - br);
// //add rounded corner
if (br > 0) {
p.rArcTo(br, br, 0, true, false, -br, br);
}
p.lineTo(bl, layout.height);
// //add rounded corner
if (bl > 0) {
p.rArcTo(bl, bl, 0, true, false, -bl, -bl);
}
p.close();
return p;
}, [layout, contentContainerStyle]);
// use Path end property to animate progress
const end = useDerivedValue(() => withTiming(progress, { duration: 200 }));
return (
<>
<Canvas
style={{
// Canvas can only have skia elements within it
// so position it absolutely and place non-skia elements
// on top of it
position: 'absolute',
backgroundColor: 'transparent',
// subtract half borderWidth for centering
left: layout.x-borderWidth/2,
top: layout.y-borderWidth/2,
width: layout.width + borderWidth,
height: layout.height + borderWidth,
}}>
<Path
path={path}
style="stroke"
strokeWidth={borderWidth}
color={color}
start={0}
end={end}
transform={[
{ translateX: borderWidth / 2 },
{ translateY: borderWidth / 2 },
]}
/>
</Canvas>
<View
style={[styles.contentContainer, { margin: borderWidth }]}
onLayout={(e) => setLayout(e.nativeEvent.layout)}>
{children}
</View>
</>
);
}
const styles = StyleSheet.create({
contentContainer: {
backgroundColor: 'transparent',
},
});
Usage
import BorderView from '@/components/ProgressBorder';
import useProgressSimulation from '@/hooks/useProgressSimulation';
import moment, { Moment } from 'moment';
import { Button, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
export default function Day({ initProgress, date, debug, borderWidth = 2 }) {
const { progress, reset, start } = useProgressSimulation(initProgress);
const dayName = date.format('dd').charAt(0);
const dayNumber = date.format('D');
const isFutureDate = date.isAfter(moment(), 'day');
return (
<View style={styles.flex}>
<TouchableOpacity style={styles.container}>
<BorderView
progress={progress}
borderWidth={borderWidth}
color="green"
contentContainerStyle={styles.card}>
<View style={styles.card}>
<Text style={styles.dayText}>{dayName}</Text>
<Text
style={[
styles.dateText,
{ color: isFutureDate ? '#757575' : 'black' },
]}>
{dayNumber}
</Text>
</View>
</BorderView>
</TouchableOpacity>
{debug && (
<>
<View style={styles.row}>
<Button onPress={start} title="Start" />
<Button onPress={reset} title="Reset" />
</View>
<Text>{progress.toFixed(2)}</Text>
</>
)}
</View>
);
}
const styles = StyleSheet.create({
flex: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
container: {
justifyContent: 'center',
alignItems: 'center',
},
wrapper: {
justifyContent: 'center',
alignItems: 'center',
},
card: {
// position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
justifyContent: 'center',
alignItems: 'center',
borderRadius: 35,
height: 70,
width: 70,
},
dayText: {
fontSize: 14,
color: '#757575',
},
dateText: {
fontSize: 18,
fontWeight: 'bold',
},
row: {
flexDirection: 'row',
},
});
If you want to take things further, and do skia stuff, such as having the border be a linear gradient:
import {
Canvas,
SweepGradient,
Path,
Skia,
vec,
} from '@shopify/react-native-skia';
import { ReactNode, useMemo, useState } from 'react';
import { LayoutRectangle, StyleSheet, View, ViewStyle } from 'react-native';
import { useDerivedValue, withTiming } from 'react-native-reanimated';
type BorderViewProps = {
progress: number;
contentContainerStyle?: ViewStyle;
children: ReactNode;
backgroundColor?: string;
colors: string[];
gradientStart?: [number, number];
gradientEnd?: [number, number];
borderWidth?: number;
// style?:ViewStyle;
};
export default function GradientBorderView({
progress,
// borderRadius styles
contentContainerStyle,
children,
borderWidth = 2,
colors,
gradientStart,
gradientEnd,
}: BorderViewProps) {
// store children layout properties
const [layout, setLayout] = useState<LayoutRectangle>({
width: 0,
height: 0,
x: 0,
y: 0,
});
// store border as path
const path = useMemo(() => {
// tweaked https://github.com/Shopify/react-native-skia/discussions/1066#discussioncomment-4106234
let tl = (contentContainerStyle?.borderRadius ||
contentContainerStyle?.borderTopLeftRadius ||
0) as number;
if (tl > layout.width / 2) tl = layout.width / 2;
let tr = (contentContainerStyle?.borderRadius ||
contentContainerStyle?.borderTopRightRadius ||
0) as number;
if (tr > layout.width / 2) tr = layout.width / 2;
let bl = (contentContainerStyle?.borderRadius ||
contentContainerStyle?.borderBottomLeftRadius ||
0) as number;
if (bl > layout.width / 2) bl = layout.height / 2;
let br = (contentContainerStyle?.borderRadius ||
contentContainerStyle?.borderBottomRightRadius ||
0) as number;
if (br > layout.width / 2) br = layout.height / 2;
const p = Skia.Path.Make();
p.moveTo(0, tl);
// add rounded corner
if (tl > 0) {
p.rArcTo(tl, tl, 0, true, false, tl, -tl);
}
p.lineTo(layout.width - tr, 0);
// // add rounded corner
if (tr > 0) {
p.rArcTo(tr, tr, 0, true, false, tr, tr);
}
p.lineTo(layout.width, layout.height - br);
// //add rounded corner
if (br > 0) {
p.rArcTo(br, br, 0, true, false, -br, br);
}
p.lineTo(bl, layout.height);
// //add rounded corner
if (bl > 0) {
p.rArcTo(bl, bl, 0, true, false, -bl, -bl);
}
p.close();
return p;
}, [layout, contentContainerStyle]);
// use Path end property to animate progress
const end = useDerivedValue(() => withTiming(progress, { duration: 200 }));
if(!gradientStart){
gradientStart = [0,0]
}
if(!gradientEnd){
gradientEnd = [layout.width,layout.height]
}
return (
<>
<Canvas
style={{
// Canvas can only have skia elements within it
// so position it absolutely and place non-skia elements
// on top of it
position: 'absolute',
backgroundColor: 'transparent',
left: layout.x - borderWidth / 2,
top: layout.y - borderWidth / 2,
width: layout.width + borderWidth,
height: layout.height + borderWidth,
}}>
<Path
path={path}
style="stroke"
strokeWidth={borderWidth}
start={0}
end={end}
transform={[
{ translateX: borderWidth / 2 },
{ translateY: borderWidth / 2 },
]}>
<Path
path={path}
style="stroke"
strokeWidth={borderWidth}
start={0}
end={end}
transform={[
{ translateX: borderWidth / 2 },
{ translateY: borderWidth / 2 },
]}>
<SweepGradient
c={vec(layout.width/2, layout.height/2)}
colors={colors}
origin={vec(layout.width/2, layout.height/2)}
transform={[{rotate:Math.PI}]}
/>
</Path>
</Path>
</Canvas>
<View
style={[styles.contentContainer, { margin: borderWidth }]}
onLayout={(e) => setLayout(e.nativeEvent.layout)}>
{children}
</View>
</>
);
}
const styles = StyleSheet.create({
contentContainer: {
backgroundColor: 'transparent',
},
});