skip to Main Content

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;

2

Answers


  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;
    
    Login or Signup to reply.
  2. 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

    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search