react-nativesvgborderreact-native-svg

Progress border around an Elliptical or Rounded-Rectangular View in React Native


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;

Solution

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