Creating a custom React Native Switch component using the Reanimated and Gesture Handler libraries (Part 1)

Preface

Greetings dear reader! If you are interested in development for React Native and want to learn how to work with animations and click tracking, then this article is for you. This article is the first to come out my pen laptop keys, so please don’t throw your slippers too much. Here we will look at working with custom animations in React Native and using libraries react-native-reanimated And react-native-gesture-handler.

Beginning of work

First we need to initialize the project. React Native gives us two options to choose from: using Expo or pure React Native CLI. For each option, it is necessary to perform the necessary manipulations, which are described Here for Expo and Here for CLI. Once we have deployed the project, we need to configure our project to work with the libraries we need.

Reanimated

This library provides us with the ability to use custom animations in React Native using a separate JS thread. First, we need to install the library itself.

If the project is initialized via Expo:

npx expo install react-native-reanimated

If via CLI:

npm install react-native-reanimated

Next, regardless of the project initialization option, we need to add the babel plugin to the babel.config.js file to work with the library:

  module.exports = {
    presets: [
      ... // don't add it here :)
    ],
    plugins: [
      ...
      'react-native-reanimated/plugin',
    ],
  };

Important! When working with iOS, do not forget to install pods to run the native library code:

cd ios && pod install && cd ..

Gesture Handler

Through this library we will control swipe and click gestures on our component. To work with this library, we also need to first install the package we need.

If the project is initialized via Expo:

npx expo install react-native-gesture-handler

If via CLI:

npm install react-native-gesture-handler

Next we need to wrap our application in a special component

import { GestureHandlerRootView } from 'react-native-gesture-handler';

export default function App() {
  return (
    <GestureHandlerRootView style={{ flex: 1 }}>
      {/* content */}
    </GestureHandlerRootView>
  );
}

Also, when working with IOS, do not forget to install pods.

Creating a Component

We will create our component using the TypeScript language, which is the default in the latest versions of React Native immediately upon initialization.

import React, { FC } from 'react';

type SwitchProps = {};
export const Switch: FC<SwitchProps> = () => {
  return <></>;
};

Using StyleSheet we will create style objects for our component

const styles = StyleSheet.create({
  container: {
    width: 52,
    height: 32,
    borderRadius: 16,
    justifyContent: 'center',
    paddingHorizontal: 4,
  },
  circle: {
    width: 24,
    height: 24,
    backgroundColor: 'red',
    borderRadius: 12,
  },
});

The styles are given as an example, any others can be used to suit your taste (of course, within reason). The most important thing in implementation is to set the width of the container and the circle in our switch.

We set the main props of our component:

type SwitchProps = {
  value: boolean;
  onValueChange: (value: boolean) => void;
};

Now we need to calculate the width of the track along which our circle in the component can move. It is calculated simply: take the width of the container and subtract the width of our circle. Since we used a style such as paddingHorizontal, we multiply its value by two (this style specifies horizontal padding on both sides of the component) and also subtract it from the width of the container. This turns out to be a constant:

const TRACK_CIRCLE_WIDTH =
    styles.container.width -
    styles.circle.width -
    styles.container.paddingHorizontal * 2;

Now we use the special useSharedValue hook from the reanimated library and create a constant that will store the state of our component between JS threads:

const translateX = useSharedValue(value ? TRACK_CIRCLE_WIDTH : 0);

Here we see that if the switch is initially in the active state, then we immediately set the final width of the track, otherwise the initial state of the circle will be at the zero point

In addition to the previous hook, we also use another hook, useAnimatedStyle, from the same library. It returns us a styles object that we will use to animate our component:

const animatedStyle = useAnimatedStyle(() => {
    return {
      transform: [{ translateX: translateX.value }],
    };
  });

As you can see, our animation will be based on the fact that through the translateX style we will change our current circle location from 0 to TRACK_CIRCLE_WIDTH.

Next, we use the same hook, but to animate the color change of our container. To do this, we’ll use the interpolateColor function, which will control how our color gradually changes depending on the position of our circle:

const animatedContainerStyle = useAnimatedStyle(() => {
    return {
      backgroundColor: interpolateColor(
        translateX.value,
        [0, TRACK_CIRCLE_WIDTH],
        ['darkgray', 'darkblue']
      ),
    };
  });

Here the colors ‘darkgray’ and ‘darkblue’ are used for example as the colors of the component’s inactive state and active state, respectively.

To work with these styles, the library contains specially created components Text, View, ScrollView, Image and FlatList. It is also possible to create your own components by wrapping some of your custom components in a special function createAnimatedComponent. For our component we only need a View:

<Animated.View style={[animatedContainerStyle, styles.container]}>
  <Animated.View style={[animatedStyle, styles.circle]} />
</Animated.View>

The parent View is the container of our component, and the child is a circle.

Our component should now look like this

Our custom Switch

Our custom Switch

Handling clicks

Now let’s add some action to our static component.

For this we need the Gesture Handler library.

In the second version of this library, the GestureDetector component appeared, which contains the ability to track all types of interactions with the component:

    <GestureDetector>
      <Animated.View style={[animatedContainerStyle, styles.container]}>
        <Animated.View style={[animatedStyle, styles.circle]} />
      </Animated.View>
    </GestureDetector>

Typescript tells us that the required gesture prop has been missed, to which we must pass the click tracking function.

These functions are located in a special Gesture object. Now we need the Tap method, which is designed to track clicks on the component:

const tap = Gesture.Tap().onEnd(() => {
    translateX.value = value ? 0 : TRACK_CIRCLE_WIDTH;
  });

What we’re describing here is that when the user completes a click, we reverse the circle’s position value. We also need to call the onValueChange function and pass it the opposite value of the current value. The problem is that if we simply call it, there will be an error when executing the code. It’s all about the JS flow, in which the logic for processing clicks occurs. It happens in the second thread, the main thread is not aware of it. In order to notify it about this, there is a special function runOnJS from the reanimated library, which will notify the main thread that the passed function needs to be executed:

const tap = Gesture.Tap().onEnd(() => {
    translateX.value = value ? 0 : TRACK_CIRCLE_WIDTH;
    runOnJS(onValueChange)(!value);
  });

Let’s pass this constant to the gesture prop and see what happens. Let’s export our component and add useState for control:

export default function App() {
  const [value, onValueChange] = useState(false);

  return (
    <GestureHandlerRootView style={{ flex: 1 }}>
        <Switch value={value} onValueChange={onValueChange} />
    </GestureHandlerRootView>
  );
}
Handling clicks

Handling clicks

As you can see, there is no smell of smooth animation here. In order for the translateX value to change smoothly, there are special auxiliary functions from the reanimated library (withTiming, withSpring, etc.). Let’s take the withTiming function, edit the tap constant and see what we get:

const tap = Gesture.Tap()
    .onEnd(() => {
      translateX.value = withTiming(value ? 0 : TRACK_CIRCLE_WIDTH);
      runOnJS(onValueChange)(!value);
    })
Smooth transition

Smooth transition

Now we can see the smoothness of our animation when we click on our component.

Gesture processing

Let’s move on to interacting with our component via swipe.

To do this, we will use the Pan method from the Gesture object. We will need two methods in it: onUpdate and onEnd. The first is responsible for each update of finger movement on a component, the second is already known to us and is responsible for listening to the termination of gestures. To both methods we can pass a function that takes a set of parameters that change when the fingers move relative to the component. Of all the parameters, we only need the translationX key, which is responsible for tracking the movement of the finger along the horizontal coordinate axis:

const pan = Gesture.Pan().onUpdate(({ translationX }) => {});

Let’s create a constant that will give the correct location of the circle taking into account the movement of the finger:

const translate = value ? TRACK_CIRCLE_WIDTH + translationX : translationX;

Here we determine that if the switcher is active, then our circle is at the final right point of the track and we need to add the location of the finger along the X axis to this value; if the switcher is inactive, then we add the value of the finger to zero, which is removed from the formula as unnecessary.

Let’s create a function that will be responsible for limiting the movement of the circle when swiping with your fingers. The value must not be less than 0 and greater than TRACK_CIRCLE_WIDTH:

 const currentTranslate = () => {
      if (translate < 0) {
        return 0;
      }
      if (translate > TRACK_CIRCLE_WIDTH) {
        return TRACK_CIRCLE_WIDTH;
      }
      return translate;
    };

After all our manipulations, we simply transfer our value to the translateX constant:

 const pan = Gesture.Pan().onUpdate(({ translationX }) => {
    const translate = value ? TRACK_CIRCLE_WIDTH + translationX : translationX;
    const currentTranslate = () => {
      if (translate < 0) {
        return 0;
      }
      if (translate > TRACK_CIRCLE_WIDTH) {
        return TRACK_CIRCLE_WIDTH;
      }
      return translate;
    };
    translateX.value = currentTranslate();
  });

Now let’s move on to the onEnd method. For convenience, we create the same translate constant as in the previous method. We will also need one more additional constant that will track the final location of the circle and, depending on it, give either the leftmost point of the track or the rightmost one:

const selectedSnapPoint =
        translate > TRACK_CIRCLE_WIDTH / 2 ? TRACK_CIRCLE_WIDTH : 0;

We need this so that when the user stops swiping, our circle does not get stuck at the point where the user left it, but moves to one of the extreme points of the track. To do this, we divide the width of the track of our component in half and look at which half our circle stopped on and return it to the extreme point of the track of this half.

After that, we pass this value to translateX and add some animation through the withTiming function:

translateX.value = withTiming(selectedSnapPoint, { duration: 100 });

As we can see from the code, this function can accept an additional configuration object, to which we passed a delay for the start of the function of 100 milliseconds (the default delay is 300 milliseconds). At the very end, we will call the runOnJS function we are already familiar with and pass onValueChange to it to change the state of the switcher:

.onEnd(({ translationX }) => {
      const translate = value
        ? TRACK_CIRCLE_WIDTH + translationX
        : translationX;
      const selectedSnapPoint =
        translate > TRACK_CIRCLE_WIDTH / 2 ? TRACK_CIRCLE_WIDTH : 0;
      translateX.value = withTiming(selectedSnapPoint, { duration: 100 });
      runOnJS(onValueChange)(!!selectedSnapPoint);
    })

In the end, our pan constant will look like this:

const pan = Gesture.Pan()
    .onUpdate(({ translationX }) => {
      const translate = value
        ? TRACK_CIRCLE_WIDTH + translationX
        : translationX;
      const currentTranslate = () => {
        if (translate < 0) {
          return 0;
        }
        if (translate > TRACK_CIRCLE_WIDTH) {
          return TRACK_CIRCLE_WIDTH;
        }
        return translate;
      };
      translateX.value = currentTranslate();
    })
    .onEnd(({ translationX }) => {
      const translate = value
        ? TRACK_CIRCLE_WIDTH + translationX
        : translationX;
      const selectedSnapPoint =
        translate > TRACK_CIRCLE_WIDTH / 2 ? TRACK_CIRCLE_WIDTH : 0;
      translateX.value = withTiming(selectedSnapPoint, { duration: 100 });
      runOnJS(onValueChange)(!!selectedSnapPoint);
    })

In order to pass several constants to our GestureDetector, there is a special method Gesture.Race, which can combine our methods with gestures

const gesture = Gesture.Race(tap, pan);

We pass this constant as a gesture prop and see what we got

Working with Gestures

Working with Gestures

In the next final part we will implement the logic of the disabled prop, add a couple of new features and write processing for changing the value state outside the component.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *