Allows panning beyond edges of container
Opened this issue ยท 19 comments
I'm not sure if this is a bug or if I have the wrong styles applied, but as you can see by my example, panning is being allowed beyond the container's edges, even when zoomed out. Obviously, you should be able to pan while zoomed in, but only to the edges of the container. The green background in my example should not be visible at any time. I am using the latest version (v2.0.0).
import React from 'react'
import { Dimensions, View, Image } from 'react-native'
import { ReactNativeZoomableView } from '@openspacelabs/react-native-zoomable-view'
const width = Dimensions.get('window').width
const height = Dimensions.get('window').height
export default class ZoomPhoto extends React.PureComponent {
render() {
return (
<View style={{
flex: 1,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'green',
}}>
<ReactNativeZoomableView
style={{
width: width,
height: height,
backgroundColor: 'red',
}}
initialZoom={1}
minZoom={1}
maxZoom={3}
contentWidth={width}
contentHeight={height}
bindToBorders={true}
>
<Image
source={{ uri: this.props.image_url }}
style={{ width: '100%', height: '100%', resizeMode: 'contain' }}
/>
</ReactNativeZoomableView>
</View>
)
}
}
@andrewhavens I think that you set incorrect values for contentWidth and contentHeight, try to set your original image dimensions
@professorkolik Thanks for your reply. Unfortunately, your suggestion does not work. I can set the contentWidth
to 100 or 10000, or don't specify it at all, and the behavior stays the same.
@andrewhavens I believe this is behaving as intended. It works a bit like "over-scroll", and the idea is that you can zoom in on details at the edge of your image by bringing the edge of the image toward the centre of the viewport. I think this is a more common effect on iOS than on Android.
The intent is that your finger stays in the same place on the image while you're padding, even if you get to the edge of the viewport.
@elliottkember That behavior makes sense when you're zoomed in, but in this example, I am zoomed all the way out. I can fling the photo so far out of the viewport that I would have a hard time finding it again. It does not spring back to center. It allows me to endlessly pan the photo out of view.
I am experiencing a similar issue. In addition to being able to endlessly pan away the image, when I try to zoom on my photo it also appears to focus on the bottom-right corner, as if that's where I'm pinching.
It very much seems like a bug, rather than an intended feature and I strongly believe these 2 issues are connected. Anyone have any input on this?
Edit:
After additional experimentation the error appears to start happening inside views you navigate to through react-native-router. Meaning, the ReactNativeZoomableView on a main/initial page works no problem, but on a details page it starts exhibiting all these issues, namely that Zooming doesn't focus on the pinched area and panning can go beyond edges of the container.
Your configurations look correct except for contentWidth
and contentHeight
. You're passing in the window's width and height, which causes the pan boundaries to be much larger than the actual content. You'll need to pass in the RENDERED size of the image. You can try this snack https://snack.expo.dev/@thomasttvo/e5b899
FYI - the overscroll feature that @elliottkember mentioned is turned on by setting panBoundaryPadding={number}
, but I don't see that here
@thomasttvo thank you so much for clarifying that the contentWidth
and contentHeight
have to be scaled down values that fit into the viewport. I basically had the same problem as OP and apparently did not understand the explanation from the readme correctly until i read your comment.
Thanks @alexhochreiter for letting me know about the confusion, we'll take a look at the readme again and improve its verbiage in later versions.
I am having the same issue as OP. I tried setting the contentWidth and contentHeight to different values but it always has the over scroll animation when panning. Also tried not including panBoundaryPadding and tried setting it to a few numeric values including 0. That still did not get rid of the over scroll.
I found an older version of the ReactNativeZoomableView package worked as I am looking for. I tried copying the example but it no longer works. When a user pans the image, it stops at the image border while the image is zoomed it. It also does not allow panning when the image is at the initial zoom level. Is there a way to get this component to not have the over scroll feature at all as in the example below?
https://snack.expo.dev/@ivandjl/react-native-zoomable-view?platform=android
Edit: I just noticed the example is using the @dudigital/react-native-zoomable-view/src/ReactNativeZoomableView package which the latest version of that package also does not have the over scroll feature. I'll just use it for now but would still like to know if this package has that possibility since the other one is deprecated
is there any way to disable panning beyond edges also when zooming ? I am using disablePanOnInitialZoom but I want this behavior also when I zoom and get to edge of image.
I am experiencing a similar issue. In addition to being able to endlessly pan away the image, when I try to zoom on my photo it also appears to focus on the bottom-right corner, as if that's where I'm pinching.
It very much seems like a bug, rather than an intended feature and I strongly believe these 2 issues are connected. Anyone have any input on this?
Edit: After additional experimentation the error appears to start happening inside views you navigate to through react-native-router. Meaning, the ReactNativeZoomableView on a main/initial page works no problem, but on a details page it starts exhibiting all these issues, namely that Zooming doesn't focus on the pinched area and panning can go beyond edges of the container.
have you managed to fix this issue ? I am also facing this issue with using react-native-router-flux, when I render the zoomable view in the main screen it is working correctly an not allowing panning beyond edges, I have to use routing and I'm stuck on this foe few days, help please ! @thomasttvo
@Adrian-Jablonski @manarfalah do you guys have a repo where you can reproduce the issue?
@thomasttvo actually no I am facing this issue on my work project repository, but you can try to open new react-native project and install react-native-router-flux, add the example app of zoomable view as a new screen and add it as a scene in router. this is how you can reproduce the issue.
@Adrian-Jablonski @manarfalah do you guys have a repo where you can reproduce the issue?
@thomasttvo Did you manage to reproduce the issue ? still stuck on trying to fix it.
An old issue, but here is my way of fixing this :
<ReactNativeZoomableView
maxZoom={...}
minZoom={...}
initialZoom={...}
contentWidth={1600}
contentHeight={1800}
>
<View style={{ flex: 1, alignSelf: 'center', justifyContent: 'center',}}>
<MySvgContent style={{width: 1600, height: 1800}}/>
</View>
</ReactNativeZoomableView>
My logic here was to equalize the zoomable view's width and height with Content's width and height, which are 1600x1800 in my example. So then, you will have a 1600x1800 sized zoomable container with a 1600x1800 sized content. No space for over-scrolling A possible variable is that I'm using a huge svg image, so let the community know if this solution doesn't work
@thomasttvo this is the code i'm using
`import * as React from 'react';
import {
StyleSheet,
View,
Animated,
Dimensions,
Image,
Text,
} from 'react-native';
// @ts-ignore
import { ReactNativeZoomableView } from '@openspacelabs/react-native-zoomable-view';
import _ from 'lodash';
import { useSelector } from 'react-redux';
import { RootState } from '../../state/ducks';
import ProgressiveImage from '../../common/ui/progressive-image.component';
import { HelpFunctions } from '../../utils/functions';
import {
BASE_URL,
DateFormat,
IMAGE_RATIO,
LayerType,
} from '../../utils/contants';
import { ImageProperties } from '../../models/image';
import { Slider } from '@rneui/base';
import { colors } from '../../assets/style/variables';
import { textGeneralStyle } from '../../assets/style/typography';
import { generalStyles } from '../../assets/style/general-style';
import CircleIconButton from '../../common/ui/circle-icon-button.component';
import ShareService from '../../services/share.serivce';
import { CameraRoll } from '@react-native-camera-roll/camera-roll';
import LottieView from 'lottie-react-native';
import RNFetchBlob from 'rn-fetch-blob';
import { Easing } from 'react-native';
import { Feature, Geometry, propertiesContainsFilter } from '@turf/turf';
import { Actions } from 'react-native-router-flux';
import InfoSheet from '../../common/ui/info-sheet-component';
import { strings } from '../../services/translation/translate.service';
import moment from 'moment';
import { FieldProperties } from '../../models/feild';
import ActionSheet, { ActionSheetRef } from 'react-native-actions-sheet';
import { TouchableOpacity } from 'react-native-gesture-handler';
import { ApiService } from '../../services/api.service';
import CustomIcon from '../../common/ui/icon.component';
interface ImageViewerProps {
image: Feature<Geometry, ImageProperties>;
field: FieldProperties;
navigatePressCallback: (coordinates: any, image: ImageProperties) => void;
data: any;
}
export default function ImageViewer(props: ImageViewerProps) {
const screenWidth = Dimensions.get('screen').width;
const viewBoxWidth = props.image.properties.width / IMAGE_RATIO;
const viewBoxHeight = props.image.properties.height / IMAGE_RATIO;
const user = useSelector((state: RootState) => state.userAccount.user);
let initialZoom = screenWidth / viewBoxWidth;
let maxZoom = 3;
const zoomAnimatedValue = React.useRef(
new Animated.Value(1, { useNativeDriver: false }),
).current;
const actionSheetRef = React.useRef<ActionSheetRef>(null);
const [zoom, setZoom] = React.useState(initialZoom);
const [opacity, setOpacity] = React.useState(1);
const [isdownloading, setIsDownloading] = React.useState(false);
const [progress, setProgress] = React.useState(new Animated.Value(0));
const handleLayout = (zoom?: number) => {
setZoom(zoom);
};
zoomAnimatedValue.addListener(({ value }) => handleLayout(value));
const handleDownload = async () => {
setIsDownloading(true);
RNFetchBlob.config({
fileCache: true,
appendExt: 'png',
})
.fetch('GET', encodeURI(props.image.properties.imageUrl))
.progress((written, total) => {
Animated.timing(progress, {
toValue: written / total,
useNativeDriver: true,
}).start();
})
.then(res => {
setTimeout(() => {
setIsDownloading(false);
setProgress(new Animated.Value(0));
}, 3000);
CameraRoll.saveToCameraRoll(res.data, 'photo')
.then(res => console.log(res))
.catch(err => console.log('###', err));
})
.catch(error => console.log('$$$', error));
};
const getInfo = () => {
return [
{
title: strings('NAME'),
value: HelpFunctions.imageDataConverter(props.image?.properties?.name)
.name,
},
{
title: strings('TIME'),
value: `${moment
.utc(props.image?.properties?.timeTaken)
.format(DateFormat.DD_MM_YY_hhmm)} `,
},
{ title: 'ID', value: props.image?.properties?.id },
{ title: strings('FIELD'), value: props.field.name },
];
};
return (
<View style={styles.container}>
{<View style={styles.exit}>
<CustomIcon
name={'arrow-left'}
size={35}
color={colors.white}
onPress={() => {
Actions.pop();
}}></CustomIcon>
</View>}
<View
style={[
styles.box,
{ height: viewBoxHeight / initialZoom, width: screenWidth },
]}>
<ReactNativeZoomableView
contentWidth={viewBoxWidth}
contentHeight={viewBoxHeight}
panBoundaryPadding={0}
disablePanOnInitialZoom
initialZoom={initialZoom}
maxZoom={maxZoom}
minZoom={initialZoom}
zoomAnimatedValue={zoomAnimatedValue}>
<View
style={[
styles.contents,
{ height: viewBoxHeight, width: viewBoxWidth },
]}>
<ProgressiveImage
mediumSource={HelpFunctions.getImageUrl(
props.image.properties.imageUrl,
'Medium',
)}
source={props.image.properties.imageUrl}
thumbnailSource={HelpFunctions.getImageUrl(
props.image.properties.imageUrl,
'PrePreview',
)}
style={{
width: viewBoxWidth,
height: viewBoxHeight,
resizeMode: 'contain',
}}
/>
<View
style={{
position: 'absolute',
left: 0,
right: 0,
bottom: 0,
top: 0,
}}>
<Image
style={{
width: viewBoxWidth,
height: viewBoxHeight,
resizeMode: 'contain',
opacity: opacity,
}}
source={{ uri: props.image.properties.output.url }}
/>
</View>
</View>
</ReactNativeZoomableView>
</View>
<View style={styles.sliderContainer}>
<Text style={[textGeneralStyle.text_default_small, { paddingRight: 10 }]}>
Opacity %
</Text>
<Slider
value={opacity}
style={{
width: '70%',
}}
trackStyle={{ height: 4 }}
thumbStyle={{
height: 20,
width: 20,
backgroundColor: colors.orange,
}}
maximumTrackTintColor={colors.light_gray_2}
minimumTrackTintColor={colors.orange}
minimumValue={0}
maximumValue={1}
onValueChange={number => setOpacity(number)}
/>
<Text
style={[textGeneralStyle.text_default_gray_small, { paddingLeft: 10 }]}>
100
</Text>
</View>
<Animated.View
style={[
styles.footer,
{
opacity: this.fadeAnim,
},
]}>
<View style={styles.footerLeftIcons}>
<CircleIconButton
onPress={() => actionSheetRef.current?.show()}
name={'information'}
color={colors.black}
size={25}
circleSize={50}
/>
<TouchableOpacity onPress={handleDownload}>
<LottieView
style={{ height: 30, width: 30 }}
progress={progress}
source={require('../../assets/animations/download-anim.json')}
/>
</TouchableOpacity>
</View>
<View style={styles.footerRightIcons}>
<CircleIconButton
size={17}
name={'navigate-fill'}
backgroundColor={colors.blue}
color={colors.white}
onPress={() => {
Actions.navScreen({
image: props.image.properties,
droppedPin: props.image.geometry.coordinates,
field: props.field,
});
}}
/>
{
<CircleIconButton
size={17}
name={'share-fill'}
backgroundColor={colors.green}
color={colors.white}
onPress={async () => {
await ShareService.share({
title: '',
url: `${BASE_URL}share/layers/${props.data.layerType}/${props.data.layerId}/images/${props.image?.properties?.id}`,
});
}}
/>
}
<CircleIconButton
color={colors.white}
backgroundColor={colors.secondary}
size={17}
name={'chat'}
onPress={() => {
ApiService.sendMessage({
text: `${BASE_URL}share/layers/${props.data.layerType}/${props.data.layerId}/images/${props.image?.properties?.id}`,
userId: user.agronomistId,
}).then(response => {
Actions.chatScreen({ conversationId: response.data });
});
}}
/>
</View>
</Animated.View>
<ActionSheet
useBottomSafeAreaPadding
gestureEnabled={true}
ref={actionSheetRef}>
<InfoSheet title={strings('IMAGE_INFORMATION')} info={getInfo()} />
</ActionSheet>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
padding: 0,
backgroundColor: 'black',
},
box: {
flexShrink: 1,
},
contents: {
justifyContent: 'center',
alignItems: 'center',
},
img: {
width: '100%',
height: '100%',
resizeMode: 'contain',
},
sliderContainer: {
flexDirection: 'row',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-around',
backgroundColor: 'white',
borderRadius: 30,
marginVertical: 20,
position: 'absolute',
bottom: 60,
paddingHorizontal: 20,
paddingVertical: 5,
},
footer: {
...generalStyles.rowContainer,
backgroundColor: colors.white,
width: '100%',
paddingHorizontal: 20,
paddingVertical: 0,
justifyContent: 'space-between',
},
footerLeftIcons: {
...generalStyles.rowContainer,
justifyContent: 'space-between',
alignItems: 'center',
},
footerRightIcons: {
...generalStyles.rowContainer,
alignItems: 'center',
width: '35%',
justifyContent: 'space-between',
},
exit: {
alignItems: 'center',
justifyContent: 'center',
marginLeft: 10,
position: 'absolute',
top: 30,
left: 10,
zIndex: 9999,
},
});
import React, { Component } from 'react';
import { View, StyleSheet, Animated, ImageStyle } from 'react-native';
import FastImage from 'react-native-fast-image'
export interface ProgressiveImageProps {
thumbnailSource: string;
mediumSource: string;
source: string;
style: ImageStyle;
findingScreen?: () => void;
}
class ProgressiveImage extends Component<ProgressiveImageProps, {}> {
thumbnailAnimated = new Animated.Value(0);
imageAnimated = new Animated.Value(0);
render() {
const { thumbnailSource, mediumSource, source, style, ...props } = this.props;
return (
<View style={styles.container} onTouchStart={this.props.findingScreen}>
<FastImage
{...props}
source={{ uri: thumbnailSource }}
style={[style]}
/>
<FastImage
{...props}
source={{ uri: mediumSource }}
style={[styles.imageOverlay, style]}
/>
<FastImage
{...props}
style={[styles.imageOverlay, style]}
source={{ uri: source }}
/>
</View>
);
}
}
const styles = StyleSheet.create({
imageOverlay: {
position: 'absolute',
left: 0,
right: 0,
bottom: 0,
top: 0,
},
container: {
//backgroundColor: 'black',
borderRadius: 7,
},
});
export default ProgressiveImage;
`
@andrewhavens were you able to find any workaround to prevent pan while zoomed in ? I am struggling with the same issue you mentioned.
Ive noticed that occasionally, zoomable view loses the plot. Panning will be confined to the edges of the contentWidth/Height most of the time, but then something will happen, and panning beyond the contentWidht/Height will become infinite. Accompanying this behaviour will be if you try to zoom, it will always zoom as if you are zooming from the bottom right of the content.
Once in this broken mode, it persists across all instances of using zoomable view, as if some static/global data has been corrupted until the app is restarted.
What I noticed is that when I had panEnabled
set to false
, it would allow me to zoom outside of the bounds of the canvas. For our use-case, we needed panning disabled usually because we are rendering an SVG with pixels that can be painted by swiping with one finger. Our solution was to enable panning at the beginning of a 2-finger swipe and disable it after it is complete:
const [panEnabled, setPanEnabled] = useState(false);
return (
<ReactNativeZoomableView
{...otherProps}
panEnabled={panEnabled}
onZoomBefore={(event) => {
// Only enable panning if there are 2 fingers swiping
if (event.nativeEvent.touches.length === 2) {
setPanEnabled(true);
}
}}
onZoomEnd={() => {
setPanEnabled(false);
}}
>
{props.children}
</ReactNativeZoomableView>
)
For us, this snaps the content back into place as expected after a pan ends.