openspacelabs / react-native-zoomable-view

A view component for react-native with pinch to zoom, tap to move and double tap to zoom capability.
MIT License
191 stars 58 forks source link

Allows panning beyond edges of container #25

Open andrewhavens opened 2 years ago

andrewhavens commented 2 years ago

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).

zoom-with-pan

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>
    )
  }
}
professorkolik commented 2 years ago

@andrewhavens I think that you set incorrect values for contentWidth and contentHeight, try to set your original image dimensions

andrewhavens commented 2 years ago

@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.

elliottkember commented 2 years ago

@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.

andrewhavens commented 2 years ago

@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.

bjarke-uptime commented 2 years ago

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.

thomasttvo commented 2 years ago

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

alexhochreiter commented 2 years ago

@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.

thomasttvo commented 2 years ago

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.

Adrian-Jablonski commented 1 year ago

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

manarfalah commented 1 year ago

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.

manarfalah commented 1 year ago

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.

@bjarke-uptime

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

thomasttvo commented 1 year ago

@Adrian-Jablonski @manarfalah do you guys have a repo where you can reproduce the issue?

manarfalah commented 1 year ago

@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.

manarfalah commented 1 year ago

@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.

jeffinhk commented 1 year ago

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

manarfalah commented 11 months ago

@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;
`
sidpatel93 commented 9 months ago

@andrewhavens were you able to find any workaround to prevent pan while zoomed in ? I am struggling with the same issue you mentioned.

stefoid commented 4 months ago

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.