‹ Back to blog

Replicating Google’s Pull-To-Refresh SVG in React Native

Why not use the built-in Refresh component from react native

  • It offers limited customization options
  • The loading state is a simple boolean that might not be enough to cover some UI/UX requirements
  • The pull to refresh component offered by Android’s swipe refresh layout looks fun 😜

The goal

To replicate this animation with react-native-svg and utilise react-native-reanimated to ensure that calculations are ran on the UI thread to optimize perfrormance, and if possible share the same code across mobile and web for easier maintenance

Goal Animation

Challenges faced

  • react-native-svg behaves differently on web and mobile, for example: some props can be animated on mobile but can’t be animated on web and vice-versa due to how the package works internally
  • issues with applying translate and rotation props to react-native-svg paths

Solutions

  • react-native-skia was able to solve the previously mentioned issues, but i didn’t wanna introduce a new package to the code base just for this purpose
  • the solution i settled on was animating the path itself and drawing the arrow and circle manually instead of relying on animated transformations

Requirements

  • We’ll need an arc that grows to become a circle as the animation progresses
  • An arrow head that grows to full length as the animation progresses

To create the arc we need a function that takes the circle dimensions and a rotation angle and returns the arc’s svg path

const polarToCartesian = (
  centerX: number,
  centerY: number,
  radius: number,
  angleInDegrees: number
) => {
  "worklet";
  const angleInRadians = ((angleInDegrees - 90) * Math.PI) / 180.0;

  return {
    x: centerX + radius * Math.cos(angleInRadians),
    y: centerY + radius * Math.sin(angleInRadians),
  };
};

const describeArc = (
  centerX: number,
  centerY: number,
  radius: number,
  startAngle: number,
  endAngle: number
) => {
  "worklet";
  const start = polarToCartesian(centerX, centerY, radius, endAngle);
  const end = polarToCartesian(centerX, centerY, radius, startAngle);

  const largeArcFlag = endAngle - startAngle <= 180 ? "0" : "1";

  const d = `M ${start.x} ${start.y} A ${radius} ${radius} 0 ${largeArcFlag} 0 ${end.x} ${end.y}`;

  return d;
};

Then, to create the arrow head we’ll need a function that takes in the ending point of the arrow, arrow width, and the rotation angle and outputs the coordinates of the 3 points that define the triangle

type Point = { x: number; y: number };

const calculateArrowhead = (
  endPoint: Point,
  endAngle: number,
  arrowLength: number,
  arrowWidth: number
) => {
  "worklet";
  const endAngleRad = endAngle * (Math.PI / 180);

  // Calculate the direction vector for the arrowhead base
  const baseVector = {
    x: arrowWidth * Math.cos(endAngleRad + Math.PI / 2),
    y: arrowWidth * Math.sin(endAngleRad + Math.PI / 2),
  };

  // Calculate the left and right base points
  const farBase = {
    x: endPoint.x + baseVector.x / 2,
    y: endPoint.y + baseVector.y / 2,
  };

  const closeBase = {
    x: endPoint.x - baseVector.x / 2,
    y: endPoint.y - baseVector.y / 2,
  };

  // Calculate the tip of the arrowhead
  const tip = {
    x: endPoint.x + arrowLength * Math.cos(endAngleRad),
    y: endPoint.y + arrowLength * Math.sin(endAngleRad),
  };

  return { closeBase, farBase, tip };
};

Then we need to create our animated path component with the createAnimatedComponent method from reanimated, calculate the paths for the arc and arrow head based on the animation progress, and pass the resulting paths to our new animated component

import type { ColorValue } from "react-native";
import Animated, {
  Extrapolation,
  interpolate,
  useDerivedValue,
  type SharedValue,
} from "react-native-reanimated";
import { Svg, Path } from "react-native-svg";

const AnimatedPath = Animated.createAnimatedComponent(Path);

const startingAngle = 0;

type AnimatedRefreshSVGProps = {
  size: number;
  progress: SharedValue<number>;
  strokeWidth: number;
  color: ColorValue;
};

export const AnimatedRefreshSVG: React.FC<AnimatedRefreshSVGProps> = (
  props
) => {
  const { size, progress, strokeWidth, color } = props;
  const centerX = size / 2;
  const centerY = size / 2;
  const radius = size / 4;

  const angle = useDerivedValue(() => {
    return interpolate(progress.value, [0, 100], [0, 300], Extrapolation.CLAMP);
  });

  const arrowWidth = useDerivedValue(() => {
    const scale = interpolate(
      angle.value,
      [0, 60, 300],
      [0, 0, 1],
      Extrapolation.CLAMP
    );
    return strokeWidth * 3 * scale;
  });

  const arcPath = useDerivedValue(() => {
    return describeArc(centerX, centerY, radius, startingAngle, angle.value);
  });

  const arrowPath = useDerivedValue(() => {
    const halfArrowWidth = arrowWidth.value / 2;
    const arcEndPoint = polarToCartesian(centerX, centerY, radius, angle.value);
    const { closeBase, farBase, tip } = calculateArrowhead(
      arcEndPoint,
      angle.value,
      halfArrowWidth,
      arrowWidth.value
    );
    const d = `M ${closeBase.x} ${closeBase.y} L ${tip.x} ${tip.y} L ${farBase.x} ${farBase.y} Z`;
    return d;
  });

  return (
    <Svg style={{ height: size, width: size }} viewBox={`0 0 ${size} ${size}`}>
      <AnimatedPath
        stroke={color}
        fill={"transparent"}
        strokeWidth={strokeWidth}
        d={arcPath}
      />

      <AnimatedPath d={arrowPath} fill={color} stroke={color} />
    </Svg>
  );
};

The end result would look something like this, which achieves our main challenge of animating the svg, and the rest would be to show this component above our scroll view and animate its other props like scale and opacity based on the gesture direction and Y axis movement, which will be discussed in a future blog post

Animation Example

End Result