Gesture detection in React Native – fixing unexpected panning

In my previous post on gesture recognition I described how to use the PanResponder to detect a panning gesture and how to use it to transform a view’s position.

That method had an unfortunate flaw though: because we are wrapping the decorated component inside a View, it can happen that touches are registered where they shouldn’t be: outside of the wrapped view. This happens because the wrapper view – by default – stretches across the whole screen due to flexbox. And when we translate the wrapped view with a transform, the wrapper view stays in place, which leads to this “ghost-touch” behavior.

Thankfully the solution to this problem is not very hard. Instead of transforming the wrapped view, we transform the wrapper view instead!

Updating the panning decorator #

We have to make a few adjustments to our decorator for this. Here they are:

New default values #

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

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

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

We have to add the decoratedViewWidth and decoratedViewHeight properties to our state. Those will be used to adjust the size of the wrapper view to fit the wrapped view.

Detecting the wrapped view’s dimensions #

componentDidMount() {
  setTimeout(() => {
    UIManager.measure(React.findNodeHandle(this.refs.decorated), (x, y, w, h) => {
      this.setState({
        decoratedViewHeight: h,
        decoratedViewWidth: w
      });
    }, 0);
  });
}

As soon as the component is mounted, we measure the wrapped component. The ref we are using for this will be assigned in render. After receiving the dimensions we set them in our state. The measurement has to be done within a setTimeout because else measure() will only return zero values.

Updating the render function #

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

  const state = {
    decoratedViewWidth: width,
    decoratedViewHeight: height,
    ...rest
  } = this.state;

  const style = {
    ...panningDecoratorStyle,
    width,
    height
  };

  return (
    <View {...this._panResponder.panHandlers} style={style}>
      <BaseComponent ref="decorated" {...props} {...rest} />
    </View>
  );
}

We now support a new prop – panningDecoratorStyle. This prop can be used to style the wrapper view. The width and height properties will be overwritten though, as we set them to our previously measured values.

The updated decorator #

Here is the whole code for the updated decorator:

'use strict';

import React, { Component, View, PanResponder, NativeModules } from 'react-native';
const { UIManager } = NativeModules;

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,
        decoratedViewWidth: null,
        decoratedViewHeight: null
      };
    }

    componentDidMount() {
      setTimeout(() => {
        UIManager.measure(React.findNodeHandle(this.refs.decorated), (x, y, w, h) => {
          this.setState({
            decoratedViewHeight: h,
            decoratedViewWidth: w
          });
        });
      }, 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,
        panningDecoratorStyle,
        ...props
      } = this.props;

      const state = {
        decoratedViewWidth: width,
        decoratedViewHeight: height,
        ...rest
      } = this.state;

      const style = {
        ...panningDecoratorStyle,
        width,
        height
      };

      return (
        <View {...this._panResponder.panHandlers} style={style}>
          <BaseComponent ref="decorated" {...props} {...rest} />
        </View>
      );
    }
  };
}

Translating the view #

Instead of transforming the wrapped view, we will transform the wrapper. We create a new component for this:

class TransformOnPan extends Component {

  constructor(props, context) {
    super(props, context);
    this.state = {
      transform: null
    }
  }

  onPan = ({ absoluteChangeX, absoluteChangeY, changeX, changeY}) => {
    this.setState({
      transform: [
        {translateX: absoluteChangeX},
        {translateY: absoluteChangeY}
      ]
    });
  }

  render() {
    const { transform } = this.state;
    return (
      <DecoratedWithPanning
        panningDecoratorStyle={{transform}}
        onPan={this.onPan}
      />
    );
  }
}

This component takes care of rendering our decorated component and registers a handler for onPan. Whenever this callback is called, we will update this.state.transform and re-render, while passing the transform to panningDecoratorStyle.

With the above changes we can now transform a view using translate and avoid ghost-panning.

Here is a before and after comparison:

ghost panning before fix no ghost panning after fix

An alternate solution for detecting the child view’s size #

In the above solution we relied on UIManager and did some setTimeout trickery to get the view size. Instead of doing that we could also pass a method to onLayout of the wrapped view and update our view’s size using the event data:

onLayout = ({ nativeEvent: { layout : { width, height } } }) => {
  this.setState({
    decoratedViewHeight: height,
    decoratedViewWidth: width
  });
}

The upside is that we do not need to use setTimeout or a ref to access the child view. The downside is that we have to pass the onLayout method to the wrapped view:

return (
  <View {...this._panResponder.panHandlers} style={style}>
    <BaseComponent {...props} {...rest} onLayout={this.onLayout} />
  </View>
);

That itself is not a problem, but the user now has to pass this prop as their view’s onLayout callback. If this assignment is omitted, the decorator will again not work properly.

Conclusion #

Both these methods help us to get rid of ghost-panning and both are valid. Using the UIManager seems a bit hacky, but does not require any additional action from the user. onLayout seems cleaner, but requires the user to be more careful when using the decorator. Use whichever method you are more comfortable with.

This issue makes it tricky to compose multiple gesture decorators together. Each decorator has to resize itself, which can lead to race conditions. This is something that needs further investigation. But for a single decorator, this fix will work.

2 Responses to “Gesture detection in React Native – fixing unexpected panning”

  1. […] Update: The article with a solution to the ghost-panning can be found here […]

  2. […] my last post about gesture decorators I came up with a rather crude solution to fix the ghost panning issue. It turns out that there is a […]

Leave a Reply

*