loading...
Cover image for Building a Movable Animated Component in React Native

Building a Movable Animated Component in React Native

rmunschie92 profile image Ryan Munsch ・9 min read

While building out the front end of a mobile application for a large client, my team and I found ourselves in need of a React Native input component similar to an HTML range-type input. The desired functionality was for a user to pick a single value from 0-10 along a range input by either “sliding” the input cursor or pressing on a section of the component. We started calling this hypothetical component a “slider” or a “slider picker”, so that’s the language I’ll be using from here on out in regards to it.

We struggled to find an npm package for this functionality that got along with our existing stack and the version of Expo we were forced to use at the time, so I was tasked with building out the component. I spent a couple of days building out a rudimentary first version that was not great. It didn’t really slide so much as it allowed presses within the dimensions of the component, but we needed to get the app’s MVP out the door so this was our reality for now.

Eventually, there was time for me to refactor the existing component. Using the React Native Animated and PanResponder APIs, I was able to make a proper slider component that I was proud of.

In this first of two articles I’ll walk you through the process of building a moveable React Native component that the user can “drag” around the screen, and in doing so we’ll develop a foundational understanding of the Animated and PanResponder React Native APIs. In a subsequent article, I'll be walking through the steps to convert a simple moveable component into an actual slider input.

A bare-bones look at what we’ll build

Finished product of component

Getting started

When I’m prototyping for React Native, I like to do it in a sample repo with as little overhead as possible; I recommend doing the same before integrating the slider into any project.

The only dependency necessary for building out our slider is react-native-css-vh-vw, a ditto of the vh and vw units in CSS for React Native. This package itself has no dependencies and at the time of writing this is under 4kb.

Let’s start with installing our sole dependency. At the root of your project, run the following in your terminal:

npm install react-native-css-vh-vw

Now let’s go ahead and start with some boilerplate in Movable.js.

import React, { Component } from "react";
import { Animated, PanResponder, StyleSheet } from "react-native";
import { vh, vw } from 'react-native-css-vh-vw';

export class Movable extends Component {
 constructor(props) {
   super(props);

   // Initialize state
   this.state = {
     // Create instance of Animated.XY, which interpolates
     // X and Y values
     animate: new Animated.ValueXY() // Inits both x and y to 0
   };

   // Set value of x and y coordinate
   this.state.animate.setValue({ x: 0, y: 0 });

   [...] 
 }
Enter fullscreen mode Exit fullscreen mode

Here in Movable.js, we’ve got our boilerplate along with the first bit of logic we need for getting going with Animated and PanResponder.

While we’re at it, let’s make a component that will act as the container/screen for Movable.js:

import React, { Component } from 'react';
import { StyleSheet, View } from 'react-native';
import { vh, vw } from 'react-native-css-vh-vw';

import { Movable } from '../components/Generics/Movable';

export class Container extends Component {
 render() {
   return (
     <View style={styles.container}>
       <Movable />
     </View>
   );
 }
}

const styles = StyleSheet.create({
 container: {
   height: vh(100),
   backgroundColor: '#fff',
   alignItems: 'center',
   justifyContent: 'center',
 }
});
Enter fullscreen mode Exit fullscreen mode

First, we have some configuring to do with the Animated API - in the component’s constructor we initialize this.state.animate, setting it to new Animated.ValueXY() (docs here) and immediately call setValue() on it. When this eventually gets passed to our instance of <Animated.View> it sets the x/y position of the component when it is rendered, relative to any styling you may place on it through the style prop. For example, I could execute this.state.animate.setValue({ x: 200, y: -150 }); and when the component first renders, it will be positioned 200 pixels to the right and 150 above the center of the screen (because the parent container is styled to position content vertically and horizontally).

Note: In addition to React Native’s official docs, I also found this page from https://animationbook.codedaily.io/ helpful in understanding Animated.ValueXY().

 [...]

   // Initialize panResponder and configure handlers
   this._panResponder = PanResponder.create({
     //     
     // Asks to be the touch responder for a
     // press on the View
     //
     onMoveShouldSetPanResponder: () => true,
     //
     // Actions taken when the View has begun
     // responding to touch events
     //
     onPanResponderGrant: () => {
       //
       // Set offset state.animate to prevent
       // Animated.View from returning to 0      
       // coordinates when it is moved again.
       //
       this.state.animate.setOffset({
         x: this.state.animate.x._value,
         y: this.state.animate.y._value
       });
       //
       // Set value to 0/0 to prevent AnimatedView
       // from "jumping" on start of
       // animate. Stabilizes the component.
       //
       this.state.animate.setValue({x: 0, y: 0})
     },
     //
     // The user is moving their finger
     //
     onPanResponderMove: (e, gesture) => {
       //
       // Set value of state.animate x/y to the
       // delta value of each
       //
       this.state.animate.setValue({
         x: gesture.dx,
         y: gesture.dy
       });
     },
     //
     // Fired at the end of the touch
     //
     onPanResponderRelease: () => {
       //
       // Merges the offset value into the
       // base value and resets the offset
       // to zero
       //
       this.state.animate.flattenOffset();
     }
   });
} // End of constructor

  render() {
   return (
     <Animated.View
       // Pass all panHandlers to our AnimatedView
       {...this._panResponder.panHandlers}
       //
       // getLayout() converts {x, y} into 
       // {left, top} for use in style
       //
       style={[
         this.state.animate.getLayout(),
         styles.button
       ]}
     />
   )
 }
Enter fullscreen mode Exit fullscreen mode

Copy and paste the contents of the above code block into Movable.jsand open up the iOS simulator on your machine (for now let’s ignore the actual contents of the code and make sure it’s working as expected). Your output should look very close to the gif above and have the same functionality.

With the simple movable component working, let’s take a further look into the code that provides us with the desired functionality.

For now, disregard any of the code that is within the render() function; we’ll get to that in a bit.

Divert your attention to the code we’ve added to the component’s constructor(). We’ve created an instance of PanResponder and configured its handlers. The documentation for the API is a bit confusing in my opinion (the Usage Pattern section is helpful), but the main takeaway is that it converts a series of touches into a single gesture, so that “drag” of a finger is really a series of several touch events that are consolidated into one. To achieve this functionality, the API uses a tweaked version of the Gesture Responder System.

To use PanResponder, you’ll need to create an instance and pass an object of key/value pair handlers. Reference the code block above to see the behavior and usage of each handler needed for a simple draggable component. We’ll be passing an arrow function to each handler to set the desired behavior. Let’s walk through that configuration object, one handler at a time.

Note: We won’t be using every PanResponder handler available.

The first key we’ll pass to PanResponder.create() is onMoveShouldSetPanResponder, which simply returns true. You can think of this as telling the View with the handler that it can “claim” the touch event taking place.

Second is onPanResponderGrant: this is where any logic will take place that we want executed when the View has started responding to touch events. Here, we need to call two functions for our instance of AnimatedValue stored in this.state.animate - setOffset() and setValue().

According to the React Native docs, setOffset():

“Sets an offset that is applied on top of whatever value is set, whether via setValue, an animation, or Animated.event. Useful for compensating things like the start of a pan gesture.” In the context of our example, we pass an object with a key/value pair for both x and y to setOffset(), each value the respective one of this.state.animate at the time when the touch event has begun.

this.state.animate.setOffset({
  x: this.state.animate.x._value,
  y: this.state.animate.y._value
});
Enter fullscreen mode Exit fullscreen mode

Now when the touch event begins, an update to the value of this.state.animate accounts for the value of its most recent x and y offsets; without this the values would update in relation to the values for x and y that you set when you first called this.state.animate.setValue() in the constructor (in our case x: 0,y: 0). To see what this logic really offers our little app, let’s remove it. Comment out this line of code, refresh your Simulator, and try moving around the component again and see how behavior changes.

Component with incorrect setOffset() logic

Still within onPanResponderGrant, we execute this.state.animate.setValue() once again passing an object with a key/value pair for both x and y, this time with them both set to zero. Let’s take a quick look at what the React Native docs have to say about this method: “Directly set the value. This will stop any animations running on the value and update all the bound properties.” To use layman’s terms, this prevents the possibility of our animated component from “jumping” around the container at the beginning of a touch event. At this point, calling setValue() here is more of a precautionary measure, but it will have clearer implications when we start converting this component into an actual Slider. Like with setOffset(), let’s tweak this line of code to get a better sense of what it does: change the x value to 1000 and the y value to 50, refresh your Simulator, and try moving the component around again.

Component with incorrect setOffset() logic

The next PanResponder handler is onPanResponderMove, the handler for when the user moves their finger during the touch event. For this handler, we’ll be using one of the two parameters that can be passed to each PanResponder handler, gestureState. The other parameter is nativeEvent, which we must still pass even though we won’t be using it (see the PanResponder docs for a detailed look at each parameter). We’re going to take two values within gestureState and pass them to this.state.animate.setValue(), gestureState.dx and gestureState.dy.

// The user is moving their finger
onPanResponderMove: (e, gesture) => {
  // Set value of state.animate x/y to the delta value of each
  this.state.animate.setValue({ x: gesture.dx, y: gesture.dy });
},
Enter fullscreen mode Exit fullscreen mode

These two values are relatively straightforward; according to the docs, they represent “accumulated distance of the gesture since the touch started” for each respective axis. Let’s say you move the component 50 pixels to the left, gesture.dx will have a value of -50. If you move the component 125 pixels towards the bottom of the device’s screen, gesture.dy will have a value of 125. Gestures of the same distance in the opposite direction would be 50 and 125 respectively.

The final PanResponder handler used is onPanResponderRelease, which is fired at the end of the touch when the user lifts their finger. In cases where you’d like to set a local state value or execute a callback to hoist state to a parent container, this is likely the best handler to do it in. For now, we simply call flattenOffset() on this.state.animate - according to the docs, this “merges the offset value into the base value and resets the offset to zero.”[1] This merge happens without changing the output value of the animation event. If we remove flattenOffset() the first one or two drags of our component look ok, but continued drags will show the problem with not resetting the offset value.

No flattenOffset() example

With our PanResponder handlers configured, let’s turn our attention to what’s going on inside of our render() function.

Instead of using a normal View component, we use the Animated.View component and pass all the PanResponder handlers as props using an object with the JavaScript spread operator. Finally, we pass an array to the component’s style prop with whatever styles you’d like to apply to your component (I’ve included mine below), along with a call to this.state.animate.getLayout() - which converts the Animated instance’s x and y values to left and top style values respectively.

const styles = StyleSheet.create({
  button: {
    width: vw(6),
    height: vw(6),
    borderRadius: vw(100), * .5,
    borderWidth: 1,
    backgroundColor: 'blue'
  }
});
Enter fullscreen mode Exit fullscreen mode

Now we have a fully moveable React Native component! It’s not much at this point, but feel free to continue along in the following article to finish building out our Slider component.

Thanks for reading! The second article is coming very soon!

Discussion

pic
Editor guide