'How to update scale focal origin with react reanimated and react native gesture handler for iterative pinch gestures?

I built a pinch to zoom effect using react-native-gesture-handler and react-native-reanimated. The user can pinch anywhere on the image and zoom in or out with the position between the fingers as the zoom origin. This is great. What I am having trouble with is in allowing the user to zoom in or out in multiple pinch gestures. This requires remembering the offsets and zoom scale from the user's prior pinch gesture. With the code I currently have, when a user pinches a second time, the gesture handler remembers the zoom scale value from the first pinch gesture, does not update the zoom origin properly. How can I fix this without increasing the number of transform statements?

  const prevZoomScale = useSharedValue(1)
  const currZoomScale = useSharedValue(1)
  const zoomScale = useDerivedValue(() => { return prevZoomScale.value * currZoomScale.value }, [prevZoomScale.value, currZoomScale.value])
  const tempZoomScale = useSharedValue(1)
  const prevOriginOffset = useSharedValue({x: 0, y: 0})
  const tempOriginOffset = useSharedValue({x: 0, y: 0})
  const currOriginOffset = useSharedValue({x: 0, y: 0})
  const pinchOriginOffset = useDerivedValue(() => 
     { 
        return {
                 x: (prevOriginOffset.value.x + currOriginOffset.value.x), 
                 y: (prevOriginOffset.value.y + currOriginOffset.value.y)
        }
    }, 
     [prevOriginOffset.value.x,  prevOriginOffset.value.y,  currOriginOffset.value.x,  currOriginOffset.value.y]
  )

  const onPinchEvent = useAnimatedGestureHandler<PinchGestureHandlerGestureEvent>({
    onStart: (_) => {
      prevZoomScale.value = tempZoomScale.value
      currZoomScale.value = 1
      prevOriginOffset.value = tempOriginOffset.value
      currOriginOffset.value = {x: _.focalX - SIZE / 2, y: _.focalY - SIZE / 2}
    },
    onActive: (event) => {
      if ((event.scale * prevZoomScale.value) > 1) {
        currZoomScale.value = event.scale
      }
    },
    onEnd: (_) => {
      tempZoomScale.value = zoomScale.value
      tempOriginOffset.value = pinchOriginOffset.value
    },


  const animatedStyle = useAnimatedStyle(
    () => ({
      transform: [
        {
          translateX: (pinchOriginOffset.value.x)
        },
        {
          translateY:  (pinchOriginOffset.value.y)
        },
        {
          scale: zoomScale.value
        },
        {
          translateX: - (pinchOriginOffset.value.x)
        },
        {
          translateY: - ( pinchOriginOffset.value.y)
        }
      ],
    }),
    []
  )

    return (
      <View style={[styles.zoomScrollContainer, { backgroundColor: color.core.black }]}>
        <PinchGestureHandler
          onGestureEvent={onPinchEvent}
        >
              <Animated.View >
                <Animated.Image
                  source={{ uri: zoomedImageUri }}
                  style={[styles.imageStyle, animatedStyle]}
                >
                </Animated.Image>
              </Animated.View>
        </PinchGestureHandler>
      </View>
    )


Solution 1:[1]

We came across a very similar thing to this recently, trying to make a Canvas type element in React Native. We solved it after a few long(!) days so I’ve outlined our thought process below with hopefully working code. We also support panning as well as zoom, but I’ve removed any panning logic from the below code as it looks like you don’t need it.

We originally did what you’re doing, trying to keep track of each offset/zoom as the user did it, but found it led to weird results and we couldn’t work out how to combine the transforms. When the user let go of a pinch, the object would jump to a new location, because the net focal point was incorrect.

We now keep track of the scale and the net x,y values as the user pinches, and then its easier to combine the current transform with the previous transform when the pinch ends.

We keep track of the X and Y components separately, but they could be easily combined into an {x,y} object.

We also ensure that there are 2 fingers on screen, as at the end of a pinch the focal point can jump to a single finger if both fingers aren't removed from the screen at the exact same time.

Let me know if the below works for you!

import React from "react";
import { View } from "react-native";
import {
    PinchGestureHandler,
} from "react-native-gesture-handler";
import Animated, {
    useAnimatedStyle,
    useAnimatedGestureHandler,
    useSharedValue,
} from "react-native-reanimated";


export default function Canvas() {
    const WIDTH = 400;
    const HEIGHT = 400;

    const focalX = useSharedValue(0);
    const focalY = useSharedValue(0);
    const xCurrent = useSharedValue(0);
    const yCurrent = useSharedValue(0);
    const xPrevious = useSharedValue(0);
    const yPrevious = useSharedValue(0);
    const scaleCurrent = useSharedValue(1);
    const scalePrevious = useSharedValue(1);

    const pinchHandler = useAnimatedGestureHandler({
        onStart: (event) => {
            if (event.numberOfPointers == 2) {
                focalX.value = event.focalX;
                focalY.value = event.focalY;
            }
        },
        onActive: (event) => {
            if (event.numberOfPointers == 2) {
                // On Android, the onStart event gives 0,0 for the focal
                // values, so we set them here instead too.
                if (event.oldState === 2) {
                    focalX.value = event.focalX;
                    focalY.value = event.focalY;
                }
                scaleCurrent.value = event.scale;

                xCurrent.value = (1 - scaleCurrent.value) * (focalX.value - WIDTH / 2);
                yCurrent.value = (1 - scaleCurrent.value) * (focalY.value - HEIGHT / 2);
            }
        },
        onEnd: () => {
            scalePrevious.value = scalePrevious.value * scaleCurrent.value;

            xPrevious.value = scaleCurrent.value * xPrevious.value + xCurrent.value;
            yPrevious.value = scaleCurrent.value * yPrevious.value + yCurrent.value;

            xCurrent.value = 0;
            yCurrent.value = 0;

            scaleCurrent.value = 1;
        },
    });

    const animatedStyle = useAnimatedStyle(() => {
        return {
            transform: [
                { translateX: xCurrent.value },
                { translateY: yCurrent.value },
                { scale: scaleCurrent.value },
                { translateX: xPrevious.value },
                { translateY: yPrevious.value },
                { scale: scalePrevious.value },
            ],
        };
    });

    return (
        <View>
            <PinchGestureHandler onGestureEvent={pinchHandler}>
                <Animated.View style={{width: 1000, height: 1000}}>
                    <Animated.Image
                        source={{uri:<IMAGE_URI>}}
                        style={[{
                            width: WIDTH,
                            height: HEIGHT,
                        },animatedStyle]}
                    >
                    </Animated.Image>
                </Animated.View>
            </PinchGestureHandler>
        </View>
    );
}

Sources

This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.

Source: Stack Overflow

Solution Source
Solution 1