Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Drag, drop, and swap items animation?

Tags:

react-native

I seem to have got the drag and drop part working, but don't know how to do the swapping places. Also not sure how to fix the z-index issue (it seems to do something fishy with Animated.View).

enter image description here

import React, { Component } from 'react';
import {
  StyleSheet,
  Text,
  View,
  Image,
  PanResponder,
  Animated,
  Alert,
} from 'react-native';

class Draggable extends Component {
  constructor(props) {
    super(props);

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

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

      onPanResponderGrant: (e, gestureState) => {
        this.state.pan.setOffset({x: this.state.pan.x._value, y: this.state.pan.y._value});
        this.state.pan.setValue({x: 0, y: 0});
        Animated.spring(
          this.state.scale,
          { toValue: 1.1, friction: 3 }
        ).start();
      },

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

      onPanResponderRelease: (e, gesture) => {
        this.state.pan.flattenOffset();
        Animated.spring(
          this.state.scale,
          { toValue: 1, friction: 3 }
        ).start();

        let dropzone = this.inDropZone(gesture);

        if (dropzone) {
          console.log(dropzone.y-this.layout.y, this.state.pan.y._value, dropzone.y);
          Animated.spring(
            this.state.pan,
            {toValue:{
              x: 0,
              y: dropzone.y-this.layout.y,
            }}
          ).start();
        } else {
         Animated.spring(
           this.state.pan,
           {toValue:{x:0,y:0}}
         ).start();
        }
      },
    });
  }

  inDropZone(gesture) {
    var isDropZone = false;
    for (dropzone of this.props.dropZoneValues) {
      if (gesture.moveY > dropzone.y && gesture.moveY < dropzone.y + dropzone.height && gesture.moveX > dropzone.x && gesture.moveX < dropzone.x + dropzone.width) {
        isDropZone = dropzone;
      }
    }
    return isDropZone;
  }

  setDropZoneValues(event) {
    this.props.setDropZoneValues(event.nativeEvent.layout);
    this.layout = event.nativeEvent.layout;
  }

  render() {
   let { pan, scale } = this.state;
   let [translateX, translateY] = [pan.x, pan.y];
   let rotate = '0deg';
   let imageStyle = {transform: [{translateX}, {translateY}, {rotate}, {scale}]};

    return (
      <View
        style={styles.dropzone}
        onLayout={this.setDropZoneValues.bind(this)}
      >
        <Animated.View
          style={[imageStyle, styles.draggable]}
          {...this._panResponder.panHandlers}>
          <Image style={styles.image} resizeMode="contain" source={{ uri: this.props.uri }} />
        </Animated.View>
      </View>
    );
  }
}


class Playground extends Component {
  constructor(props) {
    super(props);

    this.state = {
      dropZoneValues: [],
    };
  }

  setDropZoneValues(layout) {
    this.setState({
      dropZoneValues: this.state.dropZoneValues.concat(layout),
    });
  }

  render() {

    return (
      <View style={styles.container}>
        <Draggable
          dropZoneValues={this.state.dropZoneValues}
          setDropZoneValues={this.setDropZoneValues.bind(this)}
          uri="https://pbs.twimg.com/profile_images/378800000822867536/3f5a00acf72df93528b6bb7cd0a4fd0c.jpeg"
        />
        <Draggable
          dropZoneValues={this.state.dropZoneValues}
          setDropZoneValues={this.setDropZoneValues.bind(this)}
          uri="https://pbs.twimg.com/profile_images/446566229210181632/2IeTff-V.jpeg"
        />
      </View>
    );
  }
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: 'orange',
    justifyContent: 'center',
    alignItems: 'center',
  },
  dropzone: {
    zIndex: 0,
    margin: 5,
    width: 106,
    height: 106,
    borderColor: 'green',
    borderWidth: 3
  },
  draggable: {
    zIndex: 0,
    backgroundColor: 'white',
    justifyContent: 'center',
    alignItems: 'center',
    width: 100,
    height: 100,
    borderWidth: 1,
    borderColor: 'black'
  },
  image: {
    width: 75,
    height: 75
  }
});

export default Playground;

EDIT: I made an attempt at the swapping, but only seems to work about half the time. Also, the zIndex is still driving me nuts. I am printing out the state like {color} {zIndex}, so you can see it updating to 100, but it doesn't seem to take effect. Changing the color to blue seems to work though... I'm confused.

enter image description here

import React, { Component } from 'react';
import {
  StyleSheet,
  Text,
  View,
  Image,
  PanResponder,
  Animated,
  Alert,
} from 'react-native';

class Draggable extends Component {
  constructor(props) {
    super(props);

    this.state = {
      pan: new Animated.ValueXY(),
      scale: new Animated.Value(1),
      zIndex: 0,
      color: 'white',
    };
  }

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

      onPanResponderGrant: (e, gestureState) => {
        console.log('moving', this.props.index);
        this.state.pan.setOffset({x: this.state.pan.x._value, y: this.state.pan.y._value});
        this.state.pan.setValue({x: 0, y: 0});
        Animated.spring(
          this.state.scale,
          { toValue: 1.1, friction: 3 }
        ).start();

        this.setState({ color: 'blue', zIndex: 100 });
      },

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

      onPanResponderRelease: (e, gesture) => {
        this.state.pan.flattenOffset();
        // de-scale
        Animated.spring(
          this.state.scale,
          { toValue: 1, friction: 3 }
        ).start();

        this.setState({ color: 'white', zIndex: 0 });

        let dropzone = this.inDropZone(gesture);
        if (dropzone) {  // plop into dropzone
          // console.log(dropzone.y-this.layout.y, this.state.pan.y._value, dropzone.y);
          console.log('grabbed', this.props.index, ' => dropped', dropzone.index);
          Animated.spring(
            this.state.pan,
            {toValue:{
              x: 0,
              y: dropzone.y-this.layout.y,
            }}
          ).start();
          if (this.props.index !== dropzone.index) {
            this.props.swapItems(this.props.index, dropzone.index, dropzone.y-this.layout.y);
          }
        } else {
          // spring back to start
         Animated.spring(
           this.state.pan,
           {toValue:{x:0,y:0}}
         ).start();
        }
      },
    });
  }

  inDropZone(gesture) {
    var isDropZone = false;
    for (var dropzone of this.props.dropZoneValues) {
      if (gesture.moveY > dropzone.y && gesture.moveY < dropzone.y + dropzone.height) {
        isDropZone = dropzone;
      }
    }
    return isDropZone;
  }

  setDropZoneValues(event) {
    this.props.setDropZoneValues(event.nativeEvent.layout, this.props.index, this);
    this.layout = event.nativeEvent.layout;
    this.layout.index = this.props.index;
  }

  render() {
   let { pan, scale, zIndex, color } = this.state;
   let [translateX, translateY] = [pan.x, pan.y];
   let rotate = '0deg';
   let imageStyle = {
     transform: [{translateX}, {translateY}, {rotate}, {scale}]
   };

    return (
      <View
        style={[styles.dropzone]}
        onLayout={this.setDropZoneValues.bind(this)}
      >
        <Animated.View
          {...this._panResponder.panHandlers}
          style={[imageStyle, styles.draggable, { backgroundColor: color, zIndex }]}
        >
          <Text>{this.props.index}</Text>
          <Text>{this.props.char}</Text>
          <Text>{this.state.color} {this.state.zIndex}</Text>
        </Animated.View>
      </View>
    );
  }
}

Array.prototype.swap = function (x,y) {
  var b = this[x];
  this[x] = this[y];
  this[y] = b;
  return this;
}

Array.prototype.clone = function() {
    return this.slice(0);
};

const items = [
  'shiba inu',
  'labrador',
];

class Playground extends Component {
  constructor(props) {
    super(props);

    this.state = {
      items,
      dropZoneValues: [],
      dropzones: [],
    };
  }

  setDropZoneValues(layout, index, dropzone) {
    layout.index = index;
    this.setState({
      dropZoneValues: this.state.dropZoneValues.concat(layout),
    });
    this.setState({
      dropzones: this.state.dropzones.concat(dropzone),
    });
  }

  swapItems(i1, i2, y) {
    console.log('swapping', i1, i2);
    var height = y < 0 ? this.state.dropzones[i1].layout.height : -this.state.dropzones[i1].layout.height;
    Animated.spring(
      this.state.dropzones[i2].state.pan,
      {toValue:{
        x: 0,
        y: -y-height
      }}
    ).start();
    var clone = this.state.items.clone();
    console.log(clone);
    clone.swap(i1, i2);
    console.log(clone);
    this.setState({
      items: clone
    });
  }

  render() {
    console.log('state', this.state);

    return (
      <View style={styles.container}>
        {this.state.items.map((i, index) =>
          <Draggable key={index}
            dropZoneValues={this.state.dropZoneValues}
            setDropZoneValues={this.setDropZoneValues.bind(this)}
            char={i}
            index={index}
            swapItems={this.swapItems.bind(this)}
          />
        )}
        <View style={{ zIndex: 100, backgroundColor: 'red' }}><Text>foo</Text></View>
        <View style={{ zIndex: -100, top: -10, backgroundColor: 'blue' }}><Text>bar</Text></View>
      </View>
    );
  }
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: 'orange',
    justifyContent: 'center',
    alignItems: 'center',
  },
  dropzone: {
    // margin: 5,
    zIndex: -100,
    width: 106,
    height: 106,
    borderColor: 'green',
    borderWidth: 3,
    backgroundColor: 'lightgreen',
  },
  draggable: {
    backgroundColor: 'white',
    justifyContent: 'center',
    alignItems: 'center',
    width: 100,
    height: 100,
    borderWidth: 1,
    borderColor: 'black'
  },
  image: {
    width: 75,
    height: 75
  }
});

export default Playground;

EDIT2: zIndex only affects child siblings, so I had to put it on the parent (the green box) instead of Animated.View.

The reason the swap only worked half the time was because with the way I was adding the layouts in addDropzone, they sometimes ended up out of order for use in inDropzone. When I sort the layouts, inDropzone works how I would expect.

Overall, this whole thing still feels like a GIANT HACK, so if anyone who actually knows what they're doing sees flaws in my implementation and can improve it, that'd be really great. Also, it would be nice to have a preview, so when you drag over a dropzone, it shows a temporary swap of what's about to change, or any other useful visual indicators you can think of. Drag, drop, and swap is very common functionality for a mobile app, and the only library out there only works in a vertical list. I needed to implement this from scratch because I wanted to make this a grid of photos instead.

enter image description here

import React, { Component } from 'react';
import {
  StyleSheet,
  Text,
  View,
  Image,
  PanResponder,
  Animated,
  Alert,
} from 'react-native';
import _ from 'lodash';

class Draggable extends Component {
  constructor(props) {
    super(props);

    this.state = {
      pan: new Animated.ValueXY(),
      scale: new Animated.Value(1),
      zIndex: 0,
      backgroundColor: 'white',
    };
  }

  handleOnLayout(event) {
    const { addDropzone } = this.props;
    const { layout } = event.nativeEvent;
    this.layout = layout;
    addDropzone(this, layout);
  }

  componentWillMount() {
    const { inDropzone, swapItems, index } = this.props;

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

      onPanResponderGrant: (e, gestureState) => {
        console.log('moving', index);
        this.state.pan.setOffset({ x: this.state.pan.x._value, y: this.state.pan.y._value });
        this.state.pan.setValue({ x: 0, y: 0 });

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

        this.setState({ backgroundColor: 'deepskyblue', zIndex: 1 });
      },

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

      onPanResponderRelease: (e, gesture) => {
        this.state.pan.flattenOffset();
        Animated.spring(this.state.scale, { toValue: 1 }).start();
        this.setState({ backgroundColor: 'white', zIndex: 0 });

        let dropzone = inDropzone(gesture);
        if (dropzone) {
          console.log('in dropzone', dropzone.index);
          // adjust into place
          Animated.spring(this.state.pan, { toValue: {
            x: dropzone.x - this.layout.x,
            y: dropzone.y - this.layout.y,
          } }).start();
          if (index !== dropzone.index) {
            swapItems(index, dropzone.index);
          }
        }
        Animated.spring(this.state.pan, { toValue: { x: 0, y: 0 } }).start();
      }

    });
  }

  render() {
    const { pan, scale, zIndex, backgroundColor } = this.state;
    const [translateX, translateY] = [pan.x, pan.y];
    const rotate = '0deg';
    const imageStyle = {
      transform: [{ translateX }, { translateY }, { rotate }, { scale }],
    };

    return (
      <View
        style={[styles.dropzone, { zIndex }]}
        onLayout={event => this.handleOnLayout(event)}
      >
        <Animated.View
          {...this._panResponder.panHandlers}
          style={[imageStyle, styles.draggable, { backgroundColor }]}
        >
          <Image style={styles.image} source={{ uri: this.props.item }} />
        </Animated.View>
      </View>
    );
  }
}

const swap = (array, fromIndex, toIndex) => {
  const newArray = array.slice(0);
  newArray[fromIndex] = array[toIndex];
  newArray[toIndex] = array[fromIndex];
  return newArray;
}

class Playground extends Component {
  constructor(props) {
    super(props);

    this.state = {
      items: [
        'https://files.graphiq.com/465/media/images/t2/Shiba_Inu_5187048.jpg',
        'https://i.ytimg.com/vi/To8oesttqc4/hqdefault.jpg',
        'https://vitaminsforpitbulls.com/wp-content/uploads/2013/06/english-bulldog-puppy-for-sale-909x1024.jpg',
        'https://s-media-cache-ak0.pinimg.com/236x/20/16/e6/2016e61e8642c8aab60c71f6e3bcd004.jpg',
        'https://pbs.twimg.com/profile_images/446566229210181632/2IeTff-V.jpeg',
        'https://s-media-cache-ak0.pinimg.com/236x/fa/7b/18/fa7b185924d9d4d14a0623bc567f4e87.jpg',
      ],
      dropzones: [],
      dropzoneLayouts: [],
    };
  }

  addDropzone(dropzone, dropzoneLayout) {
    const { items, dropzones, dropzoneLayouts } = this.state;
    // HACK: to make sure setting state does not re-add dropzones
    if (items.length !== dropzones.length) {
      this.setState({
        dropzones: [...dropzones, dropzone],
        dropzoneLayouts: [...dropzoneLayouts, dropzoneLayout],
      });
    }
  }

  inDropzone(gesture) {
    const { dropzoneLayouts } = this.state;
    // HACK: with the way they are added, sometimes the layouts end up out of order, so we need to sort by y,x (x,y doesn't work)
    const sortedDropzoneLayouts = _.sortBy(dropzoneLayouts, ['y', 'x']);
    let inDropzone = false;

    sortedDropzoneLayouts.forEach((dropzone, index) => {
      const inX = gesture.moveX > dropzone.x && gesture.moveX < dropzone.x + dropzone.width;
      const inY = gesture.moveY > dropzone.y && gesture.moveY < dropzone.y + dropzone.height;
      if (inX && inY) {
        inDropzone = dropzone;
        inDropzone.index = index;
      }
    });
    return inDropzone;
  }

  swapItems(fromIndex, toIndex) {
    console.log('swapping', fromIndex, '<->', toIndex);
    const { items, dropzones } = this.state;
    this.setState({
      items: swap(items, fromIndex, toIndex),
      dropzones: swap(dropzones, fromIndex, toIndex),
    });
  }

  render() {
    console.log(this.state);
    return (
      <View style={styles.container}>
        {this.state.items.map((item, index) =>
          <Draggable key={index}
            item={item}
            index={index}
            addDropzone={this.addDropzone.bind(this)}
            inDropzone={this.inDropzone.bind(this)}
            swapItems={this.swapItems.bind(this)}
          />
        )}
      </View>
    );
  }
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    paddingTop: 60,
    backgroundColor: 'orange',
    justifyContent: 'center',
    alignItems: 'center',
    flexDirection: 'row',
    flexWrap: 'wrap',
  },
  dropzone: {
    // margin: 5,
    zIndex: -1,
    width: 106,
    height: 106,
    borderColor: 'green',
    borderWidth: 3,
    backgroundColor: 'lightgreen',
  },
  draggable: {
    backgroundColor: 'white',
    justifyContent: 'center',
    alignItems: 'center',
    width: 100,
    height: 100,
    borderWidth: 1,
    borderColor: 'black'
  },
  image: {
    width: 75,
    height: 75
  }
});

export default Playground;

EDIT3: So the above works great in the simulator, but is extremely slow on an actual iPhone. It takes too long to load at the start before you can drag something (~3 seconds) and freezes as it's swapping items (~1 second). Trying to figure out why (probably my terrible implementation sorting/looping over arrays way too many times, but not sure how else to do it). I couldn't believe how much significantly slower it is on an actual phone.

LATEST: I'm just going to study/use these implementations https://github.com/ollija/react-native-sortable-grid, https://github.com/fangwei716/30-days-of-react-native#day-18 to find out what I did wrong. They were very difficult to find (or else I wouldn't have done this from scratch and posted this question), so I hope this helps someone out who is trying to do the same thing for their app!

like image 905
atkayla Avatar asked Oct 30 '16 07:10

atkayla


1 Answers

For performance issue first, I can suggest use Direct Manipulation. When you want to transform your images, you need to do it with setNativeProps:

this.refs['YOUR_IMAGE'].setNativeProps({style: {
  transform: [{ translateX }, { translateY }, { rotate }, { scale }],
}});

In react-native we have two realms, JavaScript and Native side, and we have a bridge between them.

Here lies one of the main keys to understanding React Native performance. Each realm by itself is fast. The performance bottleneck often occurs when we move from one realm to the other. In order to architect performance React Native apps, we must keep passes over the bridge to a minimum.

You can read more with examples here.

Second, see your Performance Monitor (Shake your device, or Command-D and select Show Perf Monitor). The important part is Views, upper number is the number of views you have in you screen and bottom number typically larger but usually indicates you have something that could be improved/refactored.

like image 132
Esmaeil Avatar answered Sep 29 '22 22:09

Esmaeil