This is the note about how to implement a feature to crop area using pinch and pan gestures on React Native
Background
A few weeks ago, I needed to implement the feature to crop the background image in a View component. I won’t explain the details of the specification because it’s too unique to what I’m working on. I googled for some libraries for the purpose and found some ones like below.
However, all of them are for croping a image in image picker. I just wanted to crop the background image in a View component like below (not exactly like this).
So I needed to create it by myself. Then I encountered some things to be careful of. So I decided to document them for feature me.
What I did
I’m going to show my final implementation first. I created one component and one hook. There are two gestures, pinch and pan, controlled by react-native-reanimated
andreact-native-gesture-handler
import { StyleSheet, Image } from 'react-native';
import Animated from 'react-native-reanimated';
import { GestureDetector } from 'react-native-gesture-handler';
import { width, height} from 'config/constants';
import { useCrop } from 'hooks/useCrop';
import type { MutableRefObject } from 'react';
import { CropProps } from 'hooks/useCrop';
export type Props = {
imageUrl: string;
cropPropsRef: MutableRefObject<CropProps>;
};
export function ExampleComponent({ imageUrl, cropPropsRef }: Props) {
const { gesture, animatedStyle } = useCrop({ cropPropsRef });
return (
<GestureDetector gesture={gesture}>
<Animated.View style={[styles.image, animatedStyle]}>
<Image
style={styles.image}
source={{ uri: imageUrl }}
resizeMode="contain"
/>
</Animated.View>
</GestureDetector>
);
}
const styles = StyleSheet.create({
image: { width, height }
});
import { useEffect, useMemo, useState, useCallback } from 'react';
import { useAnimatedStyle, useSharedValue, runOnJS } from 'react-native-reanimated';
import { Gesture } from 'react-native-gesture-handler';
import { clamp } from 'react-native-redash';
import { width, height, maxZoomScale } from 'config/constants';
import type { MutableRefObject } from 'react';
export type CropProps = {
x: number;
y: number;
scale: number;
};
export const calculateCoordiatesFromLeftTopCorner = (
offsetX: number,
offsetY: number,
scale: number
): Omit<CropProps, 'scale'> => {
const coordinateX = ((scale - 1) * width) / 2 - offsetX;
const coordinateY = ((scale - 1) * height) / 2 - offsetY;
return { x: coordinateX, y: coordinateY };
};
const setCropProps = (
cropPropsRef: MutableRefObject<CropProps>,
offsetX: number,
offsetY: number,
scale: number
): void => {
const newCoordinates = calculateCoordiatesFromLeftTopCorner(offsetX, offsetY, scale);
cropPropsRef.current = { scale, ...newCoordinates };
};
type Props = {
cropPropsRef: MutableRefObject<CropProps>;
};
export const useCrop = ({ cropPropsRef }: Props) => {
const currentPosition = useSharedValue({ x: 0, y: 0 });
const offset = useSharedValue({ x: 0, y: 0 });
const currentScale = useSharedValue(1);
const scale = useSharedValue(1);
const [calculationCounter, setCalculationCountere] = useState<number>(0);
useEffect(() => {
setCropProps(cropPropsRef, offset.value.x, offset.value.y, scale.value);
}, [calculationCounter]);
const incrementCalculationCounter = useCallback(
() => setCalculationCountere((prev: number) => prev + 1),
[]
);
const pan = useMemo(
() =>
Gesture.Pan()
.minPointers(1)
.onChange((event) => {
offset.value = {
x: clamp(
event.translationX + currentPosition.value.x,
(-1 * ((scale.value - 1) * width)) / 2,
((scale.value - 1) * width) / 2
),
y: clamp(
event.translationY + currentPosition.value.y,
(-1 *((scale.value - 1) * height)) / 2,
((scale.value - 1) * height) / 2
),
};
})
.onEnd((event) => {
currentPosition.value = {
x: offset.value.x,
y: offset.value.y,
};
runOnJS(incrementCalculationCounter)();
}),
[]
);
const pinch = useMemo(
() =>
Gesture.Pinch()
.onChange((event) => {
offset.value = {
x: clamp(
offset.value.x,
(-1 * ((scale.value - 1) * screenshotWidth)) / 2,
((scale.value - 1) * screenshotWidth) / 2
),
y: clamp(
offset.value.y,
(-1 *((scale.value - 1) * height)) / 2,
((scale.value - 1) * height) / 2
),
};
scale.value = clamp(currentScale.value * event.scale, 1, maxZoomScale);
})
.onEnd((event) => {
currentPosition.value = {
x: offset.value.x,
y: offset.value.y,
};
currentScale.value = scale.value;
runOnJS(incrementCalculationCounter)();
}),
[]
);
const gesture = Gesture.Simultaneous(pan, pinch);
const animatedStyle = useAnimatedStyle(() => {
return {
transform: [
{ translateX: offset.value.x },
{ translateY: offset.value.y },
{ scale: scale.value },
],
};
});
return { gesture, animatedStyle };
};
I made this component and hook following the example.
There were two things to be careful, which I was stuck in. The first thing is how to scale the component by zooming. The second thing is how to update the ref object with react-native-gesture-handler
.
Regarding the first topic, my component allows users to zoom in and out when they crop the background image. Initially, I thought the component would expand to the right and bottom direction and the original point would be the left-top corner like the following image.
However, the actual way to scale was like below.
Actually, the component is scaled to the top, right, bottom, and left evenly and the original coordinates are at the center of it. Before I noticed the difference, my calculations to get the left-top coordinates of the cropped image from the offsets were always wrong. After realizing this, I added the following parts to my component to validate the offsets and calculate the left-top coordinates properly.
offset.value = {
x: clamp(
offset.value.x,
(-1 * ((scale.value - 1) * screenshotWidth)) / 2,
((scale.value - 1) * screenshotWidth) / 2
),
y: clamp(
offset.value.y,
(-1 *((scale.value - 1) * height)) / 2,
((scale.value - 1) * height) / 2
),
};
export const calculateCoordiatesFromLeftTopCorner = (
offsetX: number,
offsetY: number,
scale: number
): Omit<CropProps, 'scale'> => {
const coordinateX = ((scale - 1) * width) / 2 - offsetX;
const coordinateY = ((scale - 1) * height) / 2 - offsetY;
return { x: coordinateX, y: coordinateY };
};
Regarding the second topic, I pass the Ref object to my component to store the crop data. Then the parent component sends the data to a server and saves/applies the crop interactions. In the beginning, it would be straightforward to update the Ref object in the gesture event handlers. But it seems impossible for now to update the Ref object directly inside the gesture event handlers. I googled for a couple of hours and the results were two things, using useState
or useShredValue
. The useSharedValue
is not suitable to my case because the date will be sent to the server through the parent component and it will be in a JS thread. We can’t use the useSharedValue
in a JS thread. So I decided to use a useState
value. But I didn’t want to replace the Ref object with a useState
value because the parent component will be re-rendered every time the pan and pinch events happen if I use a useState
value. So I kept the Ref object and created a state to trigger to update the object in the hook. The following part is for this purpose.
const [calculationCounter, setCalculationCountere] = useState<number>(0);
useEffect(() => {
setCropProps(cropPropsRef, offset.value.x, offset.value.y, scale.value);
}, [calculationCounter]);
const incrementCalculationCounter = useCallback(
() => setCalculationCountere((prev: number) => prev + 1),
[]
);
....
runOnJS(incrementCalculationCounter)();
...
I don’t think this is the best solution to update the Ref object in a UI thread. I’ll investigate more it the near future.
That’s it!