Gesture detection in React Native

Gestures are one of the most prevalent things on mobile. Without them our apps wouldn’t be able to function – nobody could navigate anywhere! React Native comes with basic gesture support and allows us to use TouchableOpacity and friends to create views which are tappable. But what about other gestures like panning or pinch-zooming? Those we have to implement on our own – but that’s not as bad as it sounds. In fact, implementing the two mentioned gestures is quite straight forward, so that’s what we are going to do! (This post will only show the panning gesture, pinch-zooming will follow in a future post)

We will discuss the following topics:

PanResponder and the gesture responder system #

The PanResponder and the gesture responder system are what we will be using to handle touches. The difference between those two is that the PanResponder provides a bit of sugar on top of responder system, mainly the gestureState argument which gets passed to the event handlers in addition to the event.

There are eleven handlers that we can attach to our view in order to be notified of events during the touch lifecycle – and we will be needing most of them! Check the docs which I’ve linked above for more details about each of them.

Finding a way to implement our gestures #

In order to implement our gestures, it would make sense to make them as generic as possible, so we can reuse them with different components. Using a higher order component or decorator is a good way to achieve this. It allows us to create an api like this:

MyComponent = makePannable(MyComponent);

Not bad, but how about combining multiple gestures:

MyComponent = makePannable(makePinchZoomable(MyComponent));

That is pretty nice, isn’t it? We can easily enhance our component with multiple gestures, as long as the gestures itself to not interfere with each other.

Since React Native switched over to Babel for code-transpilation, we can make use of the proposed ES7 decorator syntax:

@makePannable
@makePinchZoomable
class MyComponent extends React.Component {
 /* ... */ 
}

Now if that’s not a concise and clean way to enhance your components with gestures, I don’t know what is.

What is a decorator?

In case you are not familiar with the term, here is a quick explanation. A decorator, also called higher-order component, is a component, which wraps another component to enhance it in some way. The default pattern for a decorator is:

const myDecorator = BaseComponent => {
  return class extends Component {

    render() {
      return <BaseComponent {...this.props} {...this.state} />;
    }
  }
}

It is a function, which takes a component class as its argument and returns a new component class. The new class renders the original component, but is able to modify the props being passed down. The decorator component can apply whatever logic it pleases (firing requests in componentDidMount, listening to flux stores etc.).

Now that we know that too, let’s start implementing!

Building the panning decorator #

Here is the full code:

'use strict';

import React, { Component, View, PanResponder } from 'react-native';

export default BaseComponent => {
  return class extends Component {

    constructor(props, context) {
      super(props, context);

      this.lastX = 0;
      this.lastY = 0;

      this.state = {
        absoluteChangeX: 0,
        absoluteChangeY: 0,
        changeX: 0,
        changeY: 0
      };
    }

    componentWillMount() {
      this._panResponder = PanResponder.create({

        onStartShouldSetPanResponder: ({ nativeEvent: { touches } }, { x0, y0 }) => {
          const shouldSet = touches.length === 1;

          if (shouldSet) {
            const { onPanBegin } = this.props;
            onPanBegin && onPanBegin({
              originX: x0,
              originY: y0
            });
          }

          return shouldSet;
        },

        onMoveShouldSetPanResponder: ({ nativeEvent: { touches } }) => {
          return touches.length === 1;
        },

        onPanResponderMove: (evt, { dx, dy }) => {
          const { onPan } = this.props;
          const newState = {
            absoluteChangeX: this.lastX + dx,
            absoluteChangeY: this.lastY + dy,
            changeX: dx,
            changeY: dy
          };

          this.setState(newState);

          onPan && onPan(newState);
        },

        onPanResponderTerminationRequest: () => true,
        onPanResponderTerminate: this.handlePanResponderRelease,
        onPanResponderRelease: this.handlePanResponderRelease
      });
    }

    handlePanResponderRelease = () => {
      const { onPanEnd } = this.props;
      this.lastX = this.state.absoluteChangeX;
      this.lastY = this.state.absoluteChangeY;
      onPanEnd && onPanEnd();
    }

    render() {
      const {
        onPanBegin,
        onPan,
        onPanEnd,
        ...props
      } = this.props;

      return (
        <View {...this._panResponder.panHandlers}>
          <BaseComponent {...props} {...this.state} />
        </View>
      );
    }
  };
}

And now step by step:

Setting up the defaults #

constructor(props, context) {
  super(props, context);

  this.lastX = 0;
  this.lastY = 0;

  this.state = {
    absoluteChangeX: 0,
    absoluteChangeY: 0,
    changeX: 0,
    changeY: 0
  };
}

lastX and lastY will hold the absolute changes to x and y since the first pan occurred. These two values are not in state, as they are only for internal use. We need them to compute absoluteChangeX and absoluteChangeY correctly.

changeX and changeY will hold the distance the touch travelled since the last update. absoluteChangeX and absoluteChangeY on the other hand will contain the absolute change from the origin since the first pan interaction with the view. We will use the absolute values to apply a transform style to translate the view.

Creating the PanResponder instance #

In componentDidMount we create a PanResponder instance and assign it to this._panResponder so we can reference it later.

Claiming the responder status #

onStartShouldSetPanResponder: ({ nativeEvent: { touches } }, { x0, y0 }) => {
  const shouldSet = touches.length === 1;

  if (shouldSet) {
    const { onPanBegin } = this.props;
    onPanBegin && onPanBegin({
      originX: x0,
      originY: y0
    });
  }

  return shouldSet;
},

onMoveShouldSetPanResponder: ({ nativeEvent: { touches } }) => {
  return touches.length === 1;
},

If we want to become the responder for the, we have to return true from onStartShouldSetPanResponder. We only want to respond when we have a single touch, so we check the length of the touches array.

Depending on shouldSet, we try to call the onBegin callback with the initial coordinates.

If we want to receive updates when the touch moves, we need to return true from onMoveShouldSetPanResponder. We use the same check as before.

Processing touch movement #

onPanResponderMove: (evt, { dx, dy }) => {
  const { onPan } = this.props;
  const newState = {
    absoluteChangeX: this.lastX + dx,
    absoluteChangeY: this.lastY + dy,
    changeX: dx,
    changeY: dy
  };

  this.setState(newState);

  onPan && onPan(newState);
},

Each time the touch moves, this method will be called. We grab the dx and dy values from the second argument (the handy gestureState), which represent the distances the touch has moved in x and y direction since the last event.

We now calculate the absolute change using the previous position. The dx and dy values are just added verbatim to our new state object.

After setting the state, we then try to call the onPan callback with the new state. This is done in order to expose an api into which we can hook from the outside. We could use it for example to trigger a condition based on the absolute change values.

Allowing termination and handling touch termination / release #

    onPanResponderTerminationRequest: () => true,
    onPanResponderTerminate: this.handlePanResponderRelease,
    onPanResponderRelease: this.handlePanResponderRelease
  });
}

handlePanResponderRelease = () => {
  const { onPanEnd } = this.props;
  this.lastX = this.state.absoluteChangeX;
  this.lastY = this.state.absoluteChangeY;
  onPanEnd && onPanEnd();
}

In onPanResponderTerminationRequest we just return true – we don’t want to hog the touch in case another view wants to be the responder. This might happens when another view further up the hierarchy wants to become a responder to the touch.

We delegate onPanResponderRelease and onPanResponderTerminate to handlePanResponderRelease. There we assign the current absolute changes for the x and y positions to lastX and lastY respectively, so we can reference them again in the next panning action. In addition to that we call the onPanEnd callback, if available.

Rendering the component #

render() {
  const {
    onPanBegin,
    onPan,
    onPanEnd,
    ...props
  } = this.props;

  return (
    <View {...this._panResponder.panHandlers}>
      <BaseComponent {...props} {...this.state} />
    </View>
  );
}

We wrap the passed in BaseComponent with a view, which receives all handlers from our PanResponder instance using the spread operator. The BaseComponent receives all props – minus the callbacks – and our internal state as props.

The decorated/wrapped component can now do whatever it wants with the information it received from the decorator. Most likely it will be used to pan an element using the transform property of a style-object, but it could be used for anything really. (Color tweening, custom sliders, etc. come to mind).

Using the panning decorator #

Now that we have a fully working decorator, this is how we would use it:

@makePannable
class MyComponent {

  render() {
    const {
      absoluteChangeX, absoluteChangeY
    } = this.props;
    const transform = [
      {translateX: absoluteChangeX},
      {translateY: absoluteChangeY}
    ];

    return (
      <View style={{
        flex:1,
        overflow: 'hidden',
        backgroundColor:'#f00',
        transform,
      }}>
        <Text style={styles.text}>Round, round pan me around!</Text>
      </View>
    );
  }

}

And the result would be similar to this, expect much smoother (note that the onPan callback is used to change the background color of the main view based on the sub-views position):

panning a uiview around

Conclusion #

Using the PanResponder to track simple panning actions is a powerful tool for implementing your own gesture recognizers/decorators.

In the next post I will cover how to create a decorator which supports pinch-zooming.

Please note: due to the fact that we are wrapping a component within a view, there can be issues with this approach. Especially if a component does not fill up the whole screen and is moved using transform, it can result in the ability to move the view even though we are touching next to it. This is because the wrapping view is not being moved.

A possible solution to the problem would be to enhance the decorator to allow styling of the wrapper node and move the transformation code into a parent component. If I figure out a good way to do this I will publish the solution in another post.

Update: The article with a solution to the ghost-panning can be found here

Thanks to @notbrent for proof-reading.

One Response to “Gesture detection in React Native”

  1. […] my previous post on gesture recognition I described how to use the PanResponder to detect a panning gesture and how we can use it to […]

Leave a Reply

*