Sunday, November 5, 2017

React-Native why are the animations not linear and from the released place?

Leave a Comment

I am trying to build a simple drag-and-drop with animation when releasing the piece to its original square

enter image description here

The goal is simply to drag coins and, when releasing them, they go back to their cells. But the animations for the pieces return are a bit strange. For example if you drag the red coin into the bottom-right cell, then the animation starts from the bottom-left cell and does not go into a straight line !

This is the code of the page, which can be directly integrated in your RN app, if you have the same package.json as the following one :

import React, { Component } from 'react'; import { StyleSheet, View, Animated, PanResponder, Easing } from 'react-native'; import _ from 'underscore';  class Square {     constructor(value, origin, cellsSize) {         this.value = value;         this.pan = new Animated.ValueXY();         this.cellsSize = cellsSize;         this.boardSize = 3 * this.cellsSize;         this.minXY = this.cellsSize * (0.5);         this.maxXY = this.cellsSize * (1.5);         this.midXY = this.cellsSize;         this.origin = origin;         this.constrainedX = this.pan.x.interpolate({             inputRange: [this.minXY, this.midXY, this.maxXY],             outputRange: [this.minXY, this.midXY, this.maxXY],             extrapolate: 'clamp',         });         this.constrainedY = this.pan.y.interpolate({             inputRange: [this.minXY, this.midXY, this.maxXY],             outputRange: [this.minXY, this.midXY, this.maxXY],             extrapolate: 'clamp',         });          const x = parseInt(this.cellsSize * (0.5 + this.origin.file));         const y = parseInt(this.cellsSize * (0.5 + this.origin.rank));          this.pan.setValue({ x, y });         this.panResponder = this._buildPanResponder();     }      get valueString() {         return this.value;     }      get panRef() {         return this.pan;     }      get panResponderRef() {         return this.panResponder;     }      _buildPanResponder() {         return PanResponder.create({             onStartShouldSetPanResponder: () => true,             onPanResponderGrant: (event, gestureState) => {                 this.pan.setOffset({ x: this.pan.x._value, y: this.pan.y._value });             },             onPanResponderMove: (event, gestureState) => {                 this.pan.setValue({ x: gestureState.dx, y: gestureState.dy });             },             onPanResponderRelease: (event, gesture) => {                 const nativeEvent = event.nativeEvent;                  const origX = parseInt(this.cellsSize * (this.origin.file + 0.5));                 const origY = parseInt(this.cellsSize * (this.origin.rank + 0.5));                  Animated.timing(                     this.pan,                     {                         toValue: { x: origX, y: origY },                         duration: 400,                         delay: 0,                         easing: Easing.linear                     }                 ).start();                  this.pan.flattenOffset()             }         });     } }  export default class TestComponent extends Component {      constructor(props) {         super(props);          this.cellsSize = 100;          this.squares = [             new Square('red', { file: 1, rank: 0 }, this.cellsSize),             new Square('green', { file: 0, rank: 1 }, this.cellsSize),             new Square('blue', { file: 1, rank: 1 }, this.cellsSize),         ];     }      renderACoin(value, file, rank) {         if (value) {             let style;             switch (value.valueString) {                 case 'red': style = styles.redCoin; break;                 case 'green': style = styles.greenCoin; break;                 case 'blue': style = styles.blueCoin; break;             }              const randomKey = parseInt(Math.random() * 1000000).toString()              return (                 <Animated.View key={randomKey} style={StyleSheet.flatten([style,                     {                         left: value.constrainedX,                         top: value.constrainedY,                     }])}                     {...value.panResponderRef.panHandlers }                 />             );         }     }      renderAllCoins() {         return _.map(this.squares, (currSquare) => {             return this.renderACoin(currSquare, currSquare.origin.file, currSquare.origin.rank);         });     }      render() {          return (             <View style={styles.topLevel}>                 <View style={StyleSheet.flatten([styles.board])}                     ref="boardRoot"                 >                     <View style={StyleSheet.flatten([styles.whiteCell, {                         left: 50,                         top: 50,                     }])} />                     <View style={StyleSheet.flatten([styles.blackCell, {                         left: 150,                         top: 50,                     }])} />                     <View style={StyleSheet.flatten([styles.blackCell, {                         left: 50,                         top: 150,                     }])} />                     <View style={StyleSheet.flatten([styles.whiteCell, {                         left: 150,                         top: 150,                     }])} />                      {this.renderAllCoins()}                  </View>             </View>          );     } }  const styles = StyleSheet.create({     topLevel: {         backgroundColor: "#CCFFCC",         flex: 1,         justifyContent: 'center',         alignItems: 'center',         flexDirection: 'row',     },     board: {         width: 300,         height: 300,         backgroundColor: "#FFCCFF",     },     whiteCell: {         width: 100,         height: 100,         backgroundColor: '#FFAA22',         position: 'absolute',     },     blackCell: {         width: 100,         height: 100,         backgroundColor: '#221122',         position: 'absolute',     },     greenCoin: {         width: 100,         height: 100,         position: 'absolute',         backgroundColor: '#23CC12',         borderRadius: 50,     },     redCoin: {         width: 100,         height: 100,         position: 'absolute',         backgroundColor: '#FF0000',         borderRadius: 50,     },     blueCoin: {         width: 100,         height: 100,         position: 'absolute',         backgroundColor: '#0000FF',         borderRadius: 50,     }, }); 

This is the package.json I am using

{     "name": "test",     "version": "0.0.1",     "private": true,     "scripts": {         "start": "node node_modules/react-native/local-cli/cli.js start",         "test": "jest"     },     "dependencies": {         "react": "16.0.0-beta.5",         "react-native": "0.49.3",         "underscore": "^1.8.3"     },     "devDependencies": {         "babel-jest": "21.2.0",         "babel-preset-react-native": "4.0.0",         "jest": "21.2.1",         "react-devtools-core": "^2.5.2",         "react-test-renderer": "16.0.0-beta.5"     },     "jest": {         "preset": "react-native"     } } 

Each Square is implemented thanks to the Square class, which holds the origin square, the drag and drop pan responder and pan animated value. The drag and drop animation are constrained to the cells thanks to two x/y interpolators.

This is the Expo Snack application.

My guess is that the strange animation behaviour is caused by the interpolators, or some value I forgot to set to the pan animatedXY value, but I can't be sure.

1 Answers

Answers 1

Not from the released place

This is because of the way your offsets are resolved. Your toValue coordinates are correct when no offset is applied to them, so you should start the animation after offsets have been flattened. Otherwise you'll start off (before flattenOffset is called) going from the point of release to the wrong end point, and when offsets are flattened that "corrects" the end coordinate but the start point will now be wrong. You can see this more clearly if you slow the animation right down and put the flattenOffset call inside a setTimeout so it happens mid-animation.

To fix, just move the flattenOffset() call to before start().

  onPanResponderRelease: (event, gestureState) => {     const nativeEvent = event.nativeEvent;      const origX = parseInt(this.cellsSize * (this.origin.file + 0.5));     const origY = parseInt(this.cellsSize * (this.origin.rank + 0.5));      // Our animated path should be calculated without an offset, as our     // origX and origY are both un-offset, so flattenOffset() before start()     this.pan.flattenOffset();      Animated.timing(       this.pan,       {         toValue: { x: origX, y: origY },         duration: 400,         delay: 0,         easing: Easing.linear       }     ).start();   } 

Non-linear

This becomes more obvious once the issue above is resolved and you can see what's happening. It's simply because your pan is animating from the point of release back to the circle's origin, but the circle itself is constrained. So, if your point of release is outside the constrained area, you'll see the circle creep horizontally or vertically along the edge of the constrained area, as close as it can be to pan, until the pan value moves inside the box, where the circle can follow it.

What to do about that depends on your desired behaviour. Assuming you don't care how far outside the constrained area the pan was released, and you just want the circle to animate linearly from where it appears back to its origin, then the simplest thing to do is set your pan value to the constrained version of itself before beginning the animation:

  onPanResponderRelease: (event, gestureState) => {     const nativeEvent = event.nativeEvent;      const origX = parseInt(this.cellsSize * (this.origin.file + 0.5));     const origY = parseInt(this.cellsSize * (this.origin.rank + 0.5));      // Our animated path should be calculated without an offset, as our     // origX and origY are both un-offset, so flattenOffset() before start()     this.pan.flattenOffset();      // Act as if we have released from the centre of where the circle appears     // on screen, rather than potentially outside the constrained area     this.pan.setValue({ x: this.constrainedX.__getValue(), y: this.constrainedY.__getValue() });      Animated.timing(       this.pan,       {         toValue: { x: origX, y: origY },         duration: 400,         delay: 0,         easing: Easing.linear       }     ).start();   } 

As you can see, this uses the "private" __getValue() method as a convenient way to use the already-constrained values. If you wanted to avoid this you'd have to use the coordinates within gestureState and apply your own constraining logic - unfortunately RN doesn't expose a way to use its interpolation logic on a a non-animated value.

If You Enjoyed This, Take 5 Seconds To Share It

0 comments:

Post a Comment