From aeb3691696c303034f8fc97893e30b2b33bfb2c1 Mon Sep 17 00:00:00 2001 From: Chris Duarte Date: Fri, 11 Aug 2017 07:33:11 -0700 Subject: [PATCH] Implement new image preview components (#838) * Implement new image preview components * Review feedback --- app/screens/image_preview/image_preview.js | 42 +-- ...oomable_image.js => image_view.android.js} | 89 ++---- app/screens/image_preview/image_view.ios.js | 89 ++++++ app/screens/image_preview/previewer.js | 279 ++++++++++++++++++ 4 files changed, 417 insertions(+), 82 deletions(-) rename app/screens/image_preview/{zoomable_image.js => image_view.android.js} (78%) create mode 100644 app/screens/image_preview/image_view.ios.js create mode 100644 app/screens/image_preview/previewer.js diff --git a/app/screens/image_preview/image_preview.js b/app/screens/image_preview/image_preview.js index a2685ae01..d2a4cb0b2 100644 --- a/app/screens/image_preview/image_preview.js +++ b/app/screens/image_preview/image_preview.js @@ -22,7 +22,7 @@ import Orientation from 'react-native-orientation'; import FileAttachmentIcon from 'app/components/file_attachment_list/file_attachment_icon'; -import ZoomableImage from './zoomable_image'; +import Previewer from './previewer'; const {View: AnimatedView} = Animated; const {height: deviceHeight, width: deviceWidth} = Dimensions.get('window'); @@ -144,21 +144,13 @@ export default class ImagePreview extends PureComponent { }; 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); }; + handleImageDoubleTap = (x, y) => { + this.zoomableImages[this.state.currentFile].toggleZoom(x, y); + } + setHeaderAndFileInfoVisible = (show) => { this.setState({ showFileInfo: show @@ -180,7 +172,12 @@ export default class ImagePreview extends PureComponent { if (event.nativeEvent.contentOffset.x % this.state.deviceWidth === 0) { this.setState({ currentFile: (event.nativeEvent.contentOffset.x / this.state.deviceWidth), - pagingEnabled: true + pagingEnabled: true, + shouldShrinkImages: false + }); + } else if (!this.state.shouldShrinkImages && !this.state.isZooming) { + this.setState({ + shouldShrinkImages: true }); } }; @@ -223,7 +220,7 @@ export default class ImagePreview extends PureComponent { return ( { this.zoomableImages[index] = c; }} @@ -256,9 +253,11 @@ export default class ImagePreview extends PureComponent { theme={this.props.theme} imageHeight={Math.min(maxImageHeight, file.height)} imageWidth={Math.min(this.state.deviceWidth, file.width)} + shrink={this.state.shouldShrinkImages} wrapperHeight={this.state.deviceHeight} wrapperWidth={this.state.deviceWidth} onImageTap={this.handleImageTap} + onImageDoubleTap={this.handleImageDoubleTap} onZoom={this.imageIsZooming} /> ); @@ -378,12 +377,11 @@ const style = StyleSheet.create({ justifyContent: 'center' }, scrollView: { - flex: 1 + flex: 1, + backgroundColor: '#000' }, scrollViewContent: { - backgroundColor: '#000', - alignItems: 'center', - justifyContent: 'center' + backgroundColor: '#000' }, title: { flex: 1, @@ -393,7 +391,9 @@ const style = StyleSheet.create({ textAlign: 'center' }, wrapper: { - flex: 1, + position: 'absolute', + top: 0, + left: 0, backgroundColor: 'rgba(0, 0, 0, 0.8)' } }); diff --git a/app/screens/image_preview/zoomable_image.js b/app/screens/image_preview/image_view.android.js similarity index 78% rename from app/screens/image_preview/zoomable_image.js rename to app/screens/image_preview/image_view.android.js index f433773f4..31a172774 100644 --- a/app/screens/image_preview/zoomable_image.js +++ b/app/screens/image_preview/image_view.android.js @@ -5,11 +5,12 @@ import React, {Component} from 'react'; import PropTypes from 'prop-types'; import { + Animated, View, PanResponder } from 'react-native'; -import FileAttachmentImage from 'app/components/file_attachment_list/file_attachment_image'; +const {Image: AnimatedImage} = Animated; function calcDistance(x1, y1, x2, y2) { const dx = Math.abs(x1 - x2); @@ -45,22 +46,21 @@ function calcOffsetByZoom(width, height, imageWidth, imageHeight, zoom) { }; } -class ZoomableImage extends Component { +export default class ImageView extends Component { static propTypes = { - addFileToFetchCache: PropTypes.func.isRequired, - fetchCache: PropTypes.object.isRequired, - file: PropTypes.object.isRequired, - imageHeight: PropTypes.number.isRequired, - imageWidth: PropTypes.number.isRequired, + imageHeight: PropTypes.number, + imageWidth: PropTypes.number, + maximumZoomScale: PropTypes.number, + minimumZoomScale: PropTypes.number, onImageTap: PropTypes.func, - onZoom: PropTypes.func.isRequired, + onZoom: PropTypes.func, + style: PropTypes.object.isRequired, wrapperHeight: PropTypes.number.isRequired, - wrapperWidth: PropTypes.number.isRequired, - theme: PropTypes.object.isRequired + wrapperWidth: PropTypes.number.isRequired }; static defaultProps = { - onImageTap: () => false + onZoom: () => false }; constructor(props) { @@ -94,33 +94,8 @@ class ZoomableImage extends Component { 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; } @@ -174,17 +149,18 @@ class ZoomableImage extends Component { } } - zoomIn = (zoom = 2) => { + setZoom = (zoom = true) => { + const zoomScale = zoom ? this.props.maximumZoomScale : this.props.minimumZoomScale; const offsetByZoom = calcOffsetByZoom(this.state.width, this.state.height, - this.props.wrapperWidth, this.props.wrapperHeight, zoom); + this.props.wrapperWidth, this.props.wrapperHeight, zoomScale); this.setState({ - zoom, + zoom: zoomScale, left: offsetByZoom.left, top: offsetByZoom.top, initialX: this.state.width / 2, initialY: this.state.height / 2, - initialZoom: zoom, + initialZoom: zoomScale, initialTopWithoutZoom: this.state.top - offsetByZoom.top, initialLeftWithoutZoom: this.state.left - offsetByZoom.left }); @@ -271,16 +247,19 @@ class ZoomableImage extends Component { render() { const { - addFileToFetchCache, - fetchCache, - file, imageHeight, imageWidth, - theme, - wrapperHeight, - wrapperWidth + style, + ...otherProps } = this.props; + let height = style.height; + let width = style.width; + if (this.state.zoom > 1) { + height = imageHeight * this.state.zoom; + width = imageWidth * this.state.zoom; + } + return ( - ); } } - -export default ZoomableImage; diff --git a/app/screens/image_preview/image_view.ios.js b/app/screens/image_preview/image_view.ios.js new file mode 100644 index 000000000..9177fc3ba --- /dev/null +++ b/app/screens/image_preview/image_view.ios.js @@ -0,0 +1,89 @@ +import React, {PureComponent} from 'react'; +import PropTypes from 'prop-types'; +import { + Animated, + ScrollView +} from 'react-native'; + +const {Image: AnimatedImage} = Animated; + +export default class ImageView extends PureComponent { + static propTypes = { + maximumZoomScale: PropTypes.number, + minimumZoomScale: PropTypes.number, + onZoom: PropTypes.func, + showsHorizontalScrollIndicator: PropTypes.bool, + showsVerticalScrollIndicator: PropTypes.bool, + wrapperHeight: PropTypes.number.isRequired, + wrapperWidth: PropTypes.number.isRequired + } + + static defaultProps = { + maximumZoomScale: 3, + minimumZoomScale: 1, + onZoom: () => true, + showsHorizontalScrollIndicator: false, + showsVerticalScrollIndicator: false + } + + attachScrollView = (c) => { + if (c) { + this.scrollView = c; + this.scrollResponder = c.getScrollResponder(); + } + } + + setZoom = (zoom, x, y) => { + const rect = {}; + if (zoom) { + rect.x = x; + rect.y = y; + } else { + rect.height = this.props.wrapperHeight; + rect.width = this.props.wrapperWidth; + } + + this.scrollResponder.scrollResponderZoomTo({ + ...rect, + animated: true + }); + } + + handleScroll = (evt) => { + const {nativeEvent} = evt; + + clearTimeout(this.scrollEventTimeout); + this.scrollEventTimeout = setTimeout(() => { + this.props.onZoom(nativeEvent.zoomScale > 1); + }, 100); + } + + render() { + const { + maximumZoomScale, + minimumZoomScale, + showsHorizontalScrollIndicator, + showsVerticalScrollIndicator, + ...otherProps + } = this.props; + + return ( + + + + ); + } +} diff --git a/app/screens/image_preview/previewer.js b/app/screens/image_preview/previewer.js new file mode 100644 index 000000000..3f23f315b --- /dev/null +++ b/app/screens/image_preview/previewer.js @@ -0,0 +1,279 @@ +// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import React, {Component} from 'react'; +import PropTypes from 'prop-types'; +import { + ActivityIndicator, + Animated, + PanResponder, + Platform, + View, + StyleSheet +} from 'react-native'; +import {Client} from 'mattermost-redux/client'; + +import imageIcon from 'assets/images/icons/image.png'; + +import ImageView from './image_view'; + +const {View: AnimatedView} = Animated; + +const DOUBLE_CLICK_THRESHOLD = 250; + +export default class Previewer extends Component { + static propTypes = { + addFileToFetchCache: PropTypes.func.isRequired, + fetchCache: PropTypes.object.isRequired, + file: PropTypes.object, + gutter: PropTypes.number, + imageHeight: PropTypes.number, + imageWidth: PropTypes.number, + onImageTap: PropTypes.func, + onImageDoubleTap: PropTypes.func, + onZoom: PropTypes.func, + shrink: PropTypes.bool, + wrapperHeight: PropTypes.number, + wrapperWidth: PropTypes.number + }; + + static defaultProps = { + fadeInOnLoad: false, + gutter: 20, + loading: false, + onImageTap: () => true, + onImageDoubleTap: () => true, + onZoom: () => true + }; + + constructor(props) { + super(props); + + this.state = { + imageHeight: new Animated.Value(props.imageHeight), + imageWidth: new Animated.Value(props.imageWidth), + opacity: new Animated.Value(0), + requesting: true, + retry: 0, + zooming: false + }; + } + + componentWillMount() { + this.panResponder = PanResponder.create({ + onStartShouldSetPanResponderCapture: (evt, gestureState) => { + const {numberActiveTouches} = gestureState; + if (numberActiveTouches === 1 && !this.state.isZooming) { + return true; + } else if (numberActiveTouches === 1) { + this.handleResponderRelease(evt); + } + + return false; + }, + onPanResponderGrant: () => { + return; + }, + onPanResponderTerminate: () => { + return; + }, + onShouldBlockNativeResponder: () => false + }); + } + + componentWillReceiveProps(nextProps) { + if (this.props.shrink && !nextProps.shrink) { + this.setShrink(); + } else if (!this.props.shrink && nextProps.shrink) { + this.setShrink(true); + } + } + + setShrink = (shrink = false) => { + const {gutter, imageHeight, imageWidth} = this.props; + + let height = imageHeight; + let width = imageWidth; + const duration = 150; + if (shrink) { + height = height - gutter; + width = width - gutter; + } + + const animations = [ + Animated.timing(this.state.imageWidth, { + toValue: width, + duration + }) + ]; + + if (Platform.OS === 'android') { + animations.push( + Animated.timing(this.state.imageHeight, { + toValue: height, + duration + }) + ); + } + + Animated.parallel(animations).start(); + } + + handleResponderRelease = (evt) => { + clearTimeout(this.singleTap); + let cancelSingleTap = false; + if (this.lastTap && Date.now() - this.lastTap < DOUBLE_CLICK_THRESHOLD) { + cancelSingleTap = true; + } else { + this.lastTap = Date.now(); + } + + if (cancelSingleTap) { + const {nativeEvent} = evt; + const x = nativeEvent.locationX; + const y = nativeEvent.locationY; + + cancelSingleTap = false; + this.lastTap = null; + + this.props.onImageDoubleTap(x, y); + } else if (!this.state.isZooming) { + this.singleTap = setTimeout(() => { + this.props.onImageTap(); + }, DOUBLE_CLICK_THRESHOLD); + } + } + + handleLoadError = () => { + if (this.state.retry < 4) { + setTimeout(() => { + this.setState((prevState) => { + return { + retry: (prevState.retry + 1), + timestamp: Date.now() + }; + }); + }, 300); + } + }; + + handleLoad = () => { + this.setState({ + requesting: false + }); + + Animated.timing(this.state.opacity, { + toValue: 1, + duration: 300 + }).start(() => { + this.props.addFileToFetchCache(this.handleGetImageURL()); + }); + }; + + handleLoadStart = () => { + this.setState({ + requesting: true + }); + }; + + handleGetImageURL = () => { + const {file} = this.props; + + return Client.getFilePreviewUrl(file.id, this.state.timestamp); + }; + + attachImageView = (c) => { + this.imageView = c; + } + + handleZoom = (zoom) => { + this.setState({ + isZooming: zoom + }); + + this.props.onZoom(zoom); + } + + toggleZoom = (x, y) => { + const zoom = !this.state.isZooming; + + this.imageView.setZoom(zoom, x, y); + this.handleZoom(zoom); + } + + render() { + const { + fetchCache, + imageHeight, + imageWidth, + wrapperHeight, + wrapperWidth + } = this.props; + + let source = {}; + let usingIcon = false; + if (this.state.retry === 4) { + source = imageIcon; + usingIcon = true; + } else { + source = {uri: this.handleGetImageURL()}; + } + + let isInFetchCache = fetchCache[source.uri]; + if (usingIcon) { + isInFetchCache = true; + } + + const imageComponentLoaders = { + onError: (isInFetchCache) ? null : this.handleLoadError, + onLoadStart: isInFetchCache ? null : this.handleLoadStart, + onLoad: isInFetchCache ? null : this.handleLoad + }; + + const opacity = isInFetchCache ? 1 : this.state.opacity; + + return ( + + + + + {(!isInFetchCache && this.state.requesting) && + + + + } + + ); + } +} + +const style = StyleSheet.create({ + fileImageWrapper: { + alignItems: 'center', + justifyContent: 'center' + }, + loaderContainer: { + position: 'absolute', + height: '100%', + width: '100%', + alignItems: 'center', + justifyContent: 'center' + } +});