Crafting a Custom Vertical Slider in React Native

Crafting a Custom Vertical Slider in React Native

·

7 min read

In the realm of mobile app development, sometimes the tools you need simply don't exist yet. This was the case when I was tasked with creating a custom vertical slider component for a project at my current company. In this blog post, I'll walk you through the key aspects of this assignment, focusing on the reasons behind it, the challenges I faced, and how I overcame them.

The Task

The task was to create a custom vertical slider component for a React Native application. The challenge was that there were no existing libraries that provided a vertical slider component that met our specific needs. The slider needed to be interactive, responsive, and visually appealing. It also needed to handle user interactions such as dragging the slider up or down to adjust the real-time value of our company's IoT smart device's fans.

The Approach

The first step was to define the slider's steps and the initial fan power using React's useState and useMemo hooks. The useMemo hook was used to calculate the slider steps based on the height of the slider container and the height of the slider toggle.

const sliderSteps = useMemo(() => {
    return {
      0: 0,
      1: (sliderContainerHeight - sliderToggleHeight) * (1 / 3),
      2: (sliderContainerHeight - sliderToggleHeight) * (2 / 3),
      3: sliderContainerHeight - sliderToggleHeight,
    };
  }, [sliderContainerHeight, sliderToggleHeight]);

Handling User Interactions

To handle user interactions, I used the GestureDetector and Gesture.Pan() from the react-native-gesture-handler library. This allowed me to detect when the user starts dragging the slider, updates the drag, and finally releases the slider. The onUpdate and onFinalize handlers were used to calculate the new position of the slider based on the user's drag.

const gesture = Gesture.Pan()
    .onBegin(() => {
      isPressed.value = true;
    })
    .onUpdate(e => {
      // logic to calculate new position of the slider
    })
    .onFinalize(e => {
      // logic to finalize the position of the slider
    });

Diving Deeper: The Logic Inside the Gesture Pan Handler

One of the most complex parts of this project was handling the logic inside the Gesture Pan Handler. This is where the magic happens in terms of user interaction and slider movement. Let's break it down.

onUpdate Handler

The onUpdate handler is called every time the user moves their finger while the gesture is active. This is where we calculate the new position of the slider based on the user's drag.

First, we calculate the new Y position of the slider (toggleOffsetY) based on the current translation of the gesture (e.translationY) and the start position of the slider (start.value.y).

Next, we check if the new Y position is within the bounds of the slider container. If it's not, we adjust it to the nearest valid position. This ensures that the slider doesn't move outside of its container.

Finally, we update the offset shared value, which is used to animate the position of the slider.

.onUpdate(e => {
  const toggleOffsetY = e.translationY + start.value.y;
  const maxLimit = (sliderContainerHeight - sliderToggleHeight) * -1;

  if (toggleOffsetY > 0) {
    offset.value = {
      x: 0,
      y: 0,
    };
  } else if (toggleOffsetY < maxLimit) {
    offset.value = {
      x: 0,
      y: maxLimit,
    };
  } else {
    offset.value = {
      x: 0,
      y: toggleOffsetY,
    };
  }
})

onFinalize Handler

The onFinalize handler is called when the user releases their finger, ending the gesture. This is where we finalize the position of the slider and update the fanPower state.

First, we calculate the final Y position of the slider (finalOffsetY) based on the final translation of the gesture and the start position of the slider.

Next, we iterate over the sliderSteps object to find the two steps that the final Y position falls between. We then compare the final Y position to the values of these two steps to determine which one it's closer to.

The closer step is then used to calculate the final Y position of the slider (gestureEndOffsetY) and the new fan power value (fanPowerVal).

Finally, we update the offset and start shared values to the final Y position, set isPressed to false, and update the fanPower state using the runOnJS function.

.onFinalize(e => {
  const finalOffsetY = (e.translationY + start.value.y) * -1; // convert to positive number

  let gestureEndOffsetY;
  let fanPowerVal;
  // calculate which sliderStep value to move to

  // user drags toggle all the way beyond top
  if (finalOffsetY > sliderContainerHeight - sliderToggleHeight) {
    gestureEndOffsetY = sliderContainerHeight - sliderToggleHeight;
    fanPowerVal = numOfSliderSteps;
  }
  // compare finalOffsetY to sliderSteps values
  for (const [k, v] of Object.entries(sliderSteps)) {
    if (finalOffsetY < v) {
      const lowDiff = finalOffsetY - sliderSteps[k - 1];
      const highDiff = v - finalOffsetY;

      const sliderStepOffsetY =
        lowDiff <= highDiff ? sliderSteps[k - 1] : v;

      fanPowerVal = lowDiff <= highDiff ? k - 1 : k;

      gestureEndOffsetY = sliderStepOffsetY;
      break;
    } else if (finalOffsetY === v) {
      gestureEndOffsetY = v;
      fanPowerVal = k;
      break;
    }
  }
  offset.value = {
    x: 0,
    y: gestureEndOffsetY * -1,
  };
  start.value = {
    x: 0,
    y: offset.value.y,
  };
  isPressed.value = false;
  runOnJS(setFanPower)(fanPowerVal); // setState needs to be run outside of native stack
})

This logic ensures that the slider moves smoothly and snaps to the nearest step when the user releases their finger, providing a satisfying user experience. It also updates the fanPower state to reflect the position of the slider, allowing other parts of the app to react to the user's input.

Rendering the Slider

The slider was rendered using the Animated.View component from the react-native-reanimated library. This allowed me to animate the position of the slider based on the user's drag. The useAnimatedStyle hook was used to create an animated style that updates the position of the slider.

<Animated.View
  style={[styles.sliderContainer, {height: sliderContainerHeight}]}>
  <GestureDetector gesture={gesture}>
    <Animated.View
      style={[
        styles.sliderToggle,
        {height: sliderToggleHeight},
        animatedStylesSliderToggle,
      ]}
    />
  </GestureDetector>
  // other components here
</Animated.View>

Challenges and Solutions

The main challenge was to calculate the new position of the slider based on the user's drag. This was solved by creating a sliderSteps object that maps each step of the slider to a specific position. Then, in the onUpdate and onFinalize handlers of the gesture, I compared the final position of the user's drag to the values in the sliderSteps object to determine the new position of the slider.

Another challenge was to run a JavaScript function (setFanPower) from within the native thread. This was solved by using the runOnJS function from the react-native-reanimated library.

Leveraging 'Reanimated': A Superior Animation Library

In this project, we used the react-native-reanimated library to handle animations. This library is a powerful tool that provides a more robust and flexible API for animations compared to the built-in Animated library in React Native. Let's delve into why we chose 'react-native-reanimated' and how it benefited our project.

Why 'react-native-reanimated'?

React Native's built-in Animated library is a great tool for creating basic animations. However, it has some limitations when it comes to more complex animations and gestures. For instance, it doesn't support native-driven gestures out of the box, and running animations on the native thread can be a bit tricky.

On the other hand, 'react-native-reanimated' is designed to be fully compatible with gestures and animations running on the native thread. This means that it can handle complex animations and gestures with high performance and smoothness, which is crucial for a good user experience.

In our vertical slider component, we used react-native-reanimated to animate the position of the slider based on the user's drag. This was done using the useSharedValue and useAnimatedStyle hooks provided by the library.

The useSharedValue hook was used to create shared values (isPressed, start, offset) that can be used both in the JavaScript thread and the native thread. This allowed us to update these values in response to the user's drag and use them to animate the position of the slider.

The useAnimatedStyle hook was used to create an animated style that updates the position of the slider based on the shared values. This hook runs on the native thread, which means that the animation runs smoothly even if there are heavy computations happening on the JavaScript thread.

const animatedStylesSliderToggle = useAnimatedStyle(() => {
  return {
    transform: [
      {translateX: offset.value.x},
      {
        translateY: withTiming(offset.value.y, {
          duration: isPressed.value ? 0 : 500,
        }),
      },
    ],
    backgroundColor: isPressed.value ? '#f2f2f2' : '#fff',
  };
});

Conclusion

This assignment was a great opportunity to delve deeper into React Native and its capabilities. It was a challenging task, but the end result was a fully functional, interactive vertical slider. I hope this post provides some insight into the process and encourages you to try building your own custom components in React Native. Happy coding!