Creating a Precision Slider: Volume Interaction Part — II

Karthik Balasubramanian
Timeless
8 min readMar 25, 2023

--

Hey folks!

This is a continuation of my previous blog where we would have the basic structure of the Slider.

Now in this blog we will work on getting the Gesture Interaction.

Cover Image by Fayas fs

Extending the Gesture to Map Horizontal X-Coordinate to Width

We would have created a basic panGesture which would have handled the slider active state.

Now to work the interaction, I have some drawings to explain what we are exactly going to do.

Image Explaining the Gesture to understand interpolate()

So, what I wanted to say is that the event object of the onChange callback will have a variable x which is the X coordinate of the current position of the pointer.

The X coordinate where the component starts is 0 and the X coordinate where the component ends is equal to the width of the component.

While our gesture is inside this range, we would like to change the values of the slider. In this case, to make things simpler, I have assumed that the slider value would move from 0 to 100.

With this, we can easily map the gesture to the slider values using the interpolate function.

For peeps who wants to understand, what is the use of interpolate function. Let me explain.

Interpolation is like, given a set of known values, we can get the extended values.

From the Reanimated Docs:

Sometimes you need to map a value from one range to another. This is where you should use the interpolate function which approximates values between points in the output range and lets you map a value inside the input range to a corresponding approximation in the output range. It also supports a few types of Extrapolation to enable mapping outside the range.

It is very simple. We get the interpolated value, round it to the closest number and set it to a SharedValue variable, let's name it sliderState, using the withSpring(). The updated Pan Gesture code will look something like this:

const panGesture = Gesture.Pan()
.onBegin(event => {
sliderActive.value = withSpring(1);
startXDistance.value = event.x;
})
.onChange(event => {
const { x } = event;
if (x >= min_x && x <= max_x) {
currentXDistance.value = x;
const interpolatedValue = interpolate(
currentXDistance.value,
[min_x, startXDistance.value, max_x],
[0, startingWidth.value, 100],
);
const computedValue = Math.round(interpolatedValue);
sliderState.value = withSpring(computedValue, {
damping: 20,
stiffness: 120,
mass: 1,
});
}
})
.onEnd(() => {
startingWidth.value = sliderState.value;
})
.onFinalize(() => {
sliderActive.value = withSpring(0);
});

If you look closely, we will have the startXDistance mapped to the startingWidth which means every time the gesture gets initiated, we set the start X Coordinate to the Current Filled Width value.

So the interpolate function knows that, when we move on the left side, the values will be lesser than startingWidth and if we move on the right it is more.

Image to understand the interpolation

That wraps our interpolation.

We add a new <Animated.View /> positioned absolute and placed it inside the Slider. This View is responsible for the active state of the Slider. Let us use the useAnimatedStyle() to set the width with the sliderState.

  const filledSlider = useAnimatedStyle(() => {
return {
width:
sliderState.value >= 0 ? `${sliderState.value}%` : "0%",
};
});

With this we get our interaction something like this:

Use sliderState to update other pieces of the Interaction

The sliderState Shared Value variable stores the current slider value, which is in between 0 and 100, so we can pass this value to our SVG and update their states.

The Volume Stroke SVG

We already know there are three Volume Stroke Paths. Using the sliderState value, we can decide when the stroke should appear.

We will be manipulating the opacity and scale properties of <Path />.

At first, we will create an AnimatedPath using Animated.createAnimatedComponent.

const AnimatedPath = Animated.createAnimatedComponent(Path);

Now we can pass an Animated Style Object to the component from the useAnimatedStyle().

The updated `VolumeStroke` component will look something like this:

As we have three strokes, we can make them appear in different values of the sliderState value.

  1. The first stroke will appear when the value is greater than or equal to one. (≥1)
  2. The second, when the value is greater than or equal to 25. (≥ 25)
  3. The third, when the value is greater than 60. (> 60)

The updated component will look something like this:

type VolumeStrokesProps = {
sliderCurrentValue: SharedValue<number>;
};

const VolumeStrokes = ({ sliderCurrentValue }: VolumeStrokesProps) => {
const lowStrokeStyle = useAnimatedStyle(() => {
return {
opacity: sliderCurrentValue.value >= 1 ? withSpring(1) : withSpring(0),
transform: [
{
scale:
sliderCurrentValue.value >= 1 ? withSpring(1) : withSpring(0.9),
},
],
};
});
const mediumStrokeStyle = useAnimatedStyle(() => {
return {
opacity: sliderCurrentValue.value > 25 ? withSpring(1) : withSpring(0),
transform: [
{
scale:
sliderCurrentValue.value >= 25 ? withSpring(1) : withSpring(0.9),
},
],
};
});
const normalStrokeStyle = useAnimatedStyle(() => {
return {
opacity: sliderCurrentValue.value > 60 ? withSpring(1) : withSpring(0),
transform: [
{
scale:
sliderCurrentValue.value >= 60 ? withSpring(1) : withSpring(0.9),
},
],
};
});

return (
<View
style={[
tailwind.style("flex flex-row items-center justify-center"),
{ transform: [{ translateX: -4 }] },
]}
>
<Svg
width="3"
height="9"
viewBox="0 0 3 9"
fill="none"
style={{ transform: [{ translateY: -0.5 }] }}
>
<AnimatedPath
d="M0.380841 8.70504C0.758771 8.95113 1.25096 8.86324 1.52342 8.47652C2.23533 7.5273 2.65721 6.18258 2.65721 4.78511C2.65721 3.38765 2.23533 2.05172 1.52342 1.09371C1.25096 0.70699 0.758771 0.619099 0.380841 0.873982C-0.049823 1.15523 -0.128925 1.67379 0.213849 2.18355C0.697247 2.87789 0.969708 3.80953 0.969708 4.78511C0.969708 5.7607 0.697247 6.68355 0.213849 7.38668C-0.128925 7.89644 -0.049823 8.415 0.380841 8.70504Z"
fill="#A09FA6"
style={lowStrokeStyle}
/>
</Svg>
<Svg width="4" height="14" viewBox="0 0 4 14" fill="none">
<AnimatedPath
d="M0.922639 13.0602C1.32694 13.3063 1.81912 13.2184 2.10037 12.8054C3.24295 11.1882 3.91092 9.01727 3.91092 6.78485C3.91092 4.55242 3.25174 2.37274 2.10037 0.764338C1.81912 0.351252 1.32694 0.254572 0.922639 0.509455C0.500764 0.773127 0.43924 1.30047 0.755646 1.76629C1.68729 3.13738 2.22342 4.92156 2.22342 6.78485C2.22342 8.63934 1.6785 10.4235 0.755646 11.8034C0.448029 12.2692 0.500764 12.7878 0.922639 13.0602Z"
fill="#A09FA6"
style={mediumStrokeStyle}
/>
</Svg>
<Svg width="5" height="18" viewBox="0 0 5 18" fill="none">
<AnimatedPath
d="M0.48256 17.4425C0.869279 17.6974 1.38783 17.5831 1.66908 17.1612C3.21596 14.8409 4.1476 11.9581 4.1476 8.79404C4.1476 5.62118 3.19838 2.74716 1.66908 0.418059C1.38783 -0.0126051 0.869279 -0.118074 0.48256 0.136809C0.0606854 0.40927 -0.000838012 0.927825 0.289201 1.39364C1.63393 3.4415 2.46889 5.98154 2.46889 8.79404C2.46889 11.589 1.63393 14.1466 0.289201 16.1856C-0.000838012 16.6515 0.0606854 17.17 0.48256 17.4425Z"
fill="#A09FA6"
style={normalStrokeStyle}
/>
</Svg>
</View>
);
};

Now the Interaction would have improved to look like this:

Updated Interaction with Strokes Appearing on different state of Slider

Getting the Diagonal Cross to Indicate the Volume is Muted

This was more of a tricky part for me because there is no transform-origin in React Native and the rotated <View /> height should increase diagonally.

Volume Mute State

Well anyway I figured out a way to do it, we can interpolate an SVG Path in React Native, using react-native-redash.

So now we need to create an SVG Path resembling a diagonal Line.

With the help of this website, I could create two paths. One is the initial point and the other is the diagonal line.

We will be using two functions from react-native-redash

  1. parse() — this parses our path to make it work with reanimated
  2. interpolatePath() — similar to interpolate() it interpolates paths from a given input range to an output range.

So with this, our first path will look something like this:


// Inside the Speaker Icon
// Parsing the Path for Reanimated
const defaultPath = parse("M 4 4 L 0 0");
const extendedPath = parse("M 4 4 L 19 19");

// Creating Animated Props to pass it to the Path
const innerPathAnimatedProps = useAnimatedProps(() => {
const d = interpolatePath(
sliderCurrentValue.value,
[1, 0],
[defaultPath, extendedPath],
);
const opacity = interpolate(sliderCurrentValue.value, [1, 0], [0, 1]);

return {
d,
opacity,
};
});

// Inside the render
<View style={tailwind.style("absolute left-[3px]")}>
<Svg width="25" height="25" viewBox="0 0 25 25" fill="none">
<AnimatedPath
stroke="#A09FA6"
strokeWidth="3"
strokeLinecap="round"
animatedProps={innerPathAnimatedProps}
/>
</Svg>
</View>

We place the Path positioned absolutely to the Speaker Icon.

With the above code, we get something like this:

Volume Mute State with Just One Path

But if you see there will be a border to the stroke in the original interaction.

There was a development bottleneck to this. So I ended placing another SVG Path with it.

// Creating Animated Props to pass it to the Path  
const outerPathAnimatedProps = useAnimatedProps(() => {
const d = interpolatePath(
currentFill.value,
[1, 0],
[defaultPath, extendedPath],
);
const opacity = interpolate(currentFill.value, [1, 0], [0, 1]);
const stroke = interpolateColor(
sliderActive.value,
[0, 1],
["#141414", "#232326"],
);
return {
d,
opacity,
stroke,
};
});

// Inside the render
<View style={tailwind.style("absolute left-[3px]")}>
<Svg width="25" height="25" viewBox="0 0 25 25" fill="none">
<AnimatedPath
stroke="#141414"
strokeWidth="5"
strokeLinecap="round"
animatedProps={outerPathAnimatedProps}
/>
</Svg>
</View>

With this we will get the required Stroke, and the Interaction will look good.

Final Mute Animation

Well, that is it! We have successfully built the interaction from scratch.

Now I will be doing some design level tweaks here and there, and work with the spring configs to get a final best version of the Interaction.

Designed by Sandeep Prabhakaran, here you go, the Final Interaction!

The Final Interaction

Well, that’s it! Thanks for staying through 😂

See you all in the next blog!

This is Karthik from Timeless

I hope you found this blog post helpful and informative. If you have any feedback or suggestions, please leave a comment below. I’d love to hear your thoughts and opinions on the topic.

And if you have any ideas for future blog posts, let me know! I’m always looking for new and interesting topics to explore.

Thank you for reading, and I look forward to hearing from you. :)

--

--