Making an Input Slider in React Native with the Animated and Panresponder APIs
Ryan Munsch
Posted on December 3, 2020
This is the second of a two-part series on the React Native PanResponder API. See the first part here.
Now that we have a draggable component, let’s update our logic to give it the desired “slider” input behavior. The first step here is quite simple, we simply want to disable the y
value from being updated when we move the Animated View component. To do this we’ll go into our onPanResponderMove
handler and update the y
value to 0
in this.state.animate.setValue()
:
// The user is moving their finger
onPanResponderMove: (e, gesture) => {
// Set value of state.animate x to the delta for x and y to 0
// to prevent vertical movement
this.state.animate.setValue({ x: gesture.dx, y: 0 });
},
With these changes made, refresh your simulator and try to move the component around the screen. Now you should be able to move it from along the screen’s x-axis, but any vertical movement should be disabled.
Next, let’s include styles for a basic slider. Back in Container.js
, start by wrapping the instance of <Movable />
in a View
component. Let’s give our new View
some basic styles to make it actually look something like an HTML range-type input; for now just a set width and height, a border, a border radius, and justify the View
content along the component’s center.
Your Container component should now look like this:
export class Container extends Component {
render() {
return (
<ScrollView contentContainerStyle={styles.container} canCancelContentTouches={false}>
<View style={styles.slider}>
<Movable />
</View>
</ScrollView>
);
}
}
const styles = StyleSheet.create({
container: {
height: vh(100),
backgroundColor: '#fff',
alignItems: 'center',
justifyContent: 'center'
},
slider: {
height: vh(1.5),
width: vw(85),
borderWidth: 1,
borderRadius: 25,
justifyContent: 'center'
}
});
Things should be looking pretty good by now, but there’s an issue - nothing stops the user from simply dragging <Movable />
right outside the horizontal boundaries of the component acting as the slider itself.
Now we’ll have to establish logic to keep <Movable />
within the bounds of its parent component. Since we set the width of the slider component as 85% of the viewport width, we can bet that is going to be the maximum value we can drag <Movable />
to along the x-axis. To get access to the maximum offset we want, pass maxOffset
as a prop to <Movable />
; in this case we’ll pass vw(85)
.
Before we move on, the value of props.maxOffset
will need to take the width of <Movable />
into account, otherwise our positioning will be off by the value of the component's radius. We'll store the radius value in a class property, this.componentRadius
. Simply set the value to half of the component's width.
We'll also set another class property, this.initialPosition
, the negation of this.componentRadius
. We'll use this value in a bit.
constructor(props) {
super(props);
// Get radius from StyleSheet rule
this.componentRadius = styles.movable.width * .5;
// Set initial position to negative value of component's radius
this.initialPosition = this.componentRadius * -1;
// Set property maxOffset to prop value minus component's radius
this.maxOffset = this.props.maxOffset - this.componentRadius;
[...]
With the value of props.maxOffset
in our Animated component, we'll have to incorporate the logic to actually limit movement beyond the bounds of the slider parent component. The logic for this is pretty straightforward, we need to get the x
offset of the component while it's being moved, and if it's less than 0
or is greater than this.maxOffset
, we don't want to allow the "movement", that is, the setValue()
called on the instance of this.state.animate
.
The tricky part of implementing this is actually getting the x
offset the user has dragged to. I calculated this by getting the sum of the change in the x offset and the most recent position of <Movable />
. Let’s start by adding new value in state, latestPosition
. This will keep track of the latest position of the component along the x-axis. We’ll need this value to determine if the component is still within bounds of its parent when it is being moved. We'll intialize it as the value of this.intialPosition
, to account for the width of the component.
// Initialize state
this.state = {
// Create instance of Animated.XY, which interpolates X and Y values
animate: new Animated.ValueXY(),
latestPosition: this.initialPosition
};
We'll also need to update the x
value that we pass in this.state.animate.setValue()
for the same reason - we need to consider the width of the component skewing the positioning of <Movable />
within the slider. We'll simply subtract the value of this.componentRadius
from the existing value of 0
.
// Initialize value of x and y coordinates
this.state.animate.setValue({
// Subtract half of the width of the component to account for positioning
x: 0 - this.componentRadius,
y: 0,
});
With our new state value and class properties in place, let's go back to onPanResponderMove()
and update the logic to perform what was described above to prevent movement out of bounds:
// The user is moving their finger
onPanResponderMove: (e, gesture) => {
// Get the final value that user has dragged to.
let finalOffset = gesture.dx + this.state.latestPosition;
// If finalOffset is within bounds of the slider, update state.drag to appropriate position
if (finalOffset >= 0 && finalOffset <= this.maxOffset) {
this.state.animate.setValue({ x: gesture.dx, y: 0 });
}
},
Now try saving, refreshing and moving the component around again. You'll see that the behavior is not there yet. We'll have to update the value of this.state.latestPosition
at the end of the touch event. In order to do so, we'll have to update onPanResponderRelease()
.
At this point, your code should simply have a call to this.state.animate.flattenOffset()
in onPanResponderRelease()
. Since we're about to make the logic progressively more involved, I'd recommend moving the business logic into a handler function and passing it as a callback. Don't forget to update the handler in PanResponder.create()
to execute our callback and pass the gesture object as an argument.
// Fired at the end of the touch
onPanResponderRelease: (e, gesture) => {
this.panResponderReleaseHandler(gesture)
}
[...]
/**
* Event handler for when panResponder touch event ends.
* @param {Object} gesture - The gestureState object passed as a param to each panResponder callback.
* @return {null} Updates local state.
*/
panResponderReleaseHandler = (gesture) => {
// Merges the offset value into the base value and resets the offset to
// zero
this.state.animate.flattenOffset();
}
At the top of the function we’ll declare the finalOffset
variable the same way we did in onPanResponderMove()
. We'll also keep the call to flattenOffset()
.
// Get the final x value that user has dragged to
let finalOffset = gesture.dx + this.state.latestPosition;
// Merges the offset value into the base value and resets the offset to
// zero
this.state.animate.flattenOffset();
Try console.log()
-ing the value of finalOffset
in the same handler function. If you refresh the simulator and drag the component roughly halfway to the other end of the slider, you should get a positive value (I got about 150
on the simulated iPhone 11 Pro Max with after passing vw(85)
to props.maxOffset
.
Before we go any further, we’re also going to need two more variables: one to eventually set as the x
value in this.state.animate.setValue()
, and a second to set as the updated value of this.state.latestPosition
. Let's call these updatedOffsetX
and newPosition
.
// Initialize value we'll use to update this.state.animate.x
let updatedOffsetX;
// Initialize value we'll use to update this.state.latestPosition
let newPosition;
With the variables we'll need declared, let's think about what we want to achieve with keeping the component within it's parent. We want to prevent the component from being dragged "out of bounds". To prevent this, we need to quantify what the numeric boundaries of the slider are.
For the left/minimum side of the slider, the value would simply be 0
. If the value of finalOffset
is less than or equal to 0
, we know that the user has reached the left edge of the slider.
For the right/maximum side of the slider, we can use the value of this.maxOffset
to determine if the user has dragged out of bounds. If the value of finalOffset
is greater than or equal to this.maxOffset
, we know we'll have to forcefully end the PanResponder
event.
Taking these "boundary" values into consideration, we know the finalOffset
of an "in bounds" drag would fall within the range of 0
and this.maxOffset
, so a conditional would look like:
// If drag is "in bounds"
if (finalOffset >= 0 && finalOffset <= this.maxOffset) {
// Handle logic for an in bounds drag here
}
Naturally, we'd expand this logic with a simple else
block to determine the logic we'd use for an "out of bounds" drag. We'll do that next:
// If drag is "in bounds"
if (finalOffset >= 0 && finalOffset <= this.maxOffset) {
// Handle logic for an in bounds drag here
}
// If drag is "out of bounds"
else {
// Handle logic here
}
With this logic in place we're fundamentally designating two types of drags of our component: one that is within the bounds of the slider container and one that is out of bounds.
Within each of these types of drags we have three more scenarios:
- The user drags and it ends up to the left of its beginning position.
- The user drags the component and it ends up to the right of its beginning position.
- The user drags it and it ends up in the exact same position.
Now let's determine the logic for each of these scenarios. We'll start with the user dragging to the left. We should be able to tell if the user has moved to the left if the value of gesture.dx
is negative. If this is the case, we'll set updatedOffsetX
to the negation of this.state.latestPosition - newPosition
. This will give us the value of how far the user dragged to the left from the position of the component prior to it being moved.
// If drag is in bounds
if (finalOffset >= 0 && finalOffset <= this.maxOffset) {
// Set newPosition to that of finalOffset
newPosition = finalOffset;
// If moved to the left
if (gesture.dx < 0) {
// Set udatedOffsetX to negation of state.latestPosition - newPosition
updatedOffsetX = (this.state.latestPosition - newPosition) * -1
}
}
Now, below the handler's main if/else
block:
- Pass
updatedOffsetX
to thex
value inthis.state.animate.setValue()
- Call
this.setState()
, updating the value ofthis.state.latestPosition
to the the value ofnewPosition
. - Move the call to
flattenOffset()
to the bottom of the function.
Your handler should now look something like this:
panResponderReleaseHandler = (gesture) => {
// Get the final value that user has dragged to.
let finalOffset = gesture.dx + this.state.latestPosition;
// Initialize value we'll use to update this.state.animate.x
let updatedOffsetX;
// Initialize value we'll use to update this.state.latestPosition
let newPosition;
// If drag is in bounds
if (finalOffset >= 0 && finalOffset <= this.maxOffset) {
// Set newPosition to that of finalOffset
newPosition = finalOffset;
// If moved to the left
if (gesture.dx < 0) {
// Set udatedOffsetX to negation of state.latestPosition - newPosition
updatedOffsetX = (this.state.latestPosition - newPosition) * -1
}
}
// If drag is "out of bounds"
else {
// Handle logic here
}
// Update x value of this.state.animate
this.state.animate.setValue({ x: updatedOffsetX, y: 0 });
// Update latestPosition
this.setState({ latestPosition: newPosition });
// Merges the offset value into the base value and resets the offset to zero
this.state.animate.flattenOffset();
}
With this current form of panResponderReleaseHandler()
, we'll simply add more conditional statements to handle our other cases.
The conditional for the component being moved to the right simply checks if gesture.dx
is positive; if it is we'll set updatedOffsetX
to the value of newPosition - this.state.latestPosition
. This gives us the distance that the user has moved the component to the right from the starting point of the touch event.
// If moved to the left
if (gesture.dx < 0) {
// Set udatedOffsetX to negation of state.latestPosition - newPosition
updatedOffsetX = (this.state.latestPosition - newPosition) * -1
}
// If moved to the right
else if (gesture.dx > 0) {
// Set updatedOffsetX to newPosition - this.state.latestPosition
updatedOffsetX = newPosition - this.state.latestPosition;
}
Finally, we need to add an else
block to handle the rare event that the user retuns to exactly the same spot along the slider. If that's the case we simply set updatedOffsetX
to 0
.
// If drag is in bounds
if (finalOffset >= 0 && finalOffset <= this.maxOffset) {
// Set newPosition to that of finalOffset
newPosition = finalOffset;
// If moved to the left
if (gesture.dx < 0) {
// Set udatedOffsetX to negation of state.latestPosition - newPosition
updatedOffsetX = (this.state.latestPosition - newPosition) * -1
}
// If moved to the right
else if (gesture.dx > 0) {
// Set updatedOffsetX to newPosition - this.state.latestPosition
updatedOffsetX = newPosition - this.state.latestPosition;
}
// If user returns to original position prior to this panResponder touch
else {
// Set updatedOffsetX to 0
updatedOffsetX = 0;
}
}
Now, test your progress. You should have everything working as long as you keep <Movable />
in bounds (you'll get an error if you drag out of bounds). If things look a little wacky after a couple of touch events, make sure you removed the call to flattenOffset()
before the conditional logic in panResponderReleaseHandler()
.
Now we're at the home stretch! The following logic handles out of bounds drags. Let's take it step-by-step.
In the else
block of our function's main conditional, we'll take a similar approach to the one we did with "in bound" movements. The main difference you'll see here is that we have no else if
logic because we'll have the same logic for drags to the right and back to the original position.
In the first conditional, we'll target drags to the right boundary by checking if the value of gesture.dx
is greater than 0
.
The logic here looks similar to how we handle drags to the right for in bound movements, but we set newPosition
to this.maxOffset
and updatedOffsetX
to the difference of this.maxOffset
and this.state.latestPosition
instead of newPosition
and this.state.latestPosition
.
// If drag is out of bounds
else {
// If gesture.dx is positive
if (gesture.dx > 0) {
// Set newPosition to maxOffset
newPosition = this.maxOffset;
// Set value to update offset x with to maxOffset - latestPosition
updatedOffsetX = this.maxOffset - this.state.latestPosition;
}
// If gesture.dx is the same or negative
else {
}
}
Now we just need to handle drags to the left edge and back to the starting position of the touch event. In the else
block, start by setting newPosition
to 0
. Then we need to check if the user was already at 0
, if they were set the value updatedOffsetX
to 0
, otherwise set it to the negation of this.state.latestPosition
.
// If gesture.dx is the same or negative
else {
// Set newPosition to 0
newPosition = 0;
// If already at zero
if (this.state.latestPosition <= 0) {
// Set updatedOffsetX to 0
updatedOffsetX = 0;
}
// Set value to update offset x with to negation of latestPosition
else {
updatedOffsetX = this.state.latestPosition * -1;
}
}
Now take a look at how everything is working. If you've followed along correctly, you shouldn't be getting any more errors when dragging <Movable />
out of bounds in either direction. There should just be one issue with the slider at this point: if you return to the left edge of the slider, it should look like the component doesn't quite go to the edge of the slider like it does on the right edge. This is because we need to take the radius of the component into consideration like we did when we inititialized this.initialPosition
in the constructor
.
We can compensate for the radius by subtracting this.componentRadius
from the value of updatedOffsetX
in the else
statement if the user isn't already at the left edge of the component. Instead of using this.state.latestPosition
to determine this, let's add a very explicit boolean value in local state, atMinValue
. Initialize it as false in the constructor
.
// Initialize state
this.state = {
// Create instance of Animated.XY, which interpolates X and Y values
animate: new Animated.ValueXY(),
latestPosition: this.initialPosition,
atMinValue: false
};
Back in else
block of the out of bounds conditional in panResponderReleaseHandler()
, we want to subtract the value of this.componentRadius
from updatedOffsetX
if we're not already at the minimum value. We'll also always set this.state.atMinValue
to true
at this point.
// Set value to update offset x with to negative value of latestPosition
else {
updatedOffsetX = (this.state.latestPosition * -1);
// If not already atMinValue
if (!this.state.atMinValue) {
// Subtract component radius from updatedOffsetX
updatedOffsetX -= this.componentRadius;
}
this.setState({ atMinValue: true });
}
With this solution in place, you should now be having the adverse issue with <Movable />
not quite looking like it's all the way at the value of this.maxOffset
on a drag all the way to the right. In the conditional for a positive move, we'll add the opposite logic to add the value of the component's radius to updateOffsetX
.
// If gesture.dx is positive
if (gesture.dx > 0) {
// Set newPosition to maxOffset
newPosition = this.maxOffset;
// Set value to update offset x with to maxOffset - latestPosition
updatedOffsetX = this.maxOffset - this.state.latestPosition;
// If coming from minValue/0
if (this.state.atMinValue) {
// Add component radius to updatedOffsetX
updatedOffsetX += this.componentRadius;
// Update state.atMinValue
this.setState({ atMinValue: false });
}
}
With this logic in place, our slider should be finished. Of course you'll likely want to add some additional logic to "snap" to a pressed location, to set a minimum and maximum value with new props, or to tailor the PanResponder
handlers to your own unique needs, but this should give you a good foundation to build off of to fit your needs.
Thanks for reading and for following along! :) Happy coding.
Posted on December 3, 2020
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
December 3, 2020