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?
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;
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With