Search code examples
javascriptreact-nativeanimationreact-animated

React Native Animated - Start animation at touch location


I'm playing around with animation in React Native. Want to build a Screen that allows a user to touch the screen at any point. When they do, a circle should appear at the location of their finger, and follow it around. When they release, the circle should shrink back to nothing.

I started following this tutorial, which is for a spring-like animation, in which you drag a preexisting square (which starts in the center of the screen), and it springs back on release.

In my case, the circle should have no preexisting location, and this is causing the problem. If I initialize my component state, with, say, pan: new Animated.ValueXY(), and then touch the screen, the circle starts in the upper left corner, not where my finger is. I've tried a variety of other solutions, all replacing the commented line in the onPanResponderGrant method below, but can't seem to figure it out.

How do I have an animation that simply has the component track my finger without starting or returning anywhere?

Here's the code for the component:

class FollowCircle extends React.Component {
  constructor(props) {
    super(props);

    this.state = {
      pan: new Animated.ValueXY(),
      scale: new Animated.Value(0)
    };
  }

  componentWillMount() {
    this._panResponder = PanResponder.create({
      onStartShouldSetPanResponderCapture: () => true,
      onMoveShouldSetPanResponderCapture: () => true,

      onPanResponderGrant: (e, gestureState) => {
        // This is the line I'm playing with
        this.state.pan.setValue({ x: this.state.pan.x._value, y: this.state.pan.y._value });

        Animated.spring(
          this.state.scale,
          { toValue: 1, friction: 3 },
        ).start();
      },

      onPanResponderMove: Animated.event([
        null, { dx: this.state.pan.x, dy: this.state.pan.y }
      ]),

      onPanResponderRelease: (/* e, gestureState */) => {
        this.state.pan.flattenOffset();
        Animated.spring(
          this.state.scale,
          {
            toValue: 0,
            friction: 10,
            restDisplacementThreshold: 1
          },
        ).start();
      }
    });
  }

  render() {
    const { pan, scale } = this.state;
    const circleStyles = {
      backgroundColor: 'black',
      width: 50,
      height: 50,
      borderRadius: 25,
      transform: [
        { translateX: pan.x },
        { translateY: pan.y },
        { scale }
      ]
    };

    return (
      <AppWrapper navigation={ this.props.navigation }>
        <Animated.View
          style={ {
            flex: 1,
            width: Dimensions.get('window').width,
            height: Dimensions.get('window').height,
            backgroundColor: 'white'
          } }
          { ...this._panResponder.panHandlers }
        >
          <Animated.View
            style={ circleStyles }
          />
        </Animated.View>
      </AppWrapper>
    );
  }
};

And here are my alternative attempts (replacements for the commented line):

this.state.pan.setValue({ x: gestureState.x0, y: gestureState.y0 });

And this one:

  if (this.state.pan) {
    this.state.pan.setValue({ x: this.state.pan.x._value, y: this.state.pan.y._value });
  } else {
    this.setState({ pan: new Animated.ValueXY(gestureState.x0, gestureState.y0) });
  }

Both seem to behave similarly, resetting the circle location in the upper left after each movement, and not quite tracking my finger location unless I start my touch at that origin.

Any ideas how to do this correctly?


Solution

  • I ended up figuring this out by replacing the panResponder stuff with a page-sized TouchableHighlight, with an onTouch event.

    That event triggered the following function, which gets the nativeEvent.pageX and pageY location, and uses that. I had to make some slight alterations for the y location, to make it work across phones:

      triggerSwipe({ nativeEvent }) {
        const coordinates = { x: nativeEvent.pageX, y: nativeEvent.pageY };
        const self = this;
        if (!this.state.animating) {
          this.setState({
            x: coordinates.x,
            y: coordinates.y + (this.windowHeight / 2.5),
            animating: true
          }, () => {
            Animated.timing(
              self.state.scale,
              { toValue: 20, duration: 600 },
            ).start(() => {
              this.setState({
                scale: new Animated.Value(0.01), // See Notes.md for reasoning
                colorIndex: this.state.colorIndex + 1,
                animating: false
              });
            });
          });
        }
    

    The animating circle is an Animated.View inside the TouchableHighlight, which is fed the coordinates of the touch.