Conversation
There was a problem hiding this comment.
Pull request overview
Fixes web pinch focal point and rotation anchor point so they’re reported in the target view’s local coordinate space (matching iOS/Android behavior), addressing #2929.
Changes:
- Convert
RotationGestureHandleranchorX/anchorYfrom window/viewport space to local view space. - Convert
PinchGestureHandlerfocalX/focalYfrom window/viewport space to local view space.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 5 comments.
| File | Description |
|---|---|
| packages/react-native-gesture-handler/src/web/handlers/RotationGestureHandler.ts | Localizes rotation anchor coordinates before emitting events. |
| packages/react-native-gesture-handler/src/web/handlers/PinchGestureHandler.ts | Localizes pinch focal coordinates before emitting events. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
packages/react-native-gesture-handler/src/web/handlers/PinchGestureHandler.ts
Show resolved
Hide resolved
packages/react-native-gesture-handler/src/web/handlers/PinchGestureHandler.ts
Show resolved
Hide resolved
packages/react-native-gesture-handler/src/web/handlers/RotationGestureHandler.ts
Show resolved
Hide resolved
packages/react-native-gesture-handler/src/web/handlers/RotationGestureHandler.ts
Show resolved
Hide resolved
packages/react-native-gesture-handler/src/web/handlers/RotationGestureHandler.ts
Show resolved
Hide resolved
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 4 out of 4 changed files in this pull request and generated 3 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
packages/react-native-gesture-handler/src/web/detectors/ScaleGestureDetector.ts
Outdated
Show resolved
Hide resolved
packages/react-native-gesture-handler/src/web/handlers/RotationGestureHandler.ts
Outdated
Show resolved
Hide resolved
packages/react-native-gesture-handler/src/web/handlers/RotationGestureHandler.ts
Outdated
Show resolved
Hide resolved
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 4 out of 4 changed files in this pull request and generated no new comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
|
I've tried it quickly on #3124, and it seems it's still not exactly right. The anchor/focal points are wrong when the view is scaled. |
|
Could you check it now @j-piasecki? Should work fine. Tested on the following code (same as in #3124, but with indicators)import React from 'react';
import { StyleSheet, View, Image } from 'react-native';
import Animated, {
useAnimatedStyle,
useSharedValue,
} from 'react-native-reanimated';
import { GestureDetector, Gesture } from 'react-native-gesture-handler';
import { useState } from 'react';
// @ts-ignore it's an image
import SIGNET from '../../../ListWithHeader/signet.png';
function Pointer(props: { x: number; y: number }) {
return (
<View
style={{
position: 'absolute',
left: props.x,
top: props.y,
width: 16,
height: 16,
borderRadius: 8,
backgroundColor: 'red',
transform: [{ translateX: -8 }, { translateY: -8 }],
}}
/>
);
}
function identity4() {
'worklet';
return [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1];
}
function multiply4(a: number[], b: number[]) {
'worklet';
return [
a[0] * b[0] + a[1] * b[4] + a[2] * b[8] + a[3] * b[12],
a[0] * b[1] + a[1] * b[5] + a[2] * b[9] + a[3] * b[13],
a[0] * b[2] + a[1] * b[6] + a[2] * b[10] + a[3] * b[14],
a[0] * b[3] + a[1] * b[7] + a[2] * b[11] + a[3] * b[15],
a[4] * b[0] + a[5] * b[4] + a[6] * b[8] + a[7] * b[12],
a[4] * b[1] + a[5] * b[5] + a[6] * b[9] + a[7] * b[13],
a[4] * b[2] + a[5] * b[6] + a[6] * b[10] + a[7] * b[14],
a[4] * b[3] + a[5] * b[7] + a[6] * b[11] + a[7] * b[15],
a[8] * b[0] + a[9] * b[4] + a[10] * b[8] + a[11] * b[12],
a[8] * b[1] + a[9] * b[5] + a[10] * b[9] + a[11] * b[13],
a[8] * b[2] + a[9] * b[6] + a[10] * b[10] + a[11] * b[14],
a[8] * b[3] + a[9] * b[7] + a[10] * b[11] + a[11] * b[15],
a[12] * b[0] + a[13] * b[4] + a[14] * b[8] + a[15] * b[12],
a[12] * b[1] + a[13] * b[5] + a[14] * b[9] + a[15] * b[13],
a[12] * b[2] + a[13] * b[6] + a[14] * b[10] + a[15] * b[14],
a[12] * b[3] + a[13] * b[7] + a[14] * b[11] + a[15] * b[15],
];
}
function scale4(sx: number, sy: number, sz: number) {
'worklet';
return [sx, 0, 0, 0, 0, sy, 0, 0, 0, 0, sz, 0, 0, 0, 0, 1];
}
function translate4(tx: number, ty: number, tz: number) {
'worklet';
return [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, tx, ty, tz, 1];
}
function rotate4(rad: number, x: number, y: number, z: number) {
'worklet';
const len = Math.hypot(x, y, z);
const c = Math.cos(rad);
const s = Math.sin(rad);
const t = 1 - c;
x /= len;
y /= len;
z /= len;
return [
t * x * x + c,
t * x * y - s * z,
t * x * z + s * y,
0,
t * x * y + s * z,
t * y * y + c,
t * y * z - s * x,
0,
t * x * z - s * y,
t * y * z + s * x,
t * z * z + c,
0,
0,
0,
0,
1,
];
}
function invert2(m: number[]) {
'worklet';
const a = m[0];
const b = m[1];
const c = m[2];
const d = m[3];
const det = a * d - b * c;
return [d / det, -b / det, -c / det, a / det];
}
function toTransformedCoords(
point: { x: number; y: number },
matrix: number[]
) {
'worklet';
const m2 = [matrix[0], matrix[1], matrix[4], matrix[5]];
const inv = invert2(m2);
const x = point.x;
const y = point.y;
const newX = inv[0] * x + inv[2] * y;
const newY = inv[1] * x + inv[3] * y;
return { x: newX, y: newY };
}
function createMatrix(
translation: { x: number; y: number },
scale: number,
rotation: number,
origin: { x: number; y: number }
) {
'worklet';
let matrix = identity4();
if (scale !== 1) {
matrix = multiply4(matrix, translate4(origin.x, origin.y, 0));
matrix = multiply4(matrix, scale4(scale, scale, 1));
matrix = multiply4(matrix, translate4(-origin.x, -origin.y, 0));
}
if (rotation !== 0) {
matrix = multiply4(matrix, translate4(origin.x, origin.y, 0));
matrix = multiply4(matrix, rotate4(-rotation, 0, 0, 1));
matrix = multiply4(matrix, translate4(-origin.x, -origin.y, 0));
}
if (translation.x !== 0 || translation.y !== 0) {
matrix = multiply4(matrix, translate4(translation.x, translation.y, 0));
}
return matrix;
}
function applyTransformations(
translation: { x: number; y: number },
scale: number,
rotation: number,
origin: { x: number; y: number },
matrix: number[]
) {
'worklet';
const translationInViewCoords = toTransformedCoords(translation, matrix);
const transform = createMatrix(
translationInViewCoords,
scale,
rotation,
origin
);
return multiply4(transform, matrix);
}
function Photo() {
const [size, setSize] = useState({ width: 0, height: 0 });
const translation = useSharedValue({ x: 0, y: 0 });
const origin = useSharedValue({ x: 0, y: 0 });
const scale = useSharedValue(1);
const rotation = useSharedValue(0);
const isRotating = useSharedValue(false);
const isScaling = useSharedValue(false);
const [pointerPos, setPointerPos] = React.useState({ x: 100, y: 100 });
const [pointerVisible, setPointerVisible] = React.useState(false);
const transform = useSharedValue(identity4());
const style = useAnimatedStyle(() => {
const matrix = applyTransformations(
translation.value,
scale.value,
rotation.value,
origin.value,
transform.value
);
return {
transform: [
{ translateX: matrix[12] },
{ translateY: matrix[13] },
{ scale: Math.hypot(matrix[0], matrix[1]) },
{ rotateZ: `${Math.atan2(matrix[1], matrix[0])}rad` },
],
};
});
const rotationGesture = Gesture.Rotation()
.onStart((e) => {
if (!isRotating.value && !isScaling.value) {
origin.value = {
x: -(e.anchorX - size.width / 2),
y: -(e.anchorY - size.height / 2),
};
}
isRotating.value = true;
setPointerVisible(true);
setPointerPos({ x: e.anchorX, y: e.anchorY });
})
.onChange((e) => {
'worklet';
rotation.value += e.rotationChange;
})
.onEnd(() => {
'worklet';
transform.value = applyTransformations(
translation.value,
scale.value,
rotation.value,
origin.value,
transform.value
);
rotation.value = 0;
translation.value = { x: 0, y: 0 };
scale.value = 1;
isRotating.value = false;
setPointerVisible(false);
})
.runOnJS(true);
const scaleGesture = Gesture.Pinch()
.onStart((e) => {
if (!isRotating.value && !isScaling.value) {
origin.value = {
x: -(e.focalX - size.width / 2),
y: -(e.focalY - size.height / 2),
};
}
isScaling.value = true;
setPointerVisible(true);
setPointerPos({ x: e.focalX, y: e.focalY });
})
.onChange((e) => {
'worklet';
scale.value *= e.scaleChange;
})
.onEnd(() => {
'worklet';
transform.value = applyTransformations(
translation.value,
scale.value,
rotation.value,
origin.value,
transform.value
);
rotation.value = 0;
translation.value = { x: 0, y: 0 };
scale.value = 1;
isScaling.value = false;
setPointerVisible(false);
})
.runOnJS(true);
const panGesture = Gesture.Pan()
.averageTouches(true)
.onChange((e) => {
'worklet';
translation.value = {
x: translation.value.x + e.changeX,
y: translation.value.y + e.changeY,
};
})
.onEnd(() => {
'worklet';
transform.value = applyTransformations(
translation.value,
scale.value,
rotation.value,
origin.value,
transform.value
);
rotation.value = 0;
translation.value = { x: 0, y: 0 };
scale.value = 1;
});
const doubleTapGesture = Gesture.Tap()
.numberOfTaps(2)
.onEnd((_e, success) => {
'worklet';
if (success) {
scale.value *= 1.25;
}
});
const gesture = Gesture.Simultaneous(
rotationGesture,
scaleGesture,
panGesture,
doubleTapGesture
);
return (
<GestureDetector gesture={gesture}>
<Animated.View
onLayout={({ nativeEvent }) => {
setSize({
width: nativeEvent.layout.width,
height: nativeEvent.layout.height,
});
}}
style={[styles.container, style]}>
<Image source={SIGNET} style={styles.image} resizeMode="contain" />
{pointerVisible && <Pointer x={pointerPos.x} y={pointerPos.y} />}
</Animated.View>
</GestureDetector>
);
}
export default function Example() {
return (
<View style={styles.home}>
<Photo />
</View>
);
}
const styles = StyleSheet.create({
home: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
container: {
width: 240,
height: 240,
backgroundColor: '#eef0ff',
padding: 16,
elevation: 8,
borderRadius: 48,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.3,
shadowRadius: 4,
},
image: {
width: 208,
height: 208,
},
}); |
Description
Anchor and focal points were not converted to local view coordinates before being sent. This PR fixes this problem.
Fixes #2929
Test plan
Tested on example from #3124