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',
},
});