To implement complex interactions like a photo viewer (simultaneous pan, pinch, and rotation), do not use separate transform properties in a React transform array. Instead, use an affine matrix to accumulate transformations.
Key Strategies:
Use an Affine Matrix: Store the accumulated transformation as a single matrix. This allows each new gesture to build upon the previous state by multiplying the current matrix with a new transformation matrix. The in-progress gesture values (scale, rotation, translation) are temporary, and when the gesture ends, they are 'folded' into the main matrix.
Keep the Origin Stable: Scaling and rotation pivot around an origin. To ensure the view pivots around the user's fingers, wrap the transformation between two translations: shift the pivot point to the origin, apply the scale/rotation, and then shift it back.
matrix = multiply(matrix, translate(origin.x, origin.y));
matrix = multiply(matrix, scale(scaleValue, scaleValue));
matrix = multiply(matrix, translate(-origin.x, -origin.y));
Capture the pivot point once during onActivate and store it in a shared value. Do not recompute it every frame to avoid view jumping.
Composition: Use useSimultaneousGestures to allow multiple gestures (e.g., Pan, Pinch, Rotation) to run at the same time.
import React, { useState } from 'react';
import { StyleSheet, View } from 'react-native';
import {
GestureDetector,
usePanGesture,
usePinchGesture,
useRotationGesture,
useSimultaneousGestures,
useTapGesture,
} from 'react-native-gesture-handler';
import Animated, {
useAnimatedStyle,
useSharedValue,
} from 'react-native-reanimated';
// ... (helper functions like identity3, multiply3, scale3, etc.)
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 transform = useSharedValue(identity3());
const style = useAnimatedStyle(() => {
const matrix = applyTransformations(
translation.value,
scale.value,
rotation.value,
origin.value,
transform.value
);
return {
transform: [
{ translateX: matrix[6] },
{ translateY: matrix[7] },
{ scale: Math.hypot(matrix[0], matrix[1]) },
{ rotateZ: `${Math.atan2(matrix[1], matrix[0])}rad` },
],
};
});
const rotationGesture = useRotationGesture({
onActivate: (e) => {
if (!isRotating.value && !isScaling.value) {
origin.value = {
x: -(e.anchorX - size.width / 2),
y: -(e.anchorY - size.height / 2),
};
}
isRotating.value = true;
},
onUpdate: (e) => {
rotation.value += e.rotationChange;
},
onDeactivate: () => {
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;
},
});
const scaleGesture = usePinchGesture({
onActivate: (e) => {
if (!isRotating.value && !isScaling.value) {
origin.value = {
x: -(e.focalX - size.width / 2),
y: -(e.focalY - size.height / 2),
};
}
isScaling.value = true;
},
onUpdate: (e) => {
scale.value *= e.scaleChange;
},
onDeactivate: () => {
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;
},
});
const panGesture = usePanGesture({
averageTouches: true,
onUpdate: (e) => {
translation.value = {
x: translation.value.x + e.changeX,
y: translation.value.y + e.changeY,
};
},
onDeactivate: () => {
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 = useTapGesture({
numberOfTaps: 2,
onDeactivate: () => {
scale.value *= 1.25;
},
});
const gesture = useSimultaneousGestures(
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]}
/>
</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: '#5b6ef5',
elevation: 8,
borderRadius: 48,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.3,
shadowRadius: 4,
},
});