title: How can I pan across an image once zoomed in. (Expo/ReactNative)
body: <p>In my Expo app, on a particular screen you can take an image or select from camera roll using expo APIs and then preview. In the preview, I have an overlay where the user can touch a point on the image a dot/marker is displayed over the corresponding X, Y-axis.</p>
  
  <p>I want to be able to zoom into the image and place the marker with more accuracy. </p>
  
  <p>So far I have tried:</p>
  
  <ol>
  <li><p>Scrollview - this kinda works, but after saving the image and viewing it again the marker is no longer in the correct position. </p></li>
  <li><p><a href="https://kmagiera.github.io/react-native-gesture-handler/docs/handler-pan.html" rel="nofollow noreferrer">https://kmagiera.github.io/react-native-gesture-handler/docs/handler-pan.html</a>: This zooms much better and maintains accuracy but once zoomed, I am unable to pan around the zoomed image.</p></li>
  </ol>
  
  <p>3.<a href="https://github.com/ascoders/react-native-image-zoom" rel="nofollow noreferrer">https://github.com/ascoders/react-native-image-zoom</a>: This appears very glitchy when you try to pan and also lose the marker position. </p>
  
  <p>Ideally, I am looking at working with option 2 but making changes so I can pan the image. </p>
  
  <p>Does anyone have a similar working example or suggestions on how I can move forward?</p>
  
  <pre><code>import React from "react";
  import {
    Container,
    Text,
    Button,
    View,
    Icon,
    StyleProvider,
  } from "native-base";
  import {
    ImageBackground,
    Image,
    TouchableOpacity,
    TextInput,
    Animated,
    StyleSheet,
    Dimensions,
  } from "react-native";
  
  import {
    PinchGestureHandler,
    PanGestureHandler,
    ScrollView,
  } from "react-native-gesture-handler";
  
  import styles from "./ShotLoggerStyles";
  import texts from "../../Style/texts";
  
  import getTheme from "../../native-base-theme/components";
  import marksmenOne from "../../native-base-theme/variables/marksmenOne";
  
  import HeaderComponent from "../../Components/Header/Header";
  import FooterComponent from "../../Components/Footer/FooterComponent";
  import ChangeWeapon from "../../Components/ChangeWeapon";
  import Loader from "../../Components/Loader/Loader";
  
  const mainBG = require("../../assets/Backgrounds/metal.jpg");
  const contentBG = require("../../assets/Backgrounds/training.jpg");
  
  const windowWidth = Dimensions.get("window").width;
  
  const ShotLoggerView = props =&gt; {
    const {
      leftPress,
      log,
      addShot,
      removeShot,
      updateShots,
      updateScore,
      changeWeaponHandler,
      _takeImageHandler,
      saveLog,
      weaponModalVisible,
      toggleWeaponModal,
      loading,
      displayHeight,
      pinchRef,
      rotationRef,
      _onPinchGestureEvent,
      _onPinchHandlerStateChange,
      _scale,
      panRef,
      _onTiltGestureEvent,
      _onTiltGestureStateChange,
      minDist,
      minPointers,
      maxPointers,
      avgTouches,
      _tiltStr,
    } = props;
  
    return (
      &lt;Container&gt;
        &lt;StyleProvider style={getTheme(marksmenOne)}&gt;
          &lt;ImageBackground source={mainBG} style={styles.mainBG}&gt;
            &lt;Loader visible={loading} /&gt;
            &lt;HeaderComponent
              headerTitle="Logging Shots"
              leftPress={leftPress}
              leftAction="close"
            /&gt;
            &lt;View style={styles.content}&gt;
              {!log.image ? (
                &lt;&gt;
                  &lt;ChangeWeapon
                    weaponModalVisible={weaponModalVisible}
                    onChangeWeapon={changeWeaponHandler}
                    toggleWeaponModal={toggleWeaponModal}
                  /&gt;
                  &lt;ImageBackground style={styles.noImage} source={contentBG}&gt;
                    &lt;Text style={texts.headerTitle}&gt;
                      Take a picture of your target
                    &lt;/Text&gt;
  
                    &lt;Button
                      icon
                      style={styles.buttonPrimary}
                      onPress={() =&gt; _takeImageHandler()}
                    &gt;
                      &lt;Icon style={styles.buttonIcon} name="camera" /&gt;
                    &lt;/Button&gt;
                  &lt;/ImageBackground&gt;
                &lt;/&gt;
              ) : (
                &lt;&gt;
                  &lt;View style={styles.details}&gt;
                    &lt;View style={styles.detailsItem}&gt;
                      &lt;Text style={texts.bodyWhite}&gt;Shots&lt;/Text&gt;
                      &lt;TextInput
                        style={styles.input}
                        defaultValue={log.shots.toString()}
                        editable={false}
                      /&gt;
                    &lt;/View&gt;
                    &lt;View style={styles.detailsItem}&gt;
                      &lt;Text style={texts.bodyWhite}&gt;Score&lt;/Text&gt;
                      &lt;TextInput
                        style={styles.input}
                        onChangeText={value =&gt; updateScore(value)}
                        clearTextOnFocus
                        keyboardType="number-pad"
                        returnKeyType="done"
                      /&gt;
                    &lt;/View&gt;
                  &lt;/View&gt;
                  &lt;ScrollView&gt;
                    &lt;PanGestureHandler
                      ref={panRef}
                      onGestureEvent={_onTiltGestureEvent}
                      onHandlerStateChange={_onTiltGestureStateChange}
                      //   onGestureEvent={this._onDragGestureEvent}
                      // onHandlerStateChange={this._onDragHandlerStateChange}
                      minDist={1}
                      minPointers={2}
                      maxPointers={2}
                      avgTouches
                    &gt;
                      &lt;Animated.View
                        style={[
                          styles.style2,
                          {
                            transform: [
                              { perspective: 200 },
                              { scale: _scale },
                              { rotateX: _tiltStr },
                            ],
                          },
                        ]}
                        collapsable={false}
                      &gt;
                        &lt;PinchGestureHandler
                          ref={pinchRef}
                          simultaneousHandlers={rotationRef}
                          onGestureEvent={_onPinchGestureEvent}
                          onHandlerStateChange={_onPinchHandlerStateChange}
                        &gt;
                          &lt;Animated.View
                            style={[
                              styles.style2,
                              {
                                transform: [
                                  { perspective: 200 },
                                  { scale: _scale },
                                  { rotateX: _tiltStr },
                                ],
                              },
                            ]}
                            collapsable={false}
                          &gt;
                            &lt;TouchableOpacity
                              activeOpacity={1}
                              style={styles.pointer}
                              onPress={evt =&gt; addShot(evt.nativeEvent)}
                            &gt;
                              &lt;View
                                style={[
                                  styles.instructions,
                                  log.heatmap.length &gt; 0
                                    ? { zIndex: -5 }
                                    : { zIndex: 20 },
                                ]}
                              &gt;
                                &lt;Text style={texts.headerBody}&gt;
                                  Tap the picture to add &amp;amp; remove shots.
                                &lt;/Text&gt;
                              &lt;/View&gt;
  
                              &lt;Animated.Image
                                style={{
                                  width: "100%",
                                  minHeight: props.displayHeight,
                                  height: "auto",
                                }}
                                source={{
                                  uri: `data:image/jpg;base64,${log.image}`,
                                }}
                              /&gt;
                              {log.heatmap.length &gt; 0 &amp;&amp;
                                log.heatmap.map((shot, index) =&gt; {
                                  return (
                                    &lt;TouchableOpacity
                                      key={index}
                                      onPress={() =&gt; removeShot(index)}
                                      style={[
                                        styles.shot,
                                        { left: shot.x - 10, top: shot.y - 10 },
                                      ]}
                                    /&gt;
                                  );
                                })}
                            &lt;/TouchableOpacity&gt;
                          &lt;/Animated.View&gt;
                        &lt;/PinchGestureHandler&gt;
                      &lt;/Animated.View&gt;
                    &lt;/PanGestureHandler&gt;
                  &lt;/ScrollView&gt;
                &lt;/&gt;
              )}
            &lt;/View&gt;
            &lt;FooterComponent
              rightAction={saveLog}
              rightActionText="Save shots"
              rightActionDisable={!log.image}
            /&gt;
          &lt;/ImageBackground&gt;
        &lt;/StyleProvider&gt;
      &lt;/Container&gt;
    );
  };
  
  export default ShotLoggerView;
  
  const styles2 = StyleSheet.create({
    container: {
      ...StyleSheet.absoluteFillObject,
      backgroundColor: "black",
      overflow: "hidden",
      alignItems: "center",
      flex: 1,
      justifyContent: "center",
    },
    pinchableImage: {
      width: 250,
      height: 250,
    },
    wrapper: {
      flex: 1,
    },
  });
  </code></pre>
  
  <p>and </p>
  
  <pre><code>/* eslint-disable no-underscore-dangle */
  import React, { Component } from "react";
  import { Alert, Dimensions, Animated, PanResponder } from "react-native";
  import * as ImagePicker from "expo-image-picker";
  import Constants from "expo-constants";
  
  import { State } from "react-native-gesture-handler";
  
  import * as Permissions from "expo-permissions";
  import * as ImageManipulator from "expo-image-manipulator";
  import * as Segment from "expo-analytics-segment";
  import ShotLoggerView from "../ShotLoggerView";
  import ENV from "../../../env";
  
  import { USE_NATIVE_DRIVER } from "../../../config";
  
  const windowWidth = Dimensions.get("window").width;
  const circleRadius = 30;
  
  Segment.initialize({
    androidWriteKey: ENV.SEGMENT_ANDROID_KEY,
    iosWriteKey: ENV.SEGMENT_IOS_KEY,
  });
  
  class ShotLoggerContainer extends Component {
    _panResponder = {};
  
    _previousLeft = 0;
  
    _previousTop = 0;
  
    _circleStyles = {};
  
    panRef = React.createRef();
  
    pinchRef = React.createRef();
  
    tapRef = React.createRef();
  
    constructor(props) {
      super(props);
  
      /* Panning */
      this._touchX = new Animated.Value(windowWidth / 2 - circleRadius);
      this._translateX = Animated.add(
        this._touchX,
        new Animated.Value(-circleRadius)
      );
      this._onPanGestureEvent = Animated.event(
        [
          {
            nativeEvent: {
              x: this._touchX,
            },
          },
        ],
        { useNativeDriver: USE_NATIVE_DRIVER }
      );
  
      /* Pinching */
      this._baseScale = new Animated.Value(1);
      this._pinchScale = new Animated.Value(1);
      this._scale = Animated.multiply(this._baseScale, this._pinchScale);
      this._lastScale = 1;
      this._onPinchGestureEvent = Animated.event(
        [{ nativeEvent: { scale: this._pinchScale } }],
        { useNativeDriver: USE_NATIVE_DRIVER }
      );
  
      /* Tilt */
      this._tilt = new Animated.Value(0);
      this._tiltStr = this._tilt.interpolate({
        inputRange: [-501, -500, 0, 1],
        outputRange: ["1rad", "1rad", "0rad", "0rad"],
      });
      this._lastTilt = 0;
      this._onTiltGestureEvent = Animated.event(
        [{ nativeEvent: { translationY: this._tilt } }],
        { useNativeDriver: USE_NATIVE_DRIVER }
      );
  
      this.state = {
        weaponModalVisible: false,
        log: {
          heatmap: [],
          shots: 0,
          score: 0,
          weapon_id: 0,
          diary_id: 0,
          image: undefined,
          imageSize: {
            width: 768,
            height: 1024,
          },
        },
        loading: false,
        displayHeight: 1024,
      };
    }
  
    componentWillMount() {
      this._panResponder = PanResponder.create({
        onStartShouldSetPanResponder: this._handleStartShouldSetPanResponder,
        onMoveShouldSetPanResponder: this._handleMoveShouldSetPanResponder,
        onPanResponderGrant: this._handlePanResponderGrant,
        onPanResponderMove: this._handlePanResponderMove,
        onPanResponderRelease: this._handlePanResponderEnd,
        onPanResponderTerminate: this._handlePanResponderEnd,
      });
      this._previousLeft = 20;
      this._previousTop = 84;
      this._circleStyles = {
        style: {
          left: this._previousLeft,
          top: this._previousTop,
          backgroundColor: "green",
        },
      };
    }
  
    componentDidMount() {
      this.getPermissionAsync();
      this.state.log.weapon_id = this.props.shootingSession.currentWeapon.id;
      this.state.log.diary_id = this.props.shootingSession.id;
      this._updateNativeStyles();
    }
  
    getPermissionAsync = async () =&gt; {
      if (Constants.platform.ios) {
        const { status } = await Permissions.askAsync(
          Permissions.CAMERA_ROLL,
          Permissions.CAMERA
        );
        if (status !== "granted") {
          Alert.alert(
            "Sorry, we need camera and camera roll permissions to make this work!"
          );
        }
      }
    };
  
    _onTapHandlerStateChange = ({ nativeEvent }) =&gt; {
      if (nativeEvent.oldState === State.ACTIVE) {
        // Once tap happened we set the position of the circle under the tapped spot
        this._touchX.setValue(nativeEvent.x);
      }
    };
  
    _onPinchHandlerStateChange = event =&gt; {
      if (event.nativeEvent.oldState === State.ACTIVE) {
        this._lastScale *= event.nativeEvent.scale;
        this._baseScale.setValue(this._lastScale);
        this._pinchScale.setValue(1);
      }
    };
  
    _onTiltGestureStateChange = event =&gt; {
      if (event.nativeEvent.oldState === State.ACTIVE) {
        this._lastTilt += event.nativeEvent.translationY;
        this._tilt.setOffset(this._lastTilt);
        this._tilt.setValue(0);
      }
    };
  
    changeWeaponHandler = () =&gt; {
      this.setState({
        weaponModalVisible: true,
      });
    };
  
    toggleWeaponModal = () =&gt; {
      this.setState({
        weaponModalVisible: false,
      });
    };
  
    _takeImageHandler = async () =&gt; {
      this.setState({
        loading: true,
      });
      const result = await ImagePicker.launchCameraAsync({
        mediaTypes: ImagePicker.MediaTypeOptions.Images,
        allowsEditing: false,
        aspect: [4, 3],
        quality: 1,
        exif: true,
      });
      if (!result.cancelled) {
        const manipResult = await ImageManipulator.manipulateAsync(
          result.uri,
          [{ resize: { height: 1024 } }],
          {
            compress: 0.9,
            format: ImageManipulator.SaveFormat.JPEG,
            base64: true,
          }
        );
        this.state.log.image = manipResult.base64;
        // Calculate resized width
        this.state.log.imageSize.width = (1024 * result.width) / result.height;
        const deviceWidth = Dimensions.get("window").width;
        const displayHeight = (deviceWidth * result.height) / result.width;
        this.setState({
          loading: false,
          displayHeight,
        });
        this.forceUpdate();
      }
    };
  
    saveLog = () =&gt; {
      Alert.alert("Save Log", "Are you sure you want to save this shot log?", [
        {
          text: "Cancel",
          style: "cancel",
        },
        {
          text: "CONFIRM",
          onPress: () =&gt; {
            this.realSave();
          },
        },
      ]);
    };
  
    realSave = async () =&gt; {
      this.setState({
        loading: true,
      });
      this.props.shootingSession.logs.push(this.state.log);
      this.props.shootingSession.score =
        Number(this.props.shootingSession.score) + Number(this.state.log.score);
      this.props.shootingSession.shots =
        this.props.shootingSession.shots + this.state.log.shots;
      this.props
        .addDiaryLog(this.props.user.auth, this.state.log)
        .then(result =&gt; {
          Segment.trackWithProperties("Shots logging saved", {
            version: "Beta",
          });
          this.goBack();
        })
        .catch(error =&gt; {
          this.setState({
            loading: false,
          });
          console.log("error", error);
        });
    };
  
    addShot = event =&gt; {
      const logA = JSON.parse(JSON.stringify(this.state.log));
      logA.heatmap.push({ x: event.locationX, y: event.locationY, value: 85 });
      logA.shots++;
      this.setState({ log: logA });
    };
  
    removeShot = index =&gt; {
      const logR = JSON.parse(JSON.stringify(this.state.log));
      logR.heatmap.splice(index, 1);
      logR.shots--;
      this.setState({ log: logR });
    };
  
    updateShots = value =&gt; {
      this.state.log.shots = value;
    };
  
    updateScore = value =&gt; {
      this.state.log.score = value;
    };
  
    goBack = () =&gt; {
      this.props.navigation.goBack();
    };
  
    leftPress = () =&gt; {
      Alert.alert("Abort drill?", "Image and data will be lost.", [
        {
          text: "Cancel",
          style: "cancel",
        },
        {
          text: "Abort",
          onPress: () =&gt; {
            Segment.trackWithProperties("Shots logging cancel", {
              version: "Beta",
            });
            this.goBack();
          },
        },
      ]);
    };
  
    _highlight = () =&gt; {
      this._circleStyles.style.backgroundColor = "blue";
      this._updateNativeStyles();
    };
  
    _unHighlight = () =&gt; {
      this._circleStyles.style.backgroundColor = "green";
      this._updateNativeStyles();
    };
  
    _updateNativeStyles = () =&gt; {
      this.circle &amp;&amp; this.circle.setNativeProps(this._circleStyles);
    };
  
    _handleStartShouldSetPanResponder = (e, gestureState) =&gt; {
      // Should we become active when the user presses down on the circle?
      return true;
    };
  
    _handleMoveShouldSetPanResponder = (e, gestureState) =&gt; {
      // Should we become active when the user moves a touch over the circle?
      return true;
    };
  
    _handlePanResponderGrant = (e, gestureState) =&gt; {
      this._highlight();
    };
  
    _handlePanResponderMove = (e, gestureState) =&gt; {
      this._circleStyles.style.left =
        this._previousLeft + gestureState.dx * (I18nManager.isRTL ? -1 : 1);
      this._circleStyles.style.top = this._previousTop + gestureState.dy;
      this._updateNativeStyles();
    };
  
    _handlePanResponderEnd = (e, gestureState) =&gt; {
      this._unHighlight();
      this._previousLeft += gestureState.dx * (I18nManager.isRTL ? -1 : 1);
      this._previousTop += gestureState.dy;
    };
  
    render() {
      const { weaponModalVisible } = this.state;
      Segment.screenWithProperties("Shotlogger", {
        version: "Beta",
      });
      return (
        &lt;ShotLoggerView
          setState={data =&gt; this.setState(data)}
          goBack={this.goBack}
          log={this.state.log}
          leftPress={this.leftPress}
          addShot={event =&gt; this.addShot(event)}
          removeShot={event =&gt; this.removeShot(event)}
          updateShots={value =&gt; this.updateShots(value)}
          updateScore={value =&gt; this.updateScore(value)}
          _takeImageHandler={this._takeImageHandler}
          saveLog={() =&gt; this.saveLog()}
          changeWeaponHandler={this.changeWeaponHandler}
          weaponModalVisible={weaponModalVisible}
          toggleWeaponModal={this.toggleWeaponModal}
          loading={this.state.loading}
          displayHeight={this.state.displayHeight}
          _onPinchHandlerStateChange={event =&gt;
            this._onPinchHandlerStateChange(event)
          }
          pinchRef={this.pinchRef}
          rotationRef={this.rotationRef}
          _onPinchGestureEvent={this._onPinchGestureEvent}
          _scale={this._scale}
          _tiltStr={this._tiltStr}
          panRef={this.panRef}
          _onTiltGestureEvent={this._onTiltGestureEvent}
          _onTiltGestureStateChange={this._onTiltGestureStateChange}
          minDist={this.minDist}
          minPointers={this.minPointers}
          maxPointers={this.maxPointers}
          avgTouches={this.avgTouches}
          tapRef={this.tapRef}
          panRef={this.panRef}
        /&gt;
      );
    }
  }
  export default ShotLoggerContainer;
  </code></pre>
  
  <pre><code>  Expo CLI 3.9.1 environment info:
      System:
        OS: macOS 10.15.1
        Shell: 5.7.1 - /bin/zsh
      Binaries:
        Node: 12.8.0 - ~/.nvm/versions/node/v12.8.0/bin/node
        Yarn: 1.19.1 - /usr/local/bin/yarn
        npm: 6.11.3 - ~/.nvm/versions/node/v12.8.0/bin/npm
        Watchman: 4.9.0 - /usr/local/bin/watchman
      IDEs:
        Android Studio: 3.5 AI-191.8026.42.35.5791312
        Xcode: 11.2/11B41 - /usr/bin/xcodebuild
      npmPackages:
        @types/react-native: ^0.60.22 =&gt; 0.60.22 
        expo: ^35.0.1 =&gt; 35.0.1 
        react: 16.8.3 =&gt; 16.8.3 
        react-native: https://github.com/expo/react-native/archive/sdk-35.0.0.tar.gz =&gt; 0.59.8 
        react-navigation: ^3.13.0 =&gt; 3.13.0 
      npmGlobalPackages:
        expo-cli: 3.9.1
  </code></pre>