From eb99aac45cb735e5d443e45774387b3e90791787 Mon Sep 17 00:00:00 2001 From: Chris Duarte Date: Wed, 29 Mar 2017 20:35:35 -0700 Subject: [PATCH] Plt 5991 pinch zoom (#414) * Rename and get correct image URL * Add pinch and zoom * Fix pinch and zoom on device * Fix dismiss drag and landscape --- .../file_attachment_list/file_attachment.js | 4 +- ...nt_preview.js => file_attachment_image.js} | 36 +- .../file_upload_preview.js | 4 +- app/scenes/image_preview/image_preview.js | 95 ++++-- app/scenes/image_preview/zoomable_image.js | 323 ++++++++++++++++++ 5 files changed, 428 insertions(+), 34 deletions(-) rename app/components/file_attachment_list/{file_attachment_preview.js => file_attachment_image.js} (80%) create mode 100644 app/scenes/image_preview/zoomable_image.js diff --git a/app/components/file_attachment_list/file_attachment.js b/app/components/file_attachment_list/file_attachment.js index 3a638a4a8..b01d5857f 100644 --- a/app/components/file_attachment_list/file_attachment.js +++ b/app/components/file_attachment_list/file_attachment.js @@ -18,7 +18,7 @@ import {changeOpacity, makeStyleSheetFromTheme} from 'app/utils/theme'; import * as Utils from 'mattermost-redux/utils/file_utils.js'; import FileAttachmentIcon from './file_attachment_icon'; -import FileAttachmentPreview from './file_attachment_preview'; +import FileAttachmentImage from './file_attachment_image'; export default class FileAttachment extends PureComponent { static propTypes = { @@ -61,7 +61,7 @@ export default class FileAttachment extends PureComponent { let fileAttachmentComponent; if (file.has_preview_image) { fileAttachmentComponent = ( - { - this.props.addFileToFetchCache(Client.getFilePreviewUrl(this.props.file.id, this.state.timestamp)); + this.props.addFileToFetchCache(this.handleGetImageURL()); }); }; @@ -84,6 +96,20 @@ export default class FileAttachmentPreview extends PureComponent { }); }; + handleGetImageURL = () => { + const {file, imageSize} = this.props; + + switch (imageSize) { + case IMAGE_SIZE.Fullsize: + return Client.getFileUrl(file.id, this.state.timestamp); + case IMAGE_SIZE.Preview: + return Client.getFilePreviewUrl(file.id, this.state.timestamp); + case IMAGE_SIZE.Thumbnail: + default: + return Client.getFileThumbnailUrl(file.id, this.state.timestamp); + } + } + render() { const { fetchCache, @@ -97,12 +123,16 @@ export default class FileAttachmentPreview extends PureComponent { wrapperWidth } = this.props; - let source = file.id ? {uri: Client.getFilePreviewUrl(file.id, this.state.timestamp)} : {}; + let source = {}; + if (this.state.retry === 4) { source = imageIcon; + } else if (file.id) { + source = {uri: this.handleGetImageURL()}; } const isInFetchCache = fetchCache[source.uri]; + const imageComponentLoaders = { onError: isInFetchCache ? null : this.handleLoadError, onLoadStart: isInFetchCache ? null : this.handleLoadStart, diff --git a/app/components/file_upload_preview/file_upload_preview.js b/app/components/file_upload_preview/file_upload_preview.js index 8d8be534b..4e072a68d 100644 --- a/app/components/file_upload_preview/file_upload_preview.js +++ b/app/components/file_upload_preview/file_upload_preview.js @@ -13,7 +13,7 @@ import { import Font from 'react-native-vector-icons/Ionicons'; import {RequestStatus} from 'mattermost-redux/constants'; -import FileAttachmentPreview from 'app/components/file_attachment_list/file_attachment_preview'; +import FileAttachmentImage from 'app/components/file_attachment_list/file_attachment_image'; import KeyboardLayout from 'app/components/layout/keyboard_layout'; const {height: deviceHeight} = Dimensions.get('window'); @@ -47,7 +47,7 @@ export default class FileUploadPreview extends PureComponent { key={file.clientId} style={style.preview} > - file.id === props.fileId); this.state = { currentFile, @@ -65,14 +71,14 @@ export default class ImagePreview extends PureComponent { } componentWillMount() { - this.panResponder = PanResponder.create({ - onMoveShouldSetPanResponderCapture: this.onMoveShouldSetPanResponderCapture, + this.mainViewPanResponder = PanResponder.create({ + onMoveShouldSetPanResponderCapture: this.mainViewMoveShouldSetPanResponderCapture, onPanResponderMove: Animated.event([null, { dx: 0, dy: this.state.drag.y }]), - onPanResponderRelease: this.onPanResponderRelease, - onPanResponderTerminate: this.onPanResponderRelease + onPanResponderRelease: this.mainViewPanResponderRelease, + onPanResponderTerminate: this.mainViewPanResponderRelease }); } @@ -100,14 +106,21 @@ export default class ImagePreview extends PureComponent { componentWillUnmount() { Orientation.lockToPortrait(); + if (Platform.OS === 'ios') { + StatusBar.setHidden(false, 'fade'); + } } - onMoveShouldSetPanResponderCapture = (evt, gestureState) => { + mainViewMoveShouldSetPanResponderCapture = (evt, gestureState) => { + if (gestureState.numberActiveTouches === 2 || this.state.isZooming) { + return false; + } + const {dx, dy} = gestureState; return (Math.abs(dy) > DRAG_VERTICAL_THRESHOLD_START && dx < DRAG_HORIZONTAL_THRESHOLD); } - onPanResponderRelease = (evt, gestureState) => { + mainViewPanResponderRelease = (evt, gestureState) => { if (Math.abs(gestureState.dy) > DRAG_VERTICAL_THRESHOLD_END) { this.props.actions.goBack(); } else { @@ -117,15 +130,32 @@ export default class ImagePreview extends PureComponent { } } - toggleHeaderAndFileInfo = () => { - const showFileInfo = !this.state.showFileInfo; + handleImageTap = () => { + /*if (!this.lastPress) { + this.lastPress = Date.now(); + } else if (Date.now() - this.lastPress < 400) { + if (this.zoomableImages.hasOwnProperty(this.state.currentFile)) { + this.zoomableImages[this.state.currentFile].zoomIn(); + return; + } + } else { + this.lastPress = Date.now(); + + }*/ + + this.setHeaderAndFileInfoVisible(!this.state.showFileInfo); + } + + setHeaderAndFileInfoVisible = (show) => { this.setState({ - showFileInfo + showFileInfo: show }); - StatusBar.setHidden(!showFileInfo, 'fade'); + if (Platform.OS === 'ios') { + StatusBar.setHidden(!show, 'fade'); + } - const opacity = showFileInfo ? 1 : 0; + const opacity = show ? 1 : 0; Animated.timing(this.state.footerOpacity, { toValue: opacity, @@ -155,8 +185,17 @@ export default class ImagePreview extends PureComponent { } } + imageIsZooming = (zooming) => { + if (zooming !== this.state.isZooming) { + this.setHeaderAndFileInfoVisible(!zooming); + this.setState({ + isZooming: zooming + }); + } + } + render() { - const maxImageHeight = this.state.deviceHeight; + const maxImageHeight = this.state.deviceHeight - STATUSBAR_HEIGHT; return ( - {this.props.files.map((file) => { + {this.props.files.map((file, index) => { let component; if (file.has_preview_image) { component = ( - { + this.zoomableImages[index] = c; + }} addFileToFetchCache={this.props.actions.addFileToFetchCache} fetchCache={this.props.fetchCache} file={file} theme={this.props.theme} imageHeight={Math.min(maxImageHeight, file.height)} imageWidth={Math.min(this.state.deviceWidth, file.width)} - resizeMode='contain' - wrapperBackgroundColor='#000' - wrapperHeight={maxImageHeight} + wrapperHeight={this.state.deviceHeight} wrapperWidth={this.state.deviceWidth} + onImageTap={this.handleImageTap} + onZoom={this.imageIsZooming} /> ); } else { @@ -209,14 +252,12 @@ export default class ImagePreview extends PureComponent { } return ( - - - {component} - - + {component} + ); })} diff --git a/app/scenes/image_preview/zoomable_image.js b/app/scenes/image_preview/zoomable_image.js new file mode 100644 index 000000000..06fb3fde2 --- /dev/null +++ b/app/scenes/image_preview/zoomable_image.js @@ -0,0 +1,323 @@ +// This control is based on Leonti's Stack Overflow post: +// http://stackoverflow.com/users/219449/leonti +// http://stackoverflow.com/questions/36368919/scrollable-image-with-pinch-to-zoom + +import React, {Component, PropTypes} from 'react'; +import { + View, + PanResponder +} from 'react-native'; + +import FileAttachmentImage from 'app/components/file_attachment_list/file_attachment_image'; + +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; // eslint-disable-line + } + + return { + x: middle(x1, x2), + y: middle(y1, y2) + }; +} + +function maxOffset(offset, windowDimension, imageDimension) { + const max = windowDimension - imageDimension; + if (max >= 0) { + return 0; + } + return offset < max ? max : offset; +} + +function calcOffsetByZoom(width, height, imageWidth, imageHeight, zoom) { + const xDiff = (imageWidth * zoom) - width; + const yDiff = (imageHeight * zoom) - height; + return { + left: -xDiff / 2, + top: -yDiff / 2 + }; +} + +class ZoomableImage extends Component { + static propTypes = { + addFileToFetchCache: PropTypes.func.isRequired, + fetchCache: PropTypes.object.isRequired, + file: PropTypes.object.isRequired, + imageHeight: PropTypes.number.isRequired, + imageWidth: PropTypes.number.isRequired, + onImageTap: PropTypes.func, + onZoom: PropTypes.func.isRequired, + wrapperHeight: PropTypes.number.isRequired, + wrapperWidth: PropTypes.number.isRequired, + theme: PropTypes.object.isRequired + }; + + static defaultProps = { + onImageTap: () => false + }; + + constructor(props) { + super(props); + + this.onLayout = this.onLayout.bind(this); + + this.state = { + zoom: 1, + maxZoom: 3, + minZoom: 1, + layoutKnown: false, + isZooming: false, + isMoving: false, + initialDistance: 0, + initialX: 0, + initalY: 0, + offsetTop: 0, + offsetLeft: 0, + initialTop: 0, + initialLeft: 0, + initialTopWithoutZoom: 0, + initialLeftWithoutZoom: 0, + initialZoom: 1, + top: 0, + left: 0, + height: props.wrapperHeight, + width: props.wrapperWidth + }; + } + + componentWillMount() { + this.panResponder = PanResponder.create({ + onStartShouldSetPanResponder: () => { + this.tap = Date.now(); + + return true; + }, + onStartShouldSetPanResponderCapture: (evt, gestureState) => { + if (gestureState.numberActiveTouches === 2 || this.state.zoom > 1) { + // Store each press for double tap detection + /*if (!this.lastPress) { + this.lastPress = Date.now(); + } else if (Date.now() - this.lastPress < 400 && Date.now() - this.lastPress > 100) { + this.setState({ + zoom: 1, + top: 0, + offsetTop: 0, + left: 0, + offsetLeft: 0 + }); + this.props.onZoom(false); + this.lastPress = null; + + return false; + }*/ + + this.lastPress = Date.now(); + + this.props.onZoom(true); + return true; + } + + return false; + }, + onMoveShouldSetPanResponder: (evt, gestureState) => { + return gestureState.numberActiveTouches === 2 || this.state.zoom > 1; + }, + onMoveShouldSetPanResponderCapture: (evt, gestureState) => { + return gestureState.numberActiveTouches === 2 || this.state.zoom > 1; + }, + onPanResponderGrant: () => { + return; + }, + onPanResponderMove: (evt) => { + const touches = evt.nativeEvent.touches; + + if (touches.length === 2) { + const touch1 = touches[0]; + const touch2 = touches[1]; + + this.processPinch(touch1.pageX, touch1.pageY, + touch2.pageX, touch2.pageY); + } else if (touches.length === 1 && !this.state.isZooming) { + this.processTouch(touches[0].pageX, touches[0].pageY); + } + }, + onPanResponderTerminationRequest: () => { + return this.state.zoom === 1; + }, + onPanResponderRelease: () => { + this.props.onZoom(this.state.zoom > 1); + this.setState({ + isZooming: false, + isMoving: false + }); + }, + onPanResponderTerminate: () => { + return; + }, + onShouldBlockNativeResponder: () => false + }); + } + + componentWillReceiveProps(nextProps) { + if (nextProps.wrapperWidth !== this.state.width || nextProps.wrapperHeight !== this.state.height) { + this.setState({ + height: nextProps.wrapperHeight, + width: nextProps.wrapperWidth + }); + } + } + + zoomIn = (zoom = 2) => { + const offsetByZoom = calcOffsetByZoom(this.state.width, this.state.height, + this.props.wrapperWidth, this.props.wrapperHeight, zoom); + + this.setState({ + zoom, + left: offsetByZoom.left, + top: offsetByZoom.top, + initialX: this.state.width / 2, + initialY: this.state.height / 2, + initialZoom: zoom, + initialTopWithoutZoom: this.state.top - offsetByZoom.top, + initialLeftWithoutZoom: this.state.left - offsetByZoom.left + }); + this.props.onZoom(true); + } + + processPinch(x1, y1, x2, y2) { + const distance = calcDistance(x1, y1, x2, y2); + const center = calcCenter(x1, y1, x2, y2); + + if (this.state.isZooming) { + const touchZoom = distance / this.state.initialDistance; + const zoom = touchZoom * this.state.initialZoom > this.state.minZoom ? touchZoom * this.state.initialZoom : this.state.minZoom; + if (zoom > this.state.maxZoom) { + return; + } + const offsetByZoom = calcOffsetByZoom(this.state.width, this.state.height, + this.props.wrapperWidth, this.props.wrapperHeight, zoom); + const left = (this.state.initialLeftWithoutZoom * touchZoom) + offsetByZoom.left; + const top = (this.state.initialTopWithoutZoom * touchZoom) + offsetByZoom.top; + + this.setState({ + zoom, + left: left > 0 ? 0 : maxOffset(left, this.state.width, this.props.wrapperWidth * zoom), + top: top > 0 ? 0 : maxOffset(top, this.state.height, this.props.wrapperHeight * zoom) + }); + } else { + const offsetByZoom = calcOffsetByZoom(this.state.width, this.state.height, + this.props.wrapperWidth, this.props.wrapperHeight, this.state.zoom); + this.setState({ + isZooming: true, + initialDistance: distance, + initialX: center.x, + initialY: center.y, + initialTop: this.state.top, + initialLeft: this.state.left, + initialZoom: this.state.zoom, + initialTopWithoutZoom: this.state.top - offsetByZoom.top, + initialLeftWithoutZoom: this.state.left - offsetByZoom.left + }); + } + } + + processTouch(x, y) { + if (this.state.isMoving) { + const left = this.state.initialLeft + x - this.state.initialX; // eslint-disable-line + const top = this.state.initialTop + y - this.state.initialY; // eslint-disable-line + + this.setState({ + left: left > 0 ? 0 : maxOffset(left, this.state.width, this.props.wrapperWidth * this.state.zoom), + top: top > 0 ? 0 : maxOffset(top, this.state.height, this.props.wrapperHeight * this.state.zoom) + }); + } else { + this.setState({ + isMoving: true, + initialX: x, + initialY: y, + initialTop: this.state.top, + initialLeft: this.state.left + }); + } + } + + onLayout(event) { + if (this.state.layoutKnown) { + return; + } + + const layout = event.nativeEvent.layout; + + if (layout.width === this.state.width && layout.height === this.state.height) { + return; + } + + const offsetTop = 0; // eslint-disable-line + + this.setState({ + layoutKnown: true, + width: this.props.wrapperWidth, + height: this.props.wrapperHeight, + offsetTop + }); + } + + render() { + const { + addFileToFetchCache, + fetchCache, + file, + imageHeight, + imageWidth, + theme, + wrapperHeight, + wrapperWidth + } = this.props; + + return ( + { + if (Date.now() - this.tap < 100) { + this.props.onImageTap(); + } + + this.props.onZoom(this.state.zoom > 1); + this.setState({ + isZooming: false, + isMoving: false + }); + }} + style={{ + position: 'absolute', + top: this.state.offsetTop + this.state.top, + left: this.state.offsetLeft + this.state.left, + width: this.state.width * this.state.zoom, + height: this.state.height * this.state.zoom + }} + > + + + ); + } +} + +export default ZoomableImage;