react-native-gesture-handler: focalX and focalY are wrong on android only

Description

I am trying to implement pinch to zoom and I pretty sure my math is correct. But the focalX and focalY are wrong on android only

Android android

iOS ios

On android, the first pinch-to-zoom behave “correct”. The last pinch-to-zoom must be wrong. I tap two points and the coordinates are x = 194.86 y = 351.61 and x = 194.32 y = 396.36 . Then I pinch and the focal point is x = 195.93 y = 464.81. I expect the y-coord should be between two taps point. Now it is outside. 351.61 <= 464.81 <= 396.36 ❌ ❌ ❌ ❌ . x-coord has the same issue.

When you take a look iOS, it behaves correctly 313 <= 351 <= 374 ✅✅✅✅

I think the bug is on either react-native or react-native-gesture-handler. I do not think it is react or react-native-reanimated bug

Platforms

  • iOS
  • Android
  • Web

Screenshots

Steps To Reproduce

  1. git clone https://github.com/wood1986/pinch-bug.git
  2. yarn android

Expected behavior

tap[0].x <= focalX <= tap[1].x tap[0].y <= focalY <= tap[1].y

Actual behavior

focalX <= tap[0].x or tap[1].x <= focalX focalY <= tap[0].y or tap[1].y <= focalY

Snack or minimal code example

Package versions

  • React: 18.0.0
  • React Native: 0.69.2
  • React Native Gesture Handler: 2.5.0
  • React Native Reanimated: 2.9.1

About this issue

  • Original URL
  • State: closed
  • Created 2 years ago
  • Reactions: 1
  • Comments: 26 (11 by maintainers)

Commits related to this issue

Most upvoted comments

This should do the trick:

import React from 'react';
import { StyleSheet, SafeAreaView, View, Button } from 'react-native';
import {
  Gesture,
  GestureDetector,
  GestureHandlerRootView,
} from 'react-native-gesture-handler';
import Animated, {
  useAnimatedStyle,
  useSharedValue,
  useAnimatedRef,
  measure,
} from 'react-native-reanimated';
import { identity3, Matrix3, multiply3 } from 'react-native-redash';

function translateMatrix(matrix: Matrix3, x: number, y: number) {
  'worklet';
  return multiply3(matrix, [1, 0, x, 0, 1, y, 0, 0, 1]);
}

function scaleMatrix(matrox: Matrix3, value: number) {
  'worklet';
  return multiply3(matrox, [value, 0, 0, 0, value, 0, 0, 0, 1]);
}

const ImageViewer = () => {
  const ref = useAnimatedRef();
  const origin = useSharedValue({ x: 0, y: 0 });
  const transform = useSharedValue(identity3);
  const scale = useSharedValue(1);
  const translation = useSharedValue({ x: 0, y: 0 });

  const pinch = Gesture.Pinch()
    .onStart((event) => {
      const measured = measure(ref);
      origin.value = {
        x: event.focalX - measured.width / 2,
        y: event.focalY - measured.height / 2,
      };
    })
    .onChange((event) => {
      scale.value = event.scale;
    })
    .onEnd(() => {
      let matrix = identity3;
      matrix = translateMatrix(matrix, origin.value.x, origin.value.y);
      matrix = scaleMatrix(matrix, scale.value);
      matrix = translateMatrix(matrix, -origin.value.x, -origin.value.y);
      transform.value = multiply3(matrix, transform.value);
      scale.value = 1;
    });

  const pan = Gesture.Pan()
    .averageTouches(true)
    .onChange((event) => {
      translation.value = {
        x: event.translationX,
        y: event.translationY,
      };
    })
    .onEnd(() => {
      let matrix = identity3;
      matrix = translateMatrix(
        matrix,
        translation.value.x,
        translation.value.y
      );
      transform.value = multiply3(matrix, transform.value);
      translation.value = { x: 0, y: 0 };
    });

  const animatedStyle = useAnimatedStyle(() => {
    let matrix = identity3;

    if (translation.value.x !== 0 || translation.value.y !== 0) {
      matrix = translateMatrix(
        matrix,
        translation.value.x,
        translation.value.y
      );
    }

    if (scale.value !== 1) {
      matrix = translateMatrix(matrix, origin.value.x, origin.value.y);
      matrix = scaleMatrix(matrix, scale.value);
      matrix = translateMatrix(matrix, -origin.value.x, -origin.value.y);
    }

    matrix = multiply3(matrix, transform.value);

    return {
      transform: [
        { translateX: matrix[2] },
        { translateY: matrix[5] },
        { scaleX: matrix[0] },
        { scaleY: matrix[4] },
      ],
    };
  });

  return (
    <>
      <GestureDetector gesture={Gesture.Simultaneous(pinch, pan)}>
        <Animated.View
          ref={ref}
          collapsable={false}
          style={[styles.fullscreen]}>
          <Animated.Image
            source={require('./1.png')}
            resizeMode={'contain'}
            style={[styles.fullscreen, animatedStyle]}
          />
        </Animated.View>
      </GestureDetector>

      <View style={{ position: 'absolute', end: 0, backgroundColor: 'black' }}>
        <Button
          title="RESET"
          onPress={() => {
            transform.value = identity3;
          }}
        />
      </View>
    </>
  );
};

const styles = StyleSheet.create({
  fullscreen: {
    ...StyleSheet.absoluteFillObject,
    flex: 1,
    width: '100%',
    height: '100%',
    resizeMode: 'contain',
  },

  pointer: {
    width: 60,
    height: 60,
    borderRadius: 30,
    backgroundColor: 'red',
    position: 'absolute',
    marginStart: -30,
    marginTop: -30,
  },
});

const App = () => {
  return (
    <GestureHandlerRootView style={{ flex: 1 }}>
      <SafeAreaView style={{ flex: 1, backgroundColor: 'black' }}>
        <ImageViewer />
      </SafeAreaView>
    </GestureHandlerRootView>
  );
};

export default App;

Your code is perfect!!! Thank you so much

FYI: I do not know if you are using my repo for testing the fix. I did a clean up with a force push. you may need to clone again

Thanks @j-piasecki, just let you know I want to help and fix it fundamentally. I have been looking at the place setLocation and trying to apply matrix transformation to the second point to see if I can get the right coordinates. But no progress.