react-native-zoom-anything
Version:
A lightweight pinch-to-zoom, pan, and double-tap zoom view for React Native using react-native-gesture-handler + Animated.
243 lines • 10.8 kB
JavaScript
// MIT License
// Copyright (c) 2025 Douglas Nassif Roma Junior
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
import React, { useCallback, useMemo, useRef } from 'react';
import { Animated, Easing, StyleSheet, View, } from 'react-native';
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
const styles = StyleSheet.create({
container: {
flex: 1,
overflow: 'hidden',
},
transformLayer: {
alignItems: 'center',
flex: 1,
justifyContent: 'center',
},
});
const clamp = (value, min, max) => Math.min(Math.max(value, min), max);
const EPS = 1e-3;
const ANIM_DURATION_MS = 200;
const INERTIA_PROJECTION_MS = 300;
const Zoom = ({ children, minZoom = 1, maxZoom = 5, style }) => {
const scale = useRef(new Animated.Value(minZoom)).current;
const translateX = useRef(new Animated.Value(0)).current;
const translateY = useRef(new Animated.Value(0)).current;
const lastParallel = useRef(null);
const committedScale = useRef(minZoom);
const committedTranslateX = useRef(0);
const committedTranslateY = useRef(0);
const containerWidthPx = useRef(0);
const containerHeightPx = useRef(0);
const contentWidthPx = useRef(0);
const contentHeightPx = useRef(0);
const panStartTranslateX = useRef(0);
const panStartTranslateY = useRef(0);
const computePanBounds = (scaleValue) => {
const containerWidth = containerWidthPx.current;
const containerHeight = containerHeightPx.current;
const contentWidthScaled = contentWidthPx.current * scaleValue;
const contentHeightScaled = contentHeightPx.current * scaleValue;
const maxPanX = Math.max(0, (contentWidthScaled - containerWidth) / 2);
const maxPanY = Math.max(0, (contentHeightScaled - containerHeight) / 2);
return {
minX: -maxPanX,
maxX: maxPanX,
minY: -maxPanY,
maxY: maxPanY,
};
};
const stopAnimations = () => {
lastParallel.current?.stop();
lastParallel.current = null;
};
const onContainerLayout = useCallback((e) => {
const { width, height } = e.nativeEvent.layout;
containerWidthPx.current = width;
containerHeightPx.current = height;
const { minX, maxX, minY, maxY } = computePanBounds(committedScale.current);
const clampedX = clamp(committedTranslateX.current, minX, maxX);
const clampedY = clamp(committedTranslateY.current, minY, maxY);
committedTranslateX.current = clampedX;
committedTranslateY.current = clampedY;
translateX.setValue(clampedX);
translateY.setValue(clampedY);
}, [translateX, translateY]);
const onContentLayout = useCallback((e) => {
const { width, height } = e.nativeEvent.layout;
contentWidthPx.current = width;
contentHeightPx.current = height;
const { minX, maxX, minY, maxY } = computePanBounds(committedScale.current);
const clampedX = clamp(committedTranslateX.current, minX, maxX);
const clampedY = clamp(committedTranslateY.current, minY, maxY);
committedTranslateX.current = clampedX;
committedTranslateY.current = clampedY;
translateX.setValue(clampedX);
translateY.setValue(clampedY);
}, [translateX, translateY]);
const pinchGesture = Gesture.Pinch()
.runOnJS(true)
.onStart(event => {
if (event.numberOfPointers !== 2)
return;
stopAnimations();
})
.onUpdate(event => {
if (event.numberOfPointers !== 2)
return;
const clampedScale = clamp(committedScale.current * event.scale, minZoom, maxZoom);
scale.setValue(clampedScale);
const { minX, maxX, minY, maxY } = computePanBounds(clampedScale);
const clampedX = clamp(committedTranslateX.current, minX, maxX);
const clampedY = clamp(committedTranslateY.current, minY, maxY);
translateX.setValue(clampedX);
translateY.setValue(clampedY);
})
.onEnd(e => {
const clampedScale = clamp(committedScale.current * e.scale, minZoom, maxZoom);
committedScale.current = clampedScale;
scale.setValue(clampedScale);
const { minX, maxX, minY, maxY } = computePanBounds(clampedScale);
const clampedX = clamp(committedTranslateX.current, minX, maxX);
const clampedY = clamp(committedTranslateY.current, minY, maxY);
committedTranslateX.current = clampedX;
committedTranslateY.current = clampedY;
translateX.setValue(clampedX);
translateY.setValue(clampedY);
});
const panGesture = Gesture.Pan()
.runOnJS(true)
.onBegin(() => {
panStartTranslateX.current = committedTranslateX.current;
panStartTranslateY.current = committedTranslateY.current;
})
.onUpdate(event => {
if (committedScale.current <= minZoom + EPS)
return;
const currentScale = committedScale.current;
const { minX, maxX, minY, maxY } = computePanBounds(currentScale);
const clampedX = clamp(panStartTranslateX.current + event.translationX, minX, maxX);
const clampedY = clamp(panStartTranslateY.current + event.translationY, minY, maxY);
translateX.setValue(clampedX);
translateY.setValue(clampedY);
})
.onEnd(event => {
if (committedScale.current <= minZoom + EPS) {
committedTranslateX.current = 0;
committedTranslateY.current = 0;
translateX.setValue(0);
translateY.setValue(0);
return;
}
const currentScale = committedScale.current;
const { minX, maxX, minY, maxY } = computePanBounds(currentScale);
const releaseX = clamp(panStartTranslateX.current + event.translationX, minX, maxX);
const releaseY = clamp(panStartTranslateY.current + event.translationY, minY, maxY);
const dt = INERTIA_PROJECTION_MS / 1000;
const projectedX = releaseX + (event.velocityX ?? 0) * dt;
const projectedY = releaseY + (event.velocityY ?? 0) * dt;
const targetX = clamp(projectedX, minX, maxX);
const targetY = clamp(projectedY, minY, maxY);
committedTranslateX.current = targetX;
committedTranslateY.current = targetY;
lastParallel.current = Animated.parallel([
Animated.timing(translateX, {
toValue: targetX,
duration: INERTIA_PROJECTION_MS,
easing: Easing.out(Easing.cubic),
useNativeDriver: true,
}),
Animated.timing(translateY, {
toValue: targetY,
duration: INERTIA_PROJECTION_MS,
easing: Easing.out(Easing.cubic),
useNativeDriver: true,
}),
], { stopTogether: true });
lastParallel.current.start();
});
function getTargetScale() {
const midScale = (minZoom + maxZoom) / 2;
if (Math.abs(committedScale.current - minZoom) < EPS)
return midScale;
else if (committedScale.current < maxZoom - EPS)
return maxZoom;
return minZoom;
}
const doubleTapGesture = Gesture.Tap()
.runOnJS(true)
.numberOfTaps(2)
.onEnd((event, success) => {
if (!success)
return;
stopAnimations();
let targetScale = getTargetScale();
const containerW = containerWidthPx.current;
const containerH = containerHeightPx.current;
const tapXFromCenter = (event.x ?? containerW / 2) - containerW / 2;
const tapYFromCenter = (event.y ?? containerH / 2) - containerH / 2;
const currentScale = committedScale.current;
const ratio = targetScale / currentScale;
const targetTx = tapXFromCenter - ratio * (tapXFromCenter - committedTranslateX.current);
const targetTy = tapYFromCenter - ratio * (tapYFromCenter - committedTranslateY.current);
const { minX, maxX, minY, maxY } = computePanBounds(targetScale);
const clampedTx = clamp(targetTx, minX, maxX);
const clampedTy = clamp(targetTy, minY, maxY);
committedScale.current = targetScale;
committedTranslateX.current = clampedTx;
committedTranslateY.current = clampedTy;
lastParallel.current = Animated.parallel([
Animated.timing(scale, {
toValue: targetScale,
duration: ANIM_DURATION_MS,
useNativeDriver: true,
}),
Animated.timing(translateX, {
toValue: clampedTx,
duration: ANIM_DURATION_MS,
useNativeDriver: true,
}),
Animated.timing(translateY, {
toValue: clampedTy,
duration: ANIM_DURATION_MS,
useNativeDriver: true,
}),
], { stopTogether: true });
lastParallel.current.start();
});
const composedGesture = Gesture.Simultaneous(pinchGesture, panGesture, doubleTapGesture);
const contentStyle = useMemo(() => [
styles.transformLayer,
{
transform: [{ translateX }, { translateY }, { scale }],
},
], [translateX, translateY, scale]);
const containerStyles = useMemo(() => [styles.container, style], [style]);
return (<GestureDetector gesture={composedGesture}>
<View onLayout={onContainerLayout} style={containerStyles}>
<Animated.View style={contentStyle}>
{children
? React.cloneElement(children, {
onLayout: onContentLayout,
})
: null}
</Animated.View>
</View>
</GestureDetector>);
};
export default Zoom;
//# sourceMappingURL=Zoom.js.map