Created
October 25, 2025 11:14
-
-
Save dentemm/77f8cdff676264e8a69a6785231628b1 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import React from 'react'; | |
| import {View, StyleSheet, useWindowDimensions} from 'react-native'; | |
| import { | |
| Gesture, | |
| GestureDetector, | |
| GestureHandlerRootView, | |
| } from 'react-native-gesture-handler'; | |
| import Animated, { | |
| clamp, | |
| useAnimatedStyle, | |
| useSharedValue, | |
| withSpring, | |
| } from 'react-native-reanimated'; | |
| const MIN_SCALE = 0.5; | |
| const MAX_SCALE = 3; | |
| const RECTANGLE_SIZE = 200; | |
| export default function PinchZoomDemo() { | |
| const {width: screenWidth, height: screenHeight} = useWindowDimensions(); | |
| const translateX = useSharedValue(screenWidth / 2 - RECTANGLE_SIZE / 2); // Center the rectangle | |
| const translateY = useSharedValue(screenHeight / 2 - RECTANGLE_SIZE / 2); | |
| const scale = useSharedValue(1); | |
| const startScale = useSharedValue(1); | |
| const focalX = useSharedValue(0); | |
| const focalY = useSharedValue(0); | |
| const prevPanX = useSharedValue(0); | |
| const prevPanY = useSharedValue(0); | |
| const isDragging = useSharedValue(false); | |
| const rectangleAnimatedStyle = useAnimatedStyle(() => { | |
| return { | |
| transform: [ | |
| {translateX: translateX.value}, | |
| {translateY: translateY.value}, | |
| {scale: scale.value}, | |
| ], | |
| }; | |
| }); | |
| const panGesture = Gesture.Pan() | |
| .maxPointers(1) | |
| .minDistance(6) | |
| .onStart(() => { | |
| 'worklet'; | |
| prevPanX.value = 0; | |
| prevPanY.value = 0; | |
| isDragging.value = true; | |
| }) | |
| .onUpdate(event => { | |
| 'worklet'; | |
| const deltaX = event.translationX - prevPanX.value; | |
| const deltaY = event.translationY - prevPanY.value; | |
| translateX.value += deltaX; | |
| translateY.value += deltaY; | |
| prevPanX.value = event.translationX; | |
| prevPanY.value = event.translationY; | |
| }) | |
| .onEnd(() => { | |
| 'worklet'; | |
| isDragging.value = false; | |
| prevPanX.value = 0; | |
| prevPanY.value = 0; | |
| }) | |
| .onFinalize(() => { | |
| 'worklet'; | |
| isDragging.value = false; | |
| prevPanX.value = 0; | |
| prevPanY.value = 0; | |
| }); | |
| const pinchGesture = Gesture.Pinch() | |
| .onStart(event => { | |
| 'worklet'; | |
| startScale.value = scale.value; | |
| focalX.value = event.focalX; | |
| focalY.value = event.focalY; | |
| }) | |
| .onUpdate(event => { | |
| 'worklet'; | |
| const zoomSensitivity = 1; | |
| const rawScale = 1 + (event.scale - 1) * zoomSensitivity; | |
| const nextScale = clamp( | |
| startScale.value * rawScale, | |
| MIN_SCALE, | |
| MAX_SCALE, | |
| ); | |
| // Simple focal point zooming approach | |
| // The focal point should stay at the same screen position while the rectangle scales | |
| // Apply the new scale | |
| scale.value = nextScale; | |
| // Adjust the translation to keep the focal point fixed | |
| // The focal point is at: translateX + focalX * scale | |
| // We want to keep this screen position fixed, so: | |
| const currentFocalScreenX = translateX.value + focalX.value * scale.value; | |
| const currentFocalScreenY = translateY.value + focalY.value * scale.value; | |
| // Calculate the new translation to maintain the focal point | |
| translateX.value = currentFocalScreenX - focalX.value * nextScale; | |
| translateY.value = currentFocalScreenY - focalY.value * nextScale; | |
| }) | |
| .onEnd(() => { | |
| 'worklet'; | |
| // Snap to bounds if needed | |
| if (scale.value < MIN_SCALE) { | |
| scale.value = withSpring(MIN_SCALE, {damping: 18, stiffness: 180}); | |
| } else if (scale.value > MAX_SCALE) { | |
| scale.value = withSpring(MAX_SCALE, {damping: 18, stiffness: 180}); | |
| } | |
| }); | |
| // ======================== | |
| // Compose gestures (pan + pinch) | |
| // ======================== | |
| const composedGesture = Gesture.Simultaneous(panGesture, pinchGesture); | |
| // ======================== | |
| // Render | |
| // ======================== | |
| return ( | |
| <GestureHandlerRootView style={styles.container}> | |
| <View style={styles.canvas}> | |
| <GestureDetector gesture={composedGesture}> | |
| <Animated.View | |
| style={[ | |
| styles.rectangle, | |
| rectangleAnimatedStyle, | |
| { | |
| width: RECTANGLE_SIZE, | |
| height: RECTANGLE_SIZE, | |
| }, | |
| ]} | |
| /> | |
| </GestureDetector> | |
| </View> | |
| </GestureHandlerRootView> | |
| ); | |
| } | |
| const styles = StyleSheet.create({ | |
| container: { | |
| flex: 1, | |
| backgroundColor: '#f8fafc', | |
| }, | |
| canvas: { | |
| flex: 1, | |
| position: 'relative', | |
| }, | |
| rectangle: { | |
| position: 'absolute', | |
| backgroundColor: '#3b82f6', | |
| borderRadius: 12, | |
| }, | |
| }); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment