vincenth
vincenth

Reputation: 1792

Move an image inside a view - React-native

I am trying to create a component that let you zoom in and out on an image using pinch and then move the image around in an area. The end goal being to display the image of a map and explore it. The map will always be an image.

The image is contained in a View for which I set up a panResponder. The panResponder differentiates the pinch and the touch event and call the appropriate function.

I managed to make work the "pinch to zoom" feature as well as the "move around" feature, but the latter seems clumsy to me. I suspect that the calculation I do are slightly off, it's not my strong suit.

What would be the best way to implement it ?

Here is the whole code :

import React, {
  Component,
} from 'react';

import {
  Dimensions,
  AppRegistry,
  StyleSheet,
  Text,
  View,
  Image,
  TouchableOpacity,
  PanResponder,
} from 'react-native';

const screenWidth = Dimensions.get('window').width;
const screenHeight = Dimensions.get('window').height;
const img = require('image!cars');

const scaleStep = 0.1;

const DIR_IN = 'IN';
const DIR_OUT = 'OUT';

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

    this.state = {
      zoom: 1,
      x: 0,
      y: 0,
      distance: 0,
      isZooming: false,
      isMoving: false,
    };
  }

  getDimensionsToFitArea(image, areaDimensions) {
    const verticalFactor = areaDimensions.height / image.height;
    const horizontalFactor = areaDimensions.width / image.width;

    const imageFactor = Math.min(verticalFactor, horizontalFactor);

    return {
      width: image.width * imageFactor,
      height: image.height * imageFactor,
    };
  }

  setZoom(zoom) {
    this.setState({
      zoom: zoom,
    });
  }

  zoomIn() {
    const newZoom = this.state.zoom + scaleStep;
    this.setZoom(newZoom);
  }

  zoomOut() {
    let newZoom = this.state.zoom - (scaleStep * 1.5);

    if (newZoom < 1) {
      newZoom = 1;
      this.resetCenter();
    }

    this.setZoom(newZoom);
  }

  cancelZoom() {
    this.setZoom(1);

    this.resetCenter();
  }

  resetCenter() {
    this.setCenter(0, 0);
  }

  setCenter(x, y) {
    let newX = 0;
    let newY = 0;

    if (this.state.zoom > 1) {
      const imgAreaDimensions = this.getImageAreaDimensions();

      if (x != 0 && y != 0) {
        newX = (imgAreaDimensions.width / 2) - x;
        newY = (imgAreaDimensions.height / 2) - y;
      }
    }

    const newState = {
      x: newX,
      y: newY,
    };

    this.setState(newState);
  }

  getImageAreaDimensions() {
    return {
      width: screenWidth,
      height: screenHeight / 2
    };
  }

  processTouch(x, y) {
    if (!this.state.isMoving) {
      this.setState({
        isMoving: true,
        initialX: x,
        initialY: y,
        pathDoneX: 0,
        pathDoneY: 0,
      });
    } else {
      const path = calcPath(this.state.initialX, this.state.initialY, x, y);

      const newX = this.state.initialX - path.x;
      const newY = this.state.initialY - path.y;

      this.setCenter(newX, newY);

      this.setState({
        pathDoneX: this.state.pathDoneX + path.x,
        pathDoneY: this.state.pathDoneY + path.y,
      });

    }
  }

  processPinch(x1, y1, x2, y2) {
    const distance = calcDistance(x1, y1, x2, y2);
    const center = calcCenter(x1, y1, x2, y2);

    const direction = (distance > this.state.distance) ? DIR_IN : DIR_OUT;

    if (!this.state.isZooming) {
      if (direction === DIR_IN) {
        this.setCenter(center.x, center.y);
      }
    }
    else {
      (direction === DIR_IN) ? this.zoomIn() : this.zoomOut();
    }

    this.setState({
      distance: distance,
      isZooming: true,
    });
  }



  componentWillMount() {
    this._panResponder = PanResponder.create({
      oneStartShouldSetPanResponderCapture: () => true,
      oneMoveShouldSetPanResponder: () => true,
      oneMoveShouldSetPanResponderCapture: () => true,
      onPanResponderGrant: () => { },
      onPanResponderMove: (evt) => {
        const touches = evt.nativeEvent.touches;

        if (touches.length === 2) {
          this.processPinch(touches[0].pageX, touches[0].pageY,
            touches[1].pageX, touches[1].pageY);
        } else if (touches.length === 1 && !this.state.isZooming) {
          this.processTouch(touches[0].pageX, touches[0].pageY);
        }
      },

      onPanResponderTerminationRequest: () => false,
      onPanResponderRelease: () => {
        this.setState({
          isZooming: false,
          isMoving: false,
        });
      },
      onPanResponderTerminate: () => { },
      onMoveShouldSetPanResponderCapture: (evt, gestureState) => {
        return (Math.abs(gestureState.dx) > 2) || (Math.abs(gestureState.dy) > 2)
      },
    });
  }

  render() {
    const imgAreaDimensions = this.getImageAreaDimensions();
    const imgDimensions = this.getDimensionsToFitArea(img, {
      width: imgAreaDimensions.width,
      height: imgAreaDimensions.height,
    });

    return (
      <View style={styles.container}>
        <View
          style={{
            borderWidth: 1,
            borderColor: '#000000',
            width: imgAreaDimensions.width,
            height: imgAreaDimensions.height,
          }}
          {...this._panResponder.panHandlers}
          >
          <Image
            style={{
              width: imgDimensions.width,
              height: imgDimensions.height,
              transform: [
                { translateX: this.state.x },
                { translateY: this.state.y },
                { scaleX: this.state.zoom },
                { scaleY: this.state.zoom }
              ]
            }}
            source={img}
            />
        </View>
        <TouchableOpacity
          style={styles.button}
          onPress={() => this.cancelZoom() }
          >
          <Text>~</Text>
        </TouchableOpacity>
      </View>
    );
  }
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    justifyContent: 'center',
    alignItems: 'center',
    backgroundColor: '#F5FCFF',
  },
  button: {
    borderColor: '#000000',
    borderWidth: 1,
    padding: 25,
    paddingBottom: 5,
    paddingTop: 5,
    alignSelf: 'stretch',
    alignItems: 'center',
    margin: 1,
    height: 50,
  }
});

AppRegistry.registerComponent('reactzoom', () => reactzoom);

function calcPath(x1, y1, x2, y2) {
  return {
    x: x2 - x1,
    y: y2 - y1,
  };
}
function calcDistance(x1, y1, x2, y2) {
  const dx = Math.abs(x1 - x2);
  const dy = Math.abs(y1 - y2);
  return Math.sqrt(Math.pow(dx, 2) + Math.pow(dy, 2));
}

function calcCenter(x1, y1, x2, y2) {
  function middle(p1, p2) {
    return p1 > p2 ? p1 - (p1 - p2) / 2 : p2 - (p2 - p1) / 2;
  }

  return {
    x: middle(x1, x2),
    y: middle(y1, y2),
  };
}

Upvotes: 0

Views: 8009

Answers (1)

Thomas
Thomas

Reputation: 8013

It's probably due to the fact that you are using setState calls to handle the location and the zooming of the map. The ratio at which the PanResponder's handlers are called is simply too high to handling using setState because that causes the render function to be called and makes React reconciliation algorithm kick in.

What you can do to fix this is to make use of the Animated components and call setValue on the needed values. I slightly adapted your code (but did not test it) to give you a hint in the right direction.

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

    // these values are not kept in the state
    // because we will manipulate these directly
    this.zoom = new Animated.Value(1);
    this.x = new Animated.Value(0);
    this.y = new Animated.Value(0);
    ...
  }

  setZoom(zoom) {
    // change the zoom immediately
    this.zoom.setValue(zoom);
  }

  setCenter(x, y) {
    let newX = 0;
    let newY = 0;

    if (this.state.zoom > 1) {
      const imgAreaDimensions = this.getImageAreaDimensions();

      if (x != 0 && y != 0) {
        newX = (imgAreaDimensions.width / 2) - x;
        newY = (imgAreaDimensions.height / 2) - y;
      }
    }

    // this make the center move directly
    this.x.setValue(newX);
    this.y.setValue(newY);
  }

  render() {
     ...
     {/* We make use of an Animated.Image because now we can
         manipulate the animated values directly without the need to re-render
     */}
     <Animated.Image
        style={{
          width: imgDimensions.width,
          height: imgDimensions.height,
          transform: [
            { translateX: this.x },
            { translateY: this.y },
            { scaleX: this.zoom },
            { scaleY: this.zoom }
          ]
        }}
        source={img}
        />
     ...
  }

}

Upvotes: 1

Related Questions