Implement new image preview components (#838)

* Implement new image preview components

* Review feedback
This commit is contained in:
Chris Duarte 2017-08-11 07:33:11 -07:00 committed by enahum
parent 523a66e023
commit aeb3691696
4 changed files with 417 additions and 82 deletions

View file

@ -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 (
<View
style={style.wrapper}
style={[style.wrapper, {height: this.state.deviceHeight, width: this.state.deviceWidth}]}
onLayout={this.onLayout}
>
<AnimatedView
@ -246,7 +243,7 @@ export default class ImagePreview extends PureComponent {
let component;
if (file.has_preview_image) {
component = (
<ZoomableImage
<Previewer
ref={(c) => {
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)'
}
});

View file

@ -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 (
<View
{...this.panResponder.panHandlers}
@ -303,23 +282,11 @@ class ZoomableImage extends Component {
height: this.state.height * this.state.zoom
}}
>
<FileAttachmentImage
addFileToFetchCache={addFileToFetchCache}
fetchCache={fetchCache}
file={file}
theme={theme}
imageHeight={imageHeight * this.state.zoom}
imageSize='fullsize'
imageWidth={imageWidth * this.state.zoom}
loadingBackgroundColor='#000'
resizeMode='contain'
wrapperBackgroundColor='#000'
wrapperHeight={wrapperHeight * this.state.zoom}
wrapperWidth={wrapperWidth * this.state.zoom}
<AnimatedImage
{...otherProps}
style={{height, width}}
/>
</View>
);
}
}
export default ZoomableImage;

View file

@ -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 (
<ScrollView
ref={this.attachScrollView}
alwaysBounceHorizontal={false}
alwaysBounceVertical={false}
bounces={false}
contentContainerStyle={{alignItems: 'center', justifyContent: 'center'}}
centerContent={true}
maximumZoomScale={maximumZoomScale}
minimumZoomScale={minimumZoomScale}
onScroll={this.handleScroll}
scrollEventThrottle={16}
showsHorizontalScrollIndicator={showsHorizontalScrollIndicator}
showsVerticalScrollIndicator={showsVerticalScrollIndicator}
>
<AnimatedImage {...otherProps}/>
</ScrollView>
);
}
}

View file

@ -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 (
<View
{...this.panResponder.panHandlers}
onResponderRelease={this.handleResponderRelease}
style={[style.fileImageWrapper, {height: wrapperHeight, width: wrapperWidth}]}
>
<AnimatedView style={{height: imageHeight, width: this.state.imageWidth, backgroundColor: '#000', opacity}}>
<ImageView
ref={this.attachImageView}
source={source}
minimumZoomScale={1}
maximumZoomScale={3}
onZoom={this.handleZoom}
resizeMode='contain'
imageHeight={imageHeight}
imageWidth={imageWidth}
style={{height: this.state.imageHeight, width: this.state.imageWidth}}
wrapperHeight={wrapperHeight}
wrapperWidth={wrapperWidth}
{...imageComponentLoaders}
/>
</AnimatedView>
{(!isInFetchCache && this.state.requesting) &&
<View style={[style.loaderContainer, {backgroundColor: 'white'}]}>
<ActivityIndicator size='small'/>
</View>
}
</View>
);
}
}
const style = StyleSheet.create({
fileImageWrapper: {
alignItems: 'center',
justifyContent: 'center'
},
loaderContainer: {
position: 'absolute',
height: '100%',
width: '100%',
alignItems: 'center',
justifyContent: 'center'
}
});