Implement new image preview components (#838)
* Implement new image preview components * Review feedback
This commit is contained in:
parent
523a66e023
commit
aeb3691696
4 changed files with 417 additions and 82 deletions
|
|
@ -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)'
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
89
app/screens/image_preview/image_view.ios.js
Normal file
89
app/screens/image_preview/image_view.ios.js
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
||||
279
app/screens/image_preview/previewer.js
Normal file
279
app/screens/image_preview/previewer.js
Normal 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'
|
||||
}
|
||||
});
|
||||
Loading…
Reference in a new issue