Making an Input Slider in React Native with the Animated and Panresponder APIs

rmunschie92

Ryan Munsch

Posted on December 3, 2020

Making an Input Slider in React Native with the Animated and Panresponder APIs

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 });
},
Enter fullscreen mode Exit fullscreen mode

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'
  }
});
Enter fullscreen mode Exit fullscreen mode

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.

Slider with no min/max offset

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;

  [...]
Enter fullscreen mode Exit fullscreen mode

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
};
Enter fullscreen mode Exit fullscreen mode

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, 
});
Enter fullscreen mode Exit fullscreen mode

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 });
  }
},
Enter fullscreen mode Exit fullscreen mode

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(); 
}
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
  }
}

Enter fullscreen mode Exit fullscreen mode

Now, below the handler's main if/else block:

  • Pass updatedOffsetX to the x value in this.state.animate.setValue()
  • Call this.setState(), updating the value of this.state.latestPosition to the the value of newPosition.
  • 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();
  }
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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;
  }
}
Enter fullscreen mode Exit fullscreen mode

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 {

  }
}
Enter fullscreen mode Exit fullscreen mode

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;
  }
}
Enter fullscreen mode Exit fullscreen mode

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.

Slider with no component radius compensation

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
};
Enter fullscreen mode Exit fullscreen mode

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 });
}
Enter fullscreen mode Exit fullscreen mode

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 });
  }
}
Enter fullscreen mode Exit fullscreen mode

Finished Slider

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.

💖 💪 🙅 🚩
rmunschie92
Ryan Munsch

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