Creating a Precision Slider: Volume Interaction Part — II
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.
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.
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.
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.
- The first stroke will appear when the value is greater than or equal to one. (≥1)
- The second, when the value is greater than or equal to 25. (≥ 25)
- 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:
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.
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
- parse() — this parses our path to make it work with reanimated
- 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:
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.
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!
Well, that’s it! Thanks for staying through 😂
See you all in the next blog!
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. :)