digitalfabrik/integreat-app

View on GitHub
native/src/components/PinchPanImage.tsx

Summary

Maintainability
C
1 day
Test Coverage
F
0%
import React, { ReactElement } from 'react'
import { Animated, Image, ImageErrorEventData, NativeSyntheticEvent } from 'react-native'
import {
  PanGestureHandler,
  PinchGestureHandler,
  PanGestureHandlerGestureEvent,
  PanGestureHandlerStateChangeEvent,
  PinchGestureHandlerGestureEvent,
  PinchGestureHandlerStateChangeEvent,
  State,
} from 'react-native-gesture-handler'
import styled from 'styled-components/native'

const USE_NATIVE_DRIVER = true
const ANIMATION_DURATION = 150

const Container = styled.View`
  flex: 1;
`

type PinchPanImageProps = {
  uri: string
  /**
   * @param error Error returned by react native of type any
   */
  onError: (error: unknown) => void
}

type StateType = {
  /**
   * We do not allow to interact (pinch/pan) while an animation is in progress. The reason for this is that we do not
   * have a way to get the current position of an element when the animation is cancelled through an interaction.
   * Therefore, we disable animation.
   */
  interactive: boolean
  viewDimensions?: { width: number; height: number }
  imageDimensions?: { width: number; height: number }
}

class PinchPanImage extends React.Component<PinchPanImageProps, StateType> {
  private lastOffset: { x: number; y: number }
  private readonly panHandler: React.RefObject<PanGestureHandler>
  // Used to hold the a translation of the image which has been recorded via panning
  private readonly translateX: Animated.AnimatedValue
  private readonly translateY: Animated.AnimatedValue
  // Used for applying an the lastOffset to the position of the image
  private readonly translateXOffset: Animated.AnimatedValue
  private readonly translateYOffset: Animated.AnimatedValue
  private readonly scaledTranslateYWithOffset: Animated.AnimatedDivision<string | number>
  private readonly scaledTranslateXWithOffset: Animated.AnimatedDivision<string | number>
  private readonly onPanGestureEvent: (event: PanGestureHandlerGestureEvent) => void

  private lastScale: number
  private readonly pinchHandler: React.RefObject<PinchGestureHandler>
  private readonly baseScale: Animated.Value
  private readonly pinchScale: Animated.Value
  private readonly scale: Animated.AnimatedMultiplication<string | number>
  private readonly onPinchGestureEvent: (event: PinchGestureHandlerGestureEvent) => void

  constructor(props: PinchPanImageProps) {
    super(props)
    this.state = { interactive: true }

    this.pinchHandler = React.createRef()
    this.panHandler = React.createRef()

    // Setup: Pinching
    this.baseScale = new Animated.Value(1)
    this.pinchScale = new Animated.Value(1)
    this.scale = Animated.multiply(this.baseScale, this.pinchScale)
    this.lastScale = 1
    this.onPinchGestureEvent = Animated.event([{ nativeEvent: { scale: this.pinchScale } }], {
      useNativeDriver: USE_NATIVE_DRIVER,
    })

    // Setup: Panning
    this.translateX = new Animated.Value(0)
    this.translateY = new Animated.Value(0)

    this.lastOffset = { x: 0, y: 0 }
    this.onPanGestureEvent = Animated.event(
      [
        {
          nativeEvent: {
            translationX: this.translateX,
            translationY: this.translateY,
          },
        },
      ],
      { useNativeDriver: USE_NATIVE_DRIVER },
    )
    this.translateXOffset = new Animated.Value(0)
    this.translateYOffset = new Animated.Value(0)

    // Make translation depend on scale and offset
    this.scaledTranslateXWithOffset = Animated.divide(Animated.add(this.translateX, this.translateXOffset), this.scale)
    this.scaledTranslateYWithOffset = Animated.divide(Animated.add(this.translateY, this.translateYOffset), this.scale)

    // Setup: Initialize image dimensions
    const { uri, onError } = props
    Image.getSize(
      uri,
      (width, height) => {
        this.setState(state => ({ ...state, imageDimensions: { width, height } }))
      },
      (error: unknown) => {
        onError(error)
      },
    )
  }

  private onImageLoadError = (error: NativeSyntheticEvent<ImageErrorEventData>) => {
    const { onError } = this.props
    onError(error.nativeEvent.error)
  }

  /**
   * Moves the image along some axis back into the view. This animates the translateX and translateY properties
   * if they are out of bounds
   */
  private fixBound(axis: 'x' | 'y', minValue: number, newValue: number) {
    this.setState(state => ({ ...state, interactive: false }))

    const animation: Animated.CompositeAnimation = Animated.timing(axis === 'x' ? this.translateX : this.translateY, {
      toValue: minValue - newValue,
      duration: ANIMATION_DURATION,
      useNativeDriver: USE_NATIVE_DRIVER,
    })

    animation.start(({ finished }) => {
      if (finished) {
        if (axis === 'x') {
          this.lastOffset.x = minValue
          this.translateXOffset.setValue(minValue)
          this.translateX.setValue(0)
        } else {
          this.lastOffset.y = minValue
          this.translateYOffset.setValue(minValue)
          this.translateY.setValue(0)
        }
      }
      this.setState(state => ({ ...state, interactive: true }))
    })
  }

  /**
   * Animates the translateX and translateY properties if they are out of bounds
   * @param viewWidth The width of the full area in which the image could appear
   * @param viewHeight The height of the full area in which the image could appear
   * @param realImageWidth The non-scaled default image width
   * @param realImageHeight The non-scaled default image height
   * @param newX The new x translation accumulated over the time of the pan gestures
   * @param newY The new y translation accumulated over the time of the pan gestures
   * @param newScale The new scale accumulated over the time of the pinch gestures
   * @private
   */
  private fixBounds(
    viewWidth: number,
    viewHeight: number,
    realImageWidth: number,
    realImageHeight: number,
    newX: number,
    newY: number,
    newScale: number,
  ) {
    // Calculate the minima and maxima which should not be violated
    const widthIncreaseByScale = (Math.max(0, this.lastScale - 1) * realImageWidth) / 2
    const heightIncreaseByScale = (Math.max(0, this.lastScale - 1) * realImageHeight) / 2
    const minX = 0 - widthIncreaseByScale
    const maxX = viewWidth + widthIncreaseByScale - viewWidth
    const minY = 0 - heightIncreaseByScale
    const maxY = viewHeight + heightIncreaseByScale - viewHeight

    if (newX <= minX) {
      this.fixBound('x', minX, newX)
    }

    if (newX >= maxX) {
      this.fixBound('x', maxY, newX)
    }

    if (newY <= minY) {
      this.fixBound('y', minY, newY)
    }

    if (newY >= maxY) {
      this.fixBound('y', maxY, newY)
    }

    if (newScale <= 1) {
      // Disable gesture handler during animation
      this.setState(state => ({ ...state, interactive: false }))

      const animation: Animated.CompositeAnimation = Animated.timing(this.baseScale, {
        toValue: 1,
        duration: ANIMATION_DURATION,
        useNativeDriver: USE_NATIVE_DRIVER,
      })
      animation.start(({ finished }) => {
        if (finished) {
          this.lastScale = 1
        }
        this.setState(state => ({ ...state, interactive: true }))
      })
    }
  }

  private createOnPinchHandlerStateChange =
    (viewWidth: number, viewHeight: number, realImageWidth: number, realImageHeight: number) =>
    (event: PinchGestureHandlerStateChangeEvent) => {
      // If the two fingers were lifted from the screen, then...
      if (event.nativeEvent.oldState === State.ACTIVE) {
        const scaleDelta = this.lastScale
        const newScale = scaleDelta * event.nativeEvent.scale
        this.lastScale = newScale
        this.baseScale.setValue(newScale)
        this.pinchScale.setValue(1)

        this.fixBounds(
          viewWidth,
          viewHeight,
          realImageWidth,
          realImageHeight,
          this.lastOffset.x,
          this.lastOffset.y,
          this.lastScale,
        )
      }
    }

  private createOnPanHandlerStateChange =
    (viewWidth: number, viewHeight: number, realImageWidth: number, realImageHeight: number) =>
    (event: PanGestureHandlerStateChangeEvent) => {
      if (event.nativeEvent.state === State.END) {
        // Distances the finger was dragged across the screen
        const movedInY = event.nativeEvent.translationY
        const movedInX = event.nativeEvent.translationX
        const previousY = this.lastOffset.y
        const previousX = this.lastOffset.x

        const newY = previousY + movedInY
        this.lastOffset.y = newY
        this.translateYOffset.setValue(newY)
        this.translateY.setValue(0)

        const newX = previousX + movedInX
        this.lastOffset.x = newX
        this.translateXOffset.setValue(newX)
        this.translateX.setValue(0)

        this.fixBounds(viewWidth, viewHeight, realImageWidth, realImageHeight, newX, newY, this.lastScale)
      }
    }

  render(): ReactElement | null {
    const { uri } = this.props
    const { imageDimensions, viewDimensions, interactive } = this.state

    if (!imageDimensions || !viewDimensions) {
      return (
        <Container
          onLayout={event => {
            const { width, height } = event.nativeEvent.layout
            this.setState(state => ({ ...state, viewDimensions: { width, height } }))
          }}
        />
      )
    }

    const { width: viewWidth, height: viewHeight } = viewDimensions
    const { width: imageWidth, height: imageHeight } = imageDimensions

    const shouldImageBeLandscape = viewWidth < viewHeight
    const realImageWidth = shouldImageBeLandscape ? viewWidth : imageWidth * (viewHeight / imageHeight)
    const realImageHeight = shouldImageBeLandscape ? imageHeight * (viewWidth / imageWidth) : viewHeight

    return (
      <Container
        onLayout={event => {
          const { width, height } = event.nativeEvent.layout
          this.setState(state => ({ ...state, viewDimensions: { width, height } }))
        }}>
        <PanGestureHandler
          ref={this.panHandler}
          simultaneousHandlers={this.pinchHandler}
          enabled={interactive}
          onGestureEvent={this.onPanGestureEvent}
          onHandlerStateChange={this.createOnPanHandlerStateChange(
            viewWidth,
            viewHeight,
            realImageWidth,
            realImageHeight,
          )}
          minDist={10}>
          <Animated.View
            style={{ flex: 1, flexDirection: shouldImageBeLandscape ? 'column' : 'row', justifyContent: 'center' }}
            collapsable={false}>
            <PinchGestureHandler
              ref={this.pinchHandler}
              enabled={interactive}
              simultaneousHandlers={this.panHandler}
              onGestureEvent={this.onPinchGestureEvent}
              onHandlerStateChange={this.createOnPinchHandlerStateChange(
                viewWidth,
                viewHeight,
                realImageWidth,
                realImageHeight,
              )}
              shouldCancelWhenOutside={false}>
              <Animated.Image
                style={[
                  {
                    width: realImageWidth,
                    height: realImageHeight,
                  },
                  {
                    transform: [
                      { scale: this.scale },
                      { translateX: this.scaledTranslateXWithOffset },
                      { translateY: this.scaledTranslateYWithOffset },
                    ],
                  },
                ]}
                onError={this.onImageLoadError}
                resizeMode='stretch'
                source={{
                  uri,
                }}
              />
            </PinchGestureHandler>
          </Animated.View>
        </PanGestureHandler>
      </Container>
    )
  }
}

export default PinchPanImage