software-mansion / react-native-gesture-handler

Declarative API exposing platform native touch and gesture system to React Native.
https://docs.swmansion.com/react-native-gesture-handler/
MIT License
6.12k stars 981 forks source link

pinch and zoom image position #244

Closed pontusab closed 6 years ago

pontusab commented 6 years ago

Hey thanks for this nice library!

Im trying to make a instagram pinch and zoom image, I have a working zoomable and movable image, but i struggle with the release phase, when i just pinch on release the image is way of the position so it "jumps" at start.

onGestureMove works it updates the position correct, but then when i do onGestureRelease the animated value of gesturePosition.x and gesturePosition.y is wrong.

Would love some help on this 💯

Element.js

import React, { PureComponent } from 'react'
import PropTypes from 'prop-types'
import { Animated, Easing } from 'react-native'
import { PanGestureHandler, PinchGestureHandler, State } from 'react-native-gesture-handler'

const ANIMATION_DURATION = 200

export default class Element extends PureComponent {
  opacity = new Animated.Value(1)

  static propTypes = {
    children: PropTypes.element.isRequired,
  }

  static contextTypes = {
    scaleValue: PropTypes.object,
    onGestureStart: PropTypes.func,
    onGestureRelease: PropTypes.func,
    gesturePosition: PropTypes.object,
  }

  onPanStateChange = ({ nativeEvent }) => {
    switch (nativeEvent.state) {
      case State.BEGAN:
        return this.onGestureStart()
      case State.END:
      case State.FAILED:
      case State.UNDETERMINED:
      case State.CANCELLED:
        return this.onGestureRelease()
      default:
        return null
    }
  }

  onGestureStart = async () => {
    const { onGestureStart, gesturePosition } = this.context

    const measurement = await this.measureSelected()
    this.measurement = measurement

    onGestureStart({ element: this, measurement })

    gesturePosition.setValue({ x: 0, y: 0 })

    gesturePosition.setOffset({
      x: measurement.x,
      y: measurement.y,
    })

    Animated.timing(this.opacity, {
      toValue: 0,
      duration: ANIMATION_DURATION,
    }).start()
  }

  onGestureRelease() {
    const { gesturePosition, scaleValue, onGestureRelease } = this.context

    Animated.parallel([
      Animated.timing(gesturePosition.x, {
        toValue: 0,
        duration: ANIMATION_DURATION,
        easing: Easing.ease,
        useNativeDriver: true,
      }),
      Animated.timing(gesturePosition.y, {
        toValue: 0,
        duration: ANIMATION_DURATION,
        easing: Easing.ease,
        useNativeDriver: true,
      }),
      Animated.timing(scaleValue, {
        toValue: 1,
        duration: ANIMATION_DURATION,
        easing: Easing.ease,
        useNativeDriver: true,
      }),
    ]).start(() => {
      gesturePosition.setOffset({
        x: this.measurement.x,
        y: this.measurement.y,
      })

      // Reset original component opacity
      this.opacity.setValue(1)

      // Reset scale value
      scaleValue.setValue(1)

      requestAnimationFrame(() => {
        onGestureRelease()
      })
    })
  }

  onGestureMove = ({ nativeEvent }) => {
    const { gesturePosition } = this.context
    const { translationX, translationY } = nativeEvent

    gesturePosition.setValue({
      x: translationX,
      y: translationY,
    })
  }

  onGesturePinch = ({ nativeEvent }) => {
    const { scaleValue } = this.context
    scaleValue.setValue(nativeEvent.scale)
  }

  setRef = el => {
    this.parent = el
  }

  /* eslint-disable no-underscore-dangle */
  measureSelected = async () => {
    const parentMeasurement = await new Promise((resolve, reject) => {
      try {
        this.parent._component.measureInWindow((x, y) => {
          resolve({ x, y })
        })
      } catch (err) {
        reject(err)
      }
    })

    return {
      x: parentMeasurement.x,
      y: parentMeasurement.y,
    }
  }

  render() {
    const imagePan = React.createRef()

    return (
      <PanGestureHandler
        onGestureEvent={this.onGestureMove}
        onHandlerStateChange={this.onPanStateChange}
        ref={imagePan}
        minPointers={2}
        maxPointers={2}
        minDist={0}
        minDeltaX={0}
        avgTouches
      >
        <PinchGestureHandler simultaneousHandlers={imagePan} onGestureEvent={this.onGesturePinch}>
          <Animated.View ref={this.setRef} style={{ opacity: this.opacity }}>
            {this.props.children}
          </Animated.View>
        </PinchGestureHandler>
      </PanGestureHandler>
    )
  }
}

Selected.js

import React, { PureComponent } from 'react'
import PropTypes from 'prop-types'
import { Animated, View } from 'react-native'

const styles = {
  container: {
    flex: 1,
    position: 'absolute',
    top: 0,
    left: 0,
    bottom: 0,
    right: 0,
  },
  background: {
    position: 'absolute',
    top: 0,
    left: 0,
    bottom: 0,
    right: 0,
    backgroundColor: 'black',
  },
}

const MINIMUM_SCALE = 1
const MAXIMUM_SCALE = 5
const SCALE_MULTIPLIER = 1.2

export default class Selected extends PureComponent {
  static propTypes = {
    selected: PropTypes.object,
  }

  static contextTypes = {
    gesturePosition: PropTypes.object,
    scaleValue: PropTypes.object,
  }

  render() {
    const { selected } = this.props
    const { gesturePosition, scaleValue } = this.context

    const scale = scaleValue.interpolate({
      inputRange: [MINIMUM_SCALE, MAXIMUM_SCALE],
      outputRange: [MINIMUM_SCALE, MAXIMUM_SCALE * SCALE_MULTIPLIER],
      extrapolate: 'clamp',
    })

    const backgroundOpacityValue = scaleValue.interpolate({
      inputRange: [1, 1.2, 3],
      outputRange: [0, 0.5, 0.8],
    })

    const transform = [...gesturePosition.getTranslateTransform(), { scale }]

    // TODO: See if cloneElement glitches
    return (
      <View style={styles.container}>
        <Animated.View style={[styles.background, { opacity: backgroundOpacityValue }]} />
        <Animated.View
          style={{
            position: 'absolute',
            zIndex: 10,
            transform,
          }}
        >
          {React.cloneElement(selected.element.props.children, { disableAnimation: true })}
        </Animated.View>
      </View>
    )
  }
}
osdnk commented 6 years ago

As far as I am concerned this https://github.com/kmagiera/react-native-gesture-handler/issues/229 issue is related

osdnk commented 6 years ago

Hi, @pontusab Do you observe it on both platform?

pontusab commented 6 years ago

@osdnk Yes, both iOS and Android.

CrowsVeldt commented 6 years ago

I believe I am encountering the same issue on Android, I'm using ClojureScript and Expo. I could attach the source if it'd help. I imagine the generated JS wouldn't be very useful but if is I can attach it as well.

CrowsVeldt commented 6 years ago

Never mind. Turns out I wasn't accessing the component state properly. :roll_eyes:

tomasgcs commented 6 years ago

Hello this happened to me also so what I did is I don't use setOffset Offset somehow doesn't work right when used togehter with Animated.timing so what I did I defined a separate Animated.Value to be used as gestureOffset which is then added to the gesturePosition using Animated.add For example:

const transform = [{ translateX: Animated.add(gesturePosition.x, gestureOffset.x), }, { translateY: Animated.add(gesturePosition.y, gestureOffset.y), }, { scale }]

This workaround works, but I think it is worth looking at Native code to see where is the issue. Hope this will help.

pontusab commented 6 years ago

@tomasgcs Do you have an example you can share?

pontusab commented 6 years ago

Yep, @tomasgcs idea worked! Thanks