diff --git a/src/components/ImageView/index.native.js b/src/components/ImageView/index.native.js index bcfcaf78d5c6..b365f507a4fc 100644 --- a/src/components/ImageView/index.native.js +++ b/src/components/ImageView/index.native.js @@ -1,13 +1,13 @@ -import React, {PureComponent} from 'react'; +import React, {useState, useRef, useEffect} from 'react'; import PropTypes from 'prop-types'; import {View, PanResponder} from 'react-native'; import ImageZoom from 'react-native-image-pan-zoom'; import _ from 'underscore'; import styles from '../../styles/styles'; import variables from '../../styles/variables'; -import withWindowDimensions, {windowDimensionsPropTypes} from '../withWindowDimensions'; import FullscreenLoadingIndicator from '../FullscreenLoadingIndicator'; import Image from '../Image'; +import useWindowDimensions from '../../hooks/useWindowDimensions'; /** * On the native layer, we use a image library to handle zoom functionality @@ -25,59 +25,34 @@ const propTypes = { /** Function for handle on press */ onPress: PropTypes.func, - ...windowDimensionsPropTypes, + /** Additional styles to add to the component */ + style: PropTypes.oneOfType([PropTypes.arrayOf(PropTypes.object), PropTypes.object]), }; const defaultProps = { isAuthTokenRequired: false, onPress: () => {}, + style: {}, }; -class ImageView extends PureComponent { - constructor(props) { - super(props); - - this.state = { - isLoading: true, - imageWidth: 0, - imageHeight: 0, - interactionPromise: undefined, - }; - - // Use the default double click interval from the ImageZoom library - // https://github.com/ascoders/react-native-image-zoom/blob/master/src/image-zoom/image-zoom.type.ts#L79 - this.doubleClickInterval = 175; - this.imageZoomScale = 1; - this.lastClickTime = 0; - this.amountOfTouches = 0; - - // PanResponder used to capture how many touches are active on the attachment image - this.panResponder = PanResponder.create({ - onStartShouldSetPanResponder: this.updatePanResponderTouches.bind(this), - }); - - this.configureImageZoom = this.configureImageZoom.bind(this); - this.imageLoadingStart = this.imageLoadingStart.bind(this); - } +// Use the default double click interval from the ImageZoom library +// https://github.com/ascoders/react-native-image-zoom/blob/master/src/image-zoom/image-zoom.type.ts#L79 +const DOUBLE_CLICK_INTERVAL = 175; - componentDidUpdate(prevProps) { - if (this.props.url === prevProps.url) { - return; - } +function ImageView({isAuthTokenRequired, url, onScaleChanged, onPress, style}) { + const {windowWidth, windowHeight} = useWindowDimensions(); - this.imageLoadingStart(); + const [isLoading, setIsLoading] = useState(true); + const [imageDimensions, setImageDimensions] = useState({ + width: 0, + height: 0, + }); + const [containerHeight, setContainerHeight] = useState(null); - if (this.interactionPromise) { - this.state.interactionPromise.cancel(); - } - } - - componentWillUnmount() { - if (!this.state.interactionPromise) { - return; - } - this.state.interactionPromise.cancel(); - } + const imageZoomScale = useRef(1); + const lastClickTime = useRef(0); + const numberOfTouches = useRef(0); + const zoom = useRef(null); /** * Updates the amount of active touches on the PanResponder on our ImageZoom overlay View @@ -86,14 +61,58 @@ class ImageView extends PureComponent { * @param {GestureState} gestureState * @returns {Boolean} */ - updatePanResponderTouches(e, gestureState) { + const updatePanResponderTouches = (e, gestureState) => { if (_.isNumber(gestureState.numberActiveTouches)) { - this.amountOfTouches = gestureState.numberActiveTouches; + numberOfTouches.current = gestureState.numberActiveTouches; } // We don't need to set the panResponder since all we care about is checking the gestureState, so return false return false; - } + }; + + // PanResponder used to capture how many touches are active on the attachment image + const panResponder = useRef( + PanResponder.create({ + onStartShouldSetPanResponder: updatePanResponderTouches, + }), + ).current; + + /** + * When the url changes and the image must load again, + * this resets the zoom to ensure the next image loads with the correct dimensions. + */ + const resetImageZoom = () => { + if (imageZoomScale.current !== 1) { + imageZoomScale.current = 1; + } + + if (zoom.current) { + zoom.current.centerOn({ + x: 0, + y: 0, + scale: 1, + duration: 0, + }); + } + }; + + const imageLoadingStart = () => { + if (isLoading) { + return; + } + + resetImageZoom(); + setImageDimensions({ + width: 0, + height: 0, + }); + setIsLoading(true); + }; + + useEffect(() => { + imageLoadingStart(); + // eslint-disable-next-line react-hooks/exhaustive-deps -- this effect only needs to run when the url changes + }, [url]); /** * The `ImageZoom` component requires image dimensions which @@ -102,148 +121,126 @@ class ImageView extends PureComponent { * * @param {Object} nativeEvent */ - configureImageZoom({nativeEvent}) { - let imageWidth = nativeEvent.width; - let imageHeight = nativeEvent.height; - const containerWidth = Math.round(this.props.windowWidth); - const containerHeight = Math.round(this.state.containerHeight ? this.state.containerHeight : this.props.windowHeight); + const configureImageZoom = ({nativeEvent}) => { + let imageZoomWidth = nativeEvent.width; + let imageZoomHeight = nativeEvent.height; + const roundedContainerWidth = Math.round(windowWidth); + const roundedContainerHeight = Math.round(containerHeight || windowHeight); - const aspectRatio = Math.min(containerHeight / imageHeight, containerWidth / imageWidth); + const aspectRatio = Math.min(roundedContainerHeight / imageZoomHeight, roundedContainerWidth / imageZoomWidth); - imageHeight *= aspectRatio; - imageWidth *= aspectRatio; + imageZoomHeight *= aspectRatio; + imageZoomWidth *= aspectRatio; // Resize the image to max dimensions possible on the Native platforms to prevent crashes on Android. To keep the same behavior, apply to IOS as well. const maxDimensionsScale = 11; - imageWidth = Math.min(imageWidth, containerWidth * maxDimensionsScale); - imageHeight = Math.min(imageHeight, containerHeight * maxDimensionsScale); - this.setState({imageHeight, imageWidth, isLoading: false}); - } + imageZoomWidth = Math.min(imageZoomWidth, roundedContainerWidth * maxDimensionsScale); + imageZoomHeight = Math.min(imageZoomHeight, roundedContainerHeight * maxDimensionsScale); - /** - * When the url changes and the image must load again, - * this resets the zoom to ensure the next image loads with the correct dimensions. - */ - resetImageZoom() { - if (this.imageZoomScale !== 1) { - this.imageZoomScale = 1; + setImageDimensions({ + height: imageZoomHeight, + width: imageZoomWidth, + }); + setIsLoading(false); + }; + + const configurePanResponder = () => { + const currentTimestamp = new Date().getTime(); + const isDoubleClick = currentTimestamp - lastClickTime.current <= DOUBLE_CLICK_INTERVAL; + lastClickTime.current = currentTimestamp; + + // Let ImageZoom handle the event if the tap is more than one touchPoint or if we are zoomed in + if (numberOfTouches.current === 2 || imageZoomScale.current !== 1) { + return true; } - if (this.zoom) { - this.zoom.centerOn({ + // When we have a double click and the zoom scale is 1 then programmatically zoom the image + // but let the tap fall through to the parent so we can register a swipe down to dismiss + if (isDoubleClick) { + zoom.current.centerOn({ x: 0, y: 0, - scale: 1, - duration: 0, + scale: 2, + duration: 100, }); - } - } - imageLoadingStart() { - if (this.state.isLoading) { - return; + // onMove will be called after the zoom animation. + // So it's possible to zoom and swipe and stuck in between the images. + // Sending scale just when we actually trigger the animation makes this nearly impossible. + // you should be really fast to catch in between state updates. + // And this lucky case will be fixed by migration to UI thread only code + // with gesture handler and reanimated. + onScaleChanged(2); } - this.resetImageZoom(); - this.setState({imageHeight: 0, imageWidth: 0, isLoading: true}); - } - - render() { - // Default windowHeight accounts for the modal header height - const windowHeight = this.props.windowHeight - variables.contentHeaderHeight; - const hasImageDimensions = this.state.imageWidth !== 0 && this.state.imageHeight !== 0; - const shouldShowLoadingIndicator = this.state.isLoading || !hasImageDimensions; - - // Zoom view should be loaded only after measuring actual image dimensions, otherwise it causes blurred images on Android - return ( - { - const layout = event.nativeEvent.layout; - this.setState({ - containerHeight: layout.height, - }); - }} - > - {Boolean(this.state.containerHeight) && ( - (this.zoom = el)} - onClick={() => this.props.onPress()} - cropWidth={this.props.windowWidth} - cropHeight={windowHeight} - imageWidth={this.state.imageWidth} - imageHeight={this.state.imageHeight} - onStartShouldSetPanResponder={() => { - const isDoubleClick = new Date().getTime() - this.lastClickTime <= this.doubleClickInterval; - this.lastClickTime = new Date().getTime(); - - // Let ImageZoom handle the event if the tap is more than one touchPoint or if we are zoomed in - if (this.amountOfTouches === 2 || this.imageZoomScale !== 1) { - return true; - } - - // When we have a double click and the zoom scale is 1 then programmatically zoom the image - // but let the tap fall through to the parent so we can register a swipe down to dismiss - if (isDoubleClick) { - this.zoom.centerOn({ - x: 0, - y: 0, - scale: 2, - duration: 100, - }); - - // onMove will be called after the zoom animation. - // So it's possible to zoom and swipe and stuck in between the images. - // Sending scale just when we actually trigger the animation makes this nearly impossible. - // you should be really fast to catch in between state updates. - // And this lucky case will be fixed by migration to UI thread only code - // with gesture handler and reanimated. - this.props.onScaleChanged(2); - } - - // We must be either swiping down or double tapping since we are at zoom scale 1 - return false; - }} - onMove={({scale}) => { - this.props.onScaleChanged(scale); - this.imageZoomScale = scale; - }} - > - - {/** - Create an invisible view on top of the image so we can capture and set the amount of touches before - the ImageZoom's PanResponder does. Children will be triggered first, so this needs to be inside the - ImageZoom to work - */} - - - )} - {shouldShowLoadingIndicator && } - - ); - } + + // We must be either swiping down or double tapping since we are at zoom scale 1 + return false; + }; + + // Default windowHeight accounts for the modal header height + const calculatedWindowHeight = windowHeight - variables.contentHeaderHeight; + const hasImageDimensions = imageDimensions.width !== 0 && imageDimensions.height !== 0; + const shouldShowLoadingIndicator = isLoading || !hasImageDimensions; + + // Zoom view should be loaded only after measuring actual image dimensions, otherwise it causes blurred images on Android + return ( + { + const layout = event.nativeEvent.layout; + setContainerHeight(layout.height); + }} + > + {Boolean(containerHeight) && ( + { + onScaleChanged(scale); + imageZoomScale.current = scale; + }} + > + + {/** + Create an invisible view on top of the image so we can capture and set the amount of touches before + the ImageZoom's PanResponder does. Children will be triggered first, so this needs to be inside the + ImageZoom to work + */} + + + )} + {shouldShowLoadingIndicator && } + + ); } ImageView.propTypes = propTypes; ImageView.defaultProps = defaultProps; +ImageView.displayName = 'ImageView'; -export default withWindowDimensions(ImageView); +export default ImageView;