Leonti
Leonti

Reputation: 10960

Scrollable image with pinch-to-zoom in react-native

I'm trying to display an image in my React Native app (Android) and I want to give users an ability to zoom that image in and out. This also requires the image to be scrollable once zoomed in.

How would I go about it?

I tried to use ScrollView to display a bigger image inside, but on Android it can either scroll vertically or horizontally, not both ways. Even if that worked there is a problem of making pinch-to-zoom work.

As far as I understand I need to use PanResponder on a custom view to zoom an image and position it accordingly. Is there an easier way?

Upvotes: 29

Views: 51857

Answers (8)

Animesh Singh
Animesh Singh

Reputation: 9292

I was facing similar conditions when working on an App, with requirements that needed the maps (in image) with pins, and the pins were needed to be draggable flag based. Map was required to be zoomable. Was using ScrollView with GestureDetector and PanResponder and was working smoothly in iOS, until I ran the android version, where it sucked.

Researched a bit and gave npm i @openspacelabs/react-native-zoomable-view a try.

Here's what I did.

      <View
        style={{
          flex: 1,
          minHeight: mapContainerHeight,
        }}
      >
        <ReactNativeZoomableView
          maxZoom={9}
          minZoom={0.5}
          zoomStep={0}
          initialZoom={1}
          bindToBorders={true}
        >
          <Image
            source={{ uri: mapImage }}
            style={{
              width: '100%',
              height: '100%',
              resizeMode: 'contain',
              marginTop: 0,
            }}
          />
          {pins.map((pin, index) => (
            <Pin
              key={index}
              pinImage={pinImage}
              pin={pin}
              index={index}
              isDraggable={shouldBeDraggable}
              onPinLocationUpdate={handlePinLocationUpdate}
              onPress={handlePinPress}
            />
          ))}
        </ReactNativeZoomableView>
      </View>

And my Pin.jsx looked like this:

const Pin = ({
  pin,
  isDraggable,
  index,
  pinImage,
  // scale,
  onPinLocationUpdate,
  onPress,
}) => {
  const pinSize = 15;

  const panResponder = useRef(
    PanResponder.create({
      onStartShouldSetPanResponder: () => isDraggable,
      onMoveShouldSetPanResponder: () => isDraggable,
      // onPanResponderGrant: (_, gestureState) => {
      //   // Find the pin at the current gesture location

      //   scale.value = 1; // Reset scale to 1 when pin is being dragged
      // },
      onPanResponderMove: (_, gestureState) => {
        // You can add some logic here to restrict movement if needed

        if (!pin) return;

        const newX = pin?.left + gestureState.dx;
        const newY = pin?.top + gestureState.dy;

        // Update the position of the dragged pin based on the gesture
        // gestureState.moveX and gestureState.moveY give the current coordinates
        // Adjust the position accordingly based on your requirements
        console.log(
          "Dragging pin:",
          pin?.name,
          newX,
          newY,
          // gestureState.moveX,
          // gestureState.moveY,
        );

        onPinLocationUpdate?.({ ...pin, top: newY, left: newX });
      },
      onPanResponderRelease: (_, gestureState) => {
        // This will be called when the drag is released
        // You can get the final position from gestureState.moveX and gestureState.moveY
        // Adjust the position accordingly based on your requirements
        const { moveX, moveY, dx, dy } = gestureState;
        console.log("Pin released at:", dx, dy);
        // does nothing just logs at the moment as onPanResponderMove handles the necessity

      },
    }),
  ).current;

  if (!pin) return;

  const styles = StyleSheet.create({
    pin: {
      opacity: 40,
      position: "absolute",
      left: `${(pin.left / 1000) * 100 - 2}%`, // as per your needs
      top: `${(pin.top / 1000) * 100 - 7}%`, // as per you needs
      width: pinSize,
      height: pinSize,
    },
  });

  return (
    <View key={index} style={styles.pin}>
      <Pressable
        onLongPress={() => onPress?.(pin)}
        onPress={() => onPress?.(pin)}
        hitSlop={5}>
        <View {...panResponder.panHandlers}>
          <SvgUri width="100%" height="100%" uri={pinImage} />
        </View>
      </Pressable>
    </View>
  );
};

export default Pin;

Hope, this give pointers to someone with similar requirements.

Upvotes: 0

Ajmal Hasan
Ajmal Hasan

Reputation: 1569

Features like zoom, pan, tap/swipe to switch image using react-native-gesture-handler,react-native-reanimated. Perfectly and smoothly running on android/ios.

USAGE

      <ImageZoomPan
        uri={'https://picsum.photos/200/300'}
        activityIndicatorProps={{
          color: COLOR_SECONDARY,
        }}
        onInteractionStart={onInteractionStart}
        onInteractionEnd={onInteractionEnd}
        onLongPressActiveInteration={onPressIn}
        onLongPressEndInteration={onPressOut}
        onSwipeTapForNext={onSwipeTapForNext}
        onSwipeTapForPrev={onSwipeTapForPrev}
        minScale={0.8}
        onLoadEnd={start}
        resizeMode={isFullScreen ? 'cover' : 'contain'} //'stretch'
      />

Image Zoom component

import React, {useRef, useState} from 'react';
import {ActivityIndicator,Dimensions, Image} from 'react-native';
import {
  LongPressGestureHandler,
  PanGestureHandler,
  PinchGestureHandler,
  State,
  TapGestureHandler,
} from 'react-native-gesture-handler';
import Animated, {
  useAnimatedGestureHandler,
  useAnimatedStyle,
  useSharedValue,
  withTiming,
} from 'react-native-reanimated';
import styles from './styles';

const clamp = (value, min, max) => {
  'worklet';

  return Math.min(Math.max(min, value), max);
};

const noop = () => {};

const getDeviceWidth = () => {
  return Dimensions.get('window').width;
};


const AnimatedImage = Animated.createAnimatedComponent(Image);

export default function ImageZoom({
  uri = '',
  minScale = 1,
  maxScale = 5,
  minPanPointers = 2,
  maxPanPointers = 2,
  isPanEnabled = true,
  isPinchEnabled = true,
  onLoadEnd = noop,
  onInteractionStart = noop,
  onInteractionEnd = noop,
  onPinchStart = noop,
  onPinchEnd = noop,
  onPanStart = noop,
  onPanEnd = noop,
  onLongPressActiveInteration = noop,
  onLongPressEndInteration = noop,
  onSwipeTapForNext = noop,
  onSwipeTapForPrev = noop,
  style = {},
  containerStyle = {},
  imageContainerStyle = {},
  activityIndicatorProps = {},
  renderLoader,
  resizeMode = 'cover',
  ...props
}) {
  const panRef = useRef();
  const pinchRef = useRef();

  const isInteracting = useRef(false);
  const isPanning = useRef(false);
  const isPinching = useRef(false);
  const doubleTapRef = useRef(null);

  const [isLoading, setIsLoading] = useState(true);
  const [state, setState] = useState({
    canInteract: false,
    centerX: 0,
    centerY: 0,
  });

  const {canInteract, centerX, centerY} = state;

  const scale = useSharedValue(1);
  const initialFocalX = useSharedValue(0);
  const initialFocalY = useSharedValue(0);
  const focalX = useSharedValue(0);
  const focalY = useSharedValue(0);
  const translateX = useSharedValue(0);
  const translateY = useSharedValue(0);

  const onInteractionStarted = () => {
    if (!isInteracting.current) {
      isInteracting.current = true;
      onInteractionStart();
    }
  };

  const onInteractionEnded = () => {
    if (isInteracting.current && !isPinching.current && !isPanning.current) {
      isInteracting.current = false;
      onInteractionEnd();
    }
  };

  const onPinchStarted = () => {
    onInteractionStarted();
    isPinching.current = true;
    onPinchStart();
  };

  const onPinchEnded = () => {
    isPinching.current = false;
    onPinchEnd();
    onInteractionEnded();
  };

  const onPanStarted = () => {
    onInteractionStarted();
    isPanning.current = true;
    onPanStart();
  };

  const onPanEnded = () => {
    isPanning.current = false;
    onPanEnd();
    onInteractionEnded();
  };

  const panHandler = useAnimatedGestureHandler({
    onActive: event => {
      translateX.value = event.translationX;
      translateY.value = event.translationY;
    },
    onFinish: () => {
      translateX.value = withTiming(0);
      translateY.value = withTiming(0);
    },
  });

  const pinchHandler = useAnimatedGestureHandler({
    onStart: event => {
      initialFocalX.value = event.focalX;
      initialFocalY.value = event.focalY;
    },
    onActive: event => {
      // onStart: focalX & focalY result both to 0 on Android
      if (initialFocalX.value === 0 && initialFocalY.value === 0) {
        initialFocalX.value = event.focalX;
        initialFocalY.value = event.focalY;
      }
      scale.value = clamp(event.scale, minScale, maxScale);
      focalX.value = (centerX - initialFocalX.value) * (scale.value - 1);
      focalY.value = (centerY - initialFocalY.value) * (scale.value - 1);
    },
    onFinish: () => {
      scale.value = withTiming(1);
      focalX.value = withTiming(0);
      focalY.value = withTiming(0);
      initialFocalX.value = 0;
      initialFocalY.value = 0;
    },
  });

  const animatedStyle = useAnimatedStyle(() => ({
    transform: [
      {translateX: translateX.value},
      {translateY: translateY.value},
      {translateX: focalX.value},
      {translateY: focalY.value},
      {scale: scale.value},
    ],
  }));

  const onLayout = ({
    nativeEvent: {
      layout: {x, y, width, height},
    },
  }) => {
    setState(current => ({
      ...current,
      canInteract: true,
      centerX: x + width / 2,
      centerY: y + height / 2,
    }));
  };

  const onImageLoadEnd = () => {
    onLoadEnd();
    setIsLoading(false);
  };

  const onLongPress = event => {
    if (event.nativeEvent.state === State.ACTIVE) {
      onLongPressActiveInteration();
    }
    if (
      event.nativeEvent.state === State.END ||
      event.nativeEvent.state === State.CANCELLED
    ) {
      onLongPressEndInteration();
    }
  };

  const onSingleTapEvent = event => {
    let e = event.nativeEvent;
    if (e.state === State.ACTIVE) {
      if (e.x < getDeviceWidth() / 2) {
        onSwipeTapForPrev();
      } else {
        onSwipeTapForNext();
      }
    }
  };

  return (
    <PinchGestureHandler
      ref={pinchRef}
      simultaneousHandlers={[panRef]}
      onGestureEvent={pinchHandler}
      onActivated={onPinchStarted}
      onCancelled={onPinchEnded}
      onEnded={onPinchEnded}
      onFailed={onPinchEnded}
      enabled={isPinchEnabled && canInteract}>
      <Animated.View style={[styles.container, containerStyle]}>
        <PanGestureHandler
          ref={panRef}
          simultaneousHandlers={[pinchRef]}
          onGestureEvent={panHandler}
          onActivated={onPanStarted}
          onCancelled={onPanEnded}
          onEnded={onPanEnded}
          onFailed={onPanEnded}
          minPointers={minPanPointers}
          maxPointers={maxPanPointers}
          enabled={isPanEnabled && canInteract}>
          <Animated.View
            onLayout={onLayout}
            style={[styles.content, imageContainerStyle]}>
            <TapGestureHandler
              waitFor={doubleTapRef}
              onHandlerStateChange={onSingleTapEvent}>
              <TapGestureHandler
                ref={doubleTapRef}
                onHandlerStateChange={() => null}
                numberOfTaps={2}>
                <LongPressGestureHandler
                  onHandlerStateChange={onLongPress}
                  minDurationMs={800}>
                  <AnimatedImage
                    style={[styles.container, style, animatedStyle]}
                    source={{uri}}
                    resizeMode={resizeMode}
                    onLoadEnd={onImageLoadEnd}
                    {...props}
                  />
                </LongPressGestureHandler>
              </TapGestureHandler>
            </TapGestureHandler>
            {isLoading &&
              (renderLoader ? (
                renderLoader()
              ) : (
                <ActivityIndicator
                  size="large"
                  style={styles.loader}
                  color="dimgrey"
                  {...activityIndicatorProps}
                />
              ))}
          </Animated.View>
        </PanGestureHandler>
      </Animated.View>
    </PinchGestureHandler>
  );
}

Upvotes: 0

Sanket H patel
Sanket H patel

Reputation: 121

You can simply use the react-native-image-zoom-viewer or react-native-image-pan-zoom library for that. Using this libraries you don't have to code manually.

Upvotes: 3

cmcodes
cmcodes

Reputation: 1866

npm i react-native-photo-view-ex
import PhotoView from 'react-native-photo-view-ex';
<PhotoView
    style={{ flex: 1, width: '100%', height: '100%' }}
    source={{ uri: this.state.filePath }} // you can supply any URL as well
    minimumZoomScale={1} // max value can be 1
    maximumZoomScale={2} // max value can be 3
/>

Upvotes: 2

Tushar Pandey
Tushar Pandey

Reputation: 4857

enter image description here enter image description here

In my case I have to add images inside Viewpager with Zoom functionality.

So I have used these two library.

import ViewPager from '@react-native-community/viewpager'
import PhotoView from 'react-native-photo-view-ex';

which you can install from.

npm i @react-native-community/viewpager
npm i react-native-photo-view-ex

So I have used this code.

class ResumeView extends React.Component {

    render() {
        preivewArray = this.props.showPreview.previewArray
        var pageViews = [];
        for (i = 0; i < preivewArray.length; i++) {
            pageViews.push(<View style={style.page}>

                <PhotoView
                    source={{ uri: preivewArray[i].filePath }}
                    minimumZoomScale={1}
                    maximumZoomScale={3}
                    // resizeMode='stretch'
                    style={{ width: a4_width, height: a4_height, alignSelf: 'center' }} />

            </View>);
        }

        return (
            <ViewPager
                onPageScroll={this.pageScroll}
                style={{ width: '100%', height: a4_height }}>
                {pageViews}
            </ViewPager>
        )
    }

    pageScroll = (event) => {
        console.log("onPageScroll")
    }

}

Upvotes: 3

Asmat ullah
Asmat ullah

Reputation: 711

Don't go deep if you are working with react-native because things will go more and more complex as deep you go.

Give it a try...

npm i react-native-image-zoom-viewer --save

or

yarn add react-native-image-zoom-viewer

copy this code and put it in app.js and hit Run button.

import React from 'react';
import {View} from 'react-native';
import ImageViewer from 'react-native-image-zoom-viewer';
const image = [
  {
    url:
      'https://static8.depositphotos.com/1020341/896/i/950/depositphotos_8969502-stock-photo-human-face-with-cracked-texture.jpg',
  },
];

const App = () => {
  return (
    <View style={{flex: 1}}>
      <ImageViewer imageUrls={image} />
    </View>
  );
};
export default App;

Upvotes: 0

Leonti
Leonti

Reputation: 10960

I ended up rolling my own ZoomableImage component. So far it's been working out pretty well, here is the code:

import React, { Component } from "react";
import { View, PanResponder, Image } from "react-native";
import PropTypes from "prop-types";

function calcDistance(x1, y1, x2, y2) {
  const dx = Math.abs(x1 - x2);
  const dy = Math.abs(y1 - y2);
  return Math.sqrt(Math.pow(dx, 2) + Math.pow(dy, 2));
}

function calcCenter(x1, y1, x2, y2) {
  function middle(p1, p2) {
return p1 > p2 ? p1 - (p1 - p2) / 2 : p2 - (p2 - p1) / 2;
  }

  return {
x: middle(x1, x2),
y: middle(y1, y2)
  };
}

function maxOffset(offset, windowDimension, imageDimension) {
  const max = windowDimension - imageDimension;
  if (max >= 0) {
return 0;
  }
  return offset < max ? max : offset;
}

function calcOffsetByZoom(width, height, imageWidth, imageHeight, zoom) {
  const xDiff = imageWidth * zoom - width;
  const yDiff = imageHeight * zoom - height;
  return {
left: -xDiff / 2,
top: -yDiff / 2
  };
}

class ZoomableImage extends Component {
  constructor(props) {
super(props);

this._onLayout = this._onLayout.bind(this);

this.state = {
  zoom: null,
  minZoom: null,
  layoutKnown: false,
  isZooming: false,
  isMoving: false,
  initialDistance: null,
  initialX: null,
  initalY: null,
  offsetTop: 0,
  offsetLeft: 0,
  initialTop: 0,
  initialLeft: 0,
  initialTopWithoutZoom: 0,
  initialLeftWithoutZoom: 0,
  initialZoom: 1,
  top: 0,
  left: 0
};
  }

  processPinch(x1, y1, x2, y2) {
const distance = calcDistance(x1, y1, x2, y2);
const center = calcCenter(x1, y1, x2, y2);

if (!this.state.isZooming) {
  const offsetByZoom = calcOffsetByZoom(
    this.state.width,
    this.state.height,
    this.props.imageWidth,
    this.props.imageHeight,
    this.state.zoom
  );
  this.setState({
    isZooming: true,
    initialDistance: distance,
    initialX: center.x,
    initialY: center.y,
    initialTop: this.state.top,
    initialLeft: this.state.left,
    initialZoom: this.state.zoom,
    initialTopWithoutZoom: this.state.top - offsetByZoom.top,
    initialLeftWithoutZoom: this.state.left - offsetByZoom.left
  });
} else {
  const touchZoom = distance / this.state.initialDistance;
  const zoom =
    touchZoom * this.state.initialZoom > this.state.minZoom
      ? touchZoom * this.state.initialZoom
      : this.state.minZoom;

  const offsetByZoom = calcOffsetByZoom(
    this.state.width,
    this.state.height,
    this.props.imageWidth,
    this.props.imageHeight,
    zoom
  );
  const left =
    this.state.initialLeftWithoutZoom * touchZoom + offsetByZoom.left;
  const top =
    this.state.initialTopWithoutZoom * touchZoom + offsetByZoom.top;

  this.setState({
    zoom,
    left:
      left > 0
        ? 0
        : maxOffset(left, this.state.width, this.props.imageWidth * zoom),
    top:
      top > 0
        ? 0
        : maxOffset(top, this.state.height, this.props.imageHeight * zoom)
  });
}
  }

  processTouch(x, y) {
if (!this.state.isMoving) {
  this.setState({
    isMoving: true,
    initialX: x,
    initialY: y,
    initialTop: this.state.top,
    initialLeft: this.state.left
  });
} else {
  const left = this.state.initialLeft + x - this.state.initialX;
  const top = this.state.initialTop + y - this.state.initialY;

  this.setState({
    left:
      left > 0
        ? 0
        : maxOffset(
            left,
            this.state.width,
            this.props.imageWidth * this.state.zoom
          ),
    top:
      top > 0
        ? 0
        : maxOffset(
            top,
            this.state.height,
            this.props.imageHeight * this.state.zoom
          )
  });
}
  }

  _onLayout(event) {
const layout = event.nativeEvent.layout;

if (
  layout.width === this.state.width &&
  layout.height === this.state.height
) {
  return;
}

const zoom = layout.width / this.props.imageWidth;

const offsetTop =
  layout.height > this.props.imageHeight * zoom
    ? (layout.height - this.props.imageHeight * zoom) / 2
    : 0;

this.setState({
  layoutKnown: true,
  width: layout.width,
  height: layout.height,
  zoom,
  offsetTop,
  minZoom: zoom
});
  }

  componentWillMount() {
this._panResponder = PanResponder.create({
  onStartShouldSetPanResponder: () => true,
  onStartShouldSetPanResponderCapture: () => true,
  onMoveShouldSetPanResponder: () => true,
  onMoveShouldSetPanResponderCapture: () => true,
  onPanResponderGrant: () => {},
  onPanResponderMove: evt => {
    const touches = evt.nativeEvent.touches;
    if (touches.length === 2) {
      this.processPinch(
        touches[0].pageX,
        touches[0].pageY,
        touches[1].pageX,
        touches[1].pageY
      );
    } else if (touches.length === 1 && !this.state.isZooming) {
      this.processTouch(touches[0].pageX, touches[0].pageY);
    }
  },

  onPanResponderTerminationRequest: () => true,
  onPanResponderRelease: () => {
    this.setState({
      isZooming: false,
      isMoving: false
    });
  },
  onPanResponderTerminate: () => {},
  onShouldBlockNativeResponder: () => true
});
  }

  render() {
return (
  <View
    style={this.props.style}
    {...this._panResponder.panHandlers}
    onLayout={this._onLayout}
  >
    <Image
      style={{
        position: "absolute",
        top: this.state.offsetTop + this.state.top,
        left: this.state.offsetLeft + this.state.left,
        width: this.props.imageWidth * this.state.zoom,
        height: this.props.imageHeight * this.state.zoom
      }}
      source={this.props.source}
    />
  </View>
);
  }
}

ZoomableImage.propTypes = {
  imageWidth: PropTypes.number.isRequired,
  imageHeight: PropTypes.number.isRequired,
  source: PropTypes.object.isRequired
};
export default ZoomableImage;

Upvotes: 63

samurai jack
samurai jack

Reputation: 411

There's a much easier way now. Just make a ScollView with minimumZoomScale and maximumZoomScale:

import React, { Component } from 'react';
import { AppRegistry, ScrollView, Text } from 'react-native';

export default class IScrolledDownAndWhatHappenedNextShockedMe extends Component {
  render() {
      return (
        <ScrollView minimumZoomScale={1} maximumZoomScale={5} >
          <Text style={{fontSize:96}}>Scroll me plz</Text>
          <Text style={{fontSize:96}}>If you like</Text>
          <Text style={{fontSize:96}}>Scrolling down</Text>
          <Text style={{fontSize:96}}>What's the best</Text>
          <Text style={{fontSize:96}}>Framework around?</Text>
          <Text style={{fontSize:80}}>React Native</Text>
        </ScrollView>
    );
  }
}

// skip these lines if using Create React Native App
AppRegistry.registerComponent(
  'AwesomeProject',
  () => IScrolledDownAndWhatHappenedNextShockedMe);

Upvotes: 7

Related Questions