2

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:

Goal

What I have so far:

Current

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;
1
  • 2
    If your viewBox is 70 wide and your circle is at cx=35 with a radius of 35 then any stroke will overflow the viewBox. Same with the height. Commented Aug 25, 2024 at 16:00

3 Answers 3

2

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

Sign up to request clarification or add additional context in comments.

4 Comments

This looks great! The only issue is that the border width is inconsistent: i.imgur.com/lvSi5px.png. I set the border width to 10.
Yeah in my earlier answers I noticed that the border would get clipped sometimes. I found that if you translate x and y by half the borderWidth it stops clipping. I updated the answer to show this
Okay I refreshed my project and now Im seeing that border is now not centered. Let me know if you get fixed before I do
I've updated the answer again with the centering fixed
1

I drew a simple SVG file and just switched in it to your code... Does this solution seem ok to you?

import React from 'react';
import { TouchableOpacity, StyleSheet, View } from 'react-native';
import Svg, { Rect, Text as SvgText } from 'react-native-svg';
import moment from 'moment';

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.50;
  const radius = 30;
  const strokeWidth = 3;
  const rectWidth = 60;
  const rectHeight = 80;
  const perimeter = 1.6 * (rectWidth + rectHeight);
  const progressLength = progress * perimeter;

  return (
    <View style={styles.screen}>
      <TouchableOpacity style={styles.container}>
        <Svg width="70" height="100" viewBox="0 0 70 100">
          <Rect
            x="5"
            y="10"
            width={rectWidth}
            height={rectHeight}
            rx={radius}
            ry={radius}
            fill="black"
            stroke="gray"
            strokeWidth={strokeWidth}
            opacity={0.2}
          />
          <Rect
            x="5"
            y="10"
            width={rectWidth}
            height={rectHeight}
            rx={radius}
            ry={radius}
            fill="none"
            stroke="green"
            strokeWidth={strokeWidth}
            strokeDasharray={`${progressLength} ${perimeter - progressLength}`}
            strokeLinecap="round"
          />
          <SvgText
            x="35"
            y="40"
            textAnchor="middle"
            fill="#858585"
            fontSize="20"
            fontFamily="Arial"
          >
            {dayName}
          </SvgText>
          <SvgText
            x="35"
            y="75"
            textAnchor="middle"
            fill={isFutureDate ? '#757575' : 'white'}
            fontSize="20"
            fontFamily="Arial"
          >
            {dayNumber}
          </SvgText>
        </Svg>
      </TouchableOpacity>
    </View>
  );
};

const styles = StyleSheet.create({
  screen: {
    flex: 1,
    justifyContent: 'center',
    alignItems: 'center',
    backgroundColor: '#f5f5f5',
  },
  container: {
    justifyContent: 'center',
    alignItems: 'center',
    width: 70,
    height: 100,
  },
});

export default DateComponent;

UPDATE

pathLength version

import React from 'react';
import { TouchableOpacity, StyleSheet, View } from 'react-native';
import Svg, { Rect, Text as SvgText } from 'react-native-svg';
import moment from 'moment';

  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.50;
  const radius = 30;
  const strokeWidth = 3;
  const rectWidth = 60;
  const rectHeight = 80;

  return (
    <View style={styles.screen}>
      <TouchableOpacity style={styles.container}>
        <Svg width="70" height="100" viewBox="0 0 70 100">
          <Rect
            x="5"
            y="10"
            width={rectWidth}
            height={rectHeight}
            rx={radius}
            ry={radius}
            fill="black"
            stroke="gray"
            strokeWidth={strokeWidth}
            opacity={0.2}
          />
          <Rect
            x="5"
            y="10"
            width={rectWidth}
            height={rectHeight}
            rx={radius}
            ry={radius}
            fill="none"
            stroke="green"
            strokeWidth={strokeWidth}
            strokeDasharray={`${progress * 100} ${100 - (progress * 100)}`}
            strokeLinecap="round"
            pathLength="100"
          />
          <SvgText
            x="35"
            y="40"
            textAnchor="middle"
            fill="#858585"
            fontSize="20"
            fontFamily="Arial"
          >
            {dayName}
          </SvgText>
          <SvgText
            x="35"
            y="75"
            textAnchor="middle"
            fill={isFutureDate ? '#757575' : 'white'}
            fontSize="20"
            fontFamily="Arial"
          >
            {dayNumber}
          </SvgText>
        </Svg>
      </TouchableOpacity>
    </View>
  );
};

const styles = StyleSheet.create({
  screen: {
    flex: 1,
    justifyContent: 'center',
    alignItems: 'center',
    backgroundColor: '#f5f5f5',
  },
  container: {
    justifyContent: 'center',
    alignItems: 'center',
    width: 70,
    height: 100,
  },
});

export default DateComponent;

6 Comments

this looks good, but the percentage doesn't seem to work as intended. The border should function as a progress bar.
Set pathLength="100" attribute on the <rect> to work with percentage for strokeDashArray instead of calculations: pathLength MDN
@AudunHilden Check updated code, please.
@Danny'365CSI'Engelman Here you go, buddy!
I thought react native svg did not support pathLength
|
1

I fleshed something that works for react native skia. If you draw your border using path, you can use the end prop to get a progress bar border.

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';

type BorderViewProps = {
  progress:number;
  contentContainerStyle?:ViewStyle;
  children:ReactNode;
  backgroundColor?:string;
  color?:string;
  borderWidth?:number
}

export default function BorderView({progress, contentContainerStyle,children,borderWidth=2}: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(()=>{
    const p = Skia.Path.Make()
    // changing start position of path will change
    // where the progress bar starts
    p.moveTo(layout.width,layout.height)
    // draw oval
    p.addArc({
      // tried to remove clipping drawing does by subtracting borderWidth
      width:layout.width-borderWidth,
      height:layout.height-borderWidth,
      x:0,
      y:0
    },0,360)
    p.close()
    return p
  },[layout,borderWidth])
  // 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',
        left:layout.x,
        top:layout.y,
        width:layout.width,
        height:layout.height
        }}>
        <Path path={path} style="stroke" strokeWidth={borderWidth} color="orange" start={0} end={end}/>
      </Canvas>
      <View style={[styles.contentContainer,contentContainerStyle]} onLayout={e=>{
        const {width,height,x,y} = e.nativeEvent.layout
        setLayout({
          x,
          y,
          // attempt to to remove clipping
          width:width+borderWidth+2,
          height:height+borderWidth+2
        })}}>
        {children}
      </View>
    </>
  )
}

const styles= StyleSheet.create({
  container:{
    justifyContent:'center',
    alignItems: 'center',
    borderWidth:1
  },
  contentContainer:{
    padding:5,
    backgroundColor:'transparent'
  }
})

Usage:

import { StyleSheet, Text, View } from 'react-native';

import BorderView from '@/components/ProgressBorder';
import useProgressSimulation from '@/hooks/useProgressSimulation';



export default function HomeScreen() {
 const progress = useProgressSimulation(0.1)
  return (
    <View style={styles.container}>
      <Text>This is the home page</Text>
      <BorderView progress={progress}>
        <View style={styles.calendar}>
        <Text>Monday</Text>
        <Text>5</Text>
        </View>
      </BorderView>
    </View>
  );
}

const styles = StyleSheet.create({
 container:{
  flex:1,
  justifyContent: 'center',
  alignItems: 'center',
  padding:10,
  backgroundColor:'white'
 },
 calendar:{
  justifyContent: 'center',
  alignItems: 'center',
 }
});

Hook to simulate progress value changing

import { useEffect, useState } from 'react'

const wait = (duration=100)=>{
  return new Promise(resolve=>setTimeout(()=>resolve(null), duration))
}
const getRandom=(min=0,max=1)=>{
  const range = max - min
  return Math.random()*range+min
}

export default function useProgressSimulation(initVal=0){
  const [progress,setProgress] = useState(initVal)
  useEffect(()=>{
    const interval = setInterval(()=>{
      // wait 100-500ms and then add random value to progress
      wait(getRandom(100,500)).then(()=>setProgress(prev=>{
        const newVal = prev+getRandom(0.1,0.25)
        if (newVal > 1){
          clearInterval(interval)
          return 1
        }
        return newVal
      }))
    },1000)
    return ()=>clearInterval(interval)
  },[])
  return progress
}

I tried to make a snack for demo but it kept giving me an error about using SharedValues. When using expo outside the browser it works

8 Comments

Do you have an image of what it's supposed to look like? It's a circle when I import it into my code
Nice! But my problem is when trying to create a non-circular svg with the border around it. Check the example in my original post. It's a rectancle with border-radius of 100%.
Im not sure if I am understanding you. The answer provide creates an ellipse/oval around the view provided to it. If the view is a square, then the border will be a circle. Otherwise, it would be an ellipse
Your code creates a circle around the view provided, not an ellipse/oval. Check this: i.imgur.com/izZhce8.png
|

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.