Skip to content

Instantly share code, notes, and snippets.

@Glazzes
Created August 11, 2024 23:53
Show Gist options
  • Save Glazzes/3249c28b8c33e300df0a53273db7dc78 to your computer and use it in GitHub Desktop.
Save Glazzes/3249c28b8c33e300df0a53273db7dc78 to your computer and use it in GitHub Desktop.
React Native Sticker (For Photo Editor Apps)
/**
* @author Santiago Zapata
* @description This is small gist about how to get a sticker like image to rotate over itself aswell
* resizing it's dimensions as seen in Telegram.
*
* How does it work?
* All you need is to know the angle to rotate your sticker, this achieved by getting the position of the
* center of the image relative to the screen as this one will serve as the center of our calculations,
* For the rings at the sides we want their position in the screen aswell, however the rings are mere
* decorations to trigger the pan gesture as this one will provide us the position of your touches
* through the absoluteX and absoluteY propertues.
*
* With both the position of the center of the image and the current touch in the screen, all we have to do
* is using the atan2 function to get the angle.
*
* What to keep in mind?
* - The rings are not "rotated" they're positioned according to the angle.
* - Resizing images is an expensive task, it needs to be scaled for performance reasons
* - If you're unfamiliar with trigonomentry attempt to drag a ring in the other one's direction,
* you will see a sudden snap, this the is most clear sign of trigometry beign used.
*
* @see William Cadillon's video on trigonometry https://www.youtube.com/watch?v=-lF7sSTelOg&t=88s
*/
import React from "react";
import { View, StyleSheet, ImageSourcePropType } from "react-native";
import Animated, {
cancelAnimation,
Easing,
measure,
SharedValue,
useAnimatedRef,
useAnimatedStyle,
useSharedValue,
withRepeat,
withTiming,
} from "react-native-reanimated";
import {
Gesture,
GestureDetector,
GestureUpdateEvent,
PanGestureHandlerEventPayload,
} from "react-native-gesture-handler";
type PanGestureEvent = GestureUpdateEvent<PanGestureHandlerEventPayload>;
type StickerProps = {
source: ImageSourcePropType;
};
const TAU = Math.PI * 2;
const INDICATOR_SIZE = 20;
const HITSLOP = (44 - INDICATOR_SIZE) / 2;
const SIZE = 150;
// Multiplying any square like dimension by sqrt2 gives as a result the radius of a circle
// big enough to enclose such size perfectly.
const RING_SIZE = SIZE * Math.SQRT2;
const RING_RADIUS = RING_SIZE / 2;
type Vector = {
x: SharedValue<number>;
y: SharedValue<number>;
};
export const useVector = (x: number, y?: number): Vector => {
const x1 = useSharedValue<number>(x);
const y1 = useSharedValue<number>(y ?? x);
return { x: x1, y: y1 };
};
const Sticker: React.FC<StickerProps> = ({ source }) => {
const stickerRef = useAnimatedRef();
const translate = useVector(0, 0);
const offset = useVector(0, 0);
const scale = useSharedValue<number>(1);
const stickerCenter = useVector(0, 0);
const ringScale = useSharedValue<number>(1);
const ringOpacity = useSharedValue<number>(1);
const radius = useSharedValue<number>(RING_RADIUS);
const radiusOffset = useSharedValue<number>(RING_RADIUS);
const rotation = useSharedValue<number>(0);
const stickerPan = Gesture.Pan()
.maxPointers(1)
.onStart(() => {
offset.x.value = translate.x.value;
offset.y.value = translate.y.value;
})
.onChange((e) => {
translate.x.value = offset.x.value + e.translationX;
translate.y.value = offset.y.value + e.translationY;
});
const stickerPinch = Gesture.Pinch()
.onStart(() => (radiusOffset.value = radius.value))
.onUpdate((e) => {
radius.value = Math.max(RING_SIZE / 2, radiusOffset.value * e.scale);
});
const stickerTap = Gesture.Tap()
.numberOfTaps(1)
.onStart(() => {
cancelAnimation(scale);
scale.value = withRepeat(
withTiming(0.9, {
duration: 200,
easing: Easing.bezier(0.26, 0.19, 0.42, 1.49),
}),
2,
true,
);
ringOpacity.value = withTiming(1);
ringScale.value = withTiming(1);
});
const stickerContainerStyles = useAnimatedStyle(() => ({
transform: [
{ translateX: translate.x.value },
{ translateY: translate.y.value },
],
}));
const getStickerPosition = () => {
"worklet";
const { pageX, pageY, width, height } = measure(stickerRef)!;
stickerCenter.x.value = pageX + width / 2;
stickerCenter.y.value = pageY + height / 2;
};
const onPanUpdate = (e: PanGestureEvent, direction: "right" | "left") => {
"worklet";
// Both rings are the same, the only difference is the left one has an 180 degrees offset
const acc = direction === "right" ? 0 : Math.PI;
const normalizedX = e.absoluteX - stickerCenter.x.value;
const normalizedY = -1 * (e.absoluteY - stickerCenter.y.value);
const currentRadius = Math.sqrt(normalizedX ** 2 + normalizedY ** 2);
const angle = Math.atan2(normalizedY, normalizedX);
radius.value = Math.max(RING_RADIUS / 2, currentRadius);
rotation.value = -1 * ((angle + acc + TAU) % TAU);
};
const rightPan = Gesture.Pan()
.hitSlop({ vertical: HITSLOP, horizontal: HITSLOP })
.onStart(getStickerPosition)
.onUpdate((e) => onPanUpdate(e, "right"));
const leftPan = Gesture.Pan()
.hitSlop({ vertical: HITSLOP, horizontal: HITSLOP })
.onStart(getStickerPosition)
.onUpdate((e) => onPanUpdate(e, "left"));
const stickerStyles = useAnimatedStyle(() => {
const resizeScale = (radius.value * 2) / Math.SQRT2 / SIZE;
return {
width: SIZE,
height: SIZE,
transform: [
{ rotate: `${rotation.value}rad` },
{ scale: resizeScale },
{ scale: scale.value },
],
};
}, [rotation, radius, scale]);
const ringStyles = useAnimatedStyle(
() => ({
width: radius.value * 2,
height: radius.value * 2,
borderRadius: radius.value,
transform: [{ rotate: `${rotation.value}rad` }],
}),
[rotation, radius],
);
const ringContainerStyles = useAnimatedStyle(
() => ({
opacity: ringOpacity.value,
transform: [{ scale: ringScale.value }],
}),
[ringOpacity, ringScale],
);
const leftIndicatorStyles = useAnimatedStyle(() => {
const angle = rotation.value + Math.PI;
const translateX = radius.value * Math.cos(angle);
const translateY = radius.value * Math.sin(angle);
return { transform: [{ translateX }, { translateY }] };
}, [rotation, radius]);
const rightIndicatorStyles = useAnimatedStyle(() => {
const translateX = radius.value * Math.cos(rotation.value);
const translateY = radius.value * Math.sin(rotation.value);
return { transform: [{ translateX }, { translateY }] };
}, [rotation, radius]);
const composedGesture = Gesture.Race(stickerPan, stickerPinch, stickerTap);
return (
<View style={[styles.root, styles.center]}>
<GestureDetector gesture={composedGesture}>
<Animated.View
style={[
styles.stickerContainer,
styles.center,
stickerContainerStyles,
]}
>
<Animated.View
style={[styles.ringContainer, styles.center, ringContainerStyles]}
>
<Animated.View style={[styles.ring, ringStyles]} />
<GestureDetector gesture={leftPan}>
<Animated.View
style={[styles.panIndicator, leftIndicatorStyles]}
/>
</GestureDetector>
<GestureDetector gesture={rightPan}>
<Animated.View
style={[styles.panIndicator, rightIndicatorStyles]}
/>
</GestureDetector>
</Animated.View>
<Animated.Image
ref={stickerRef}
source={source}
resizeMethod={"scale"}
style={stickerStyles}
/>
</Animated.View>
</GestureDetector>
</View>
);
};
const styles = StyleSheet.create({
root: {
flex: 1,
backgroundColor: "#151515",
},
center: {
justifyContent: "center",
alignItems: "center",
},
stickerContainer: {
width: SIZE,
height: SIZE,
},
ringContainer: {
width: RING_SIZE,
height: RING_SIZE,
position: "absolute",
},
ring: {
borderWidth: 3,
borderColor: "#fff",
borderStyle: "dashed",
position: "absolute",
},
panIndicator: {
width: INDICATOR_SIZE,
height: INDICATOR_SIZE,
borderWidth: 3,
borderColor: "#fff",
borderRadius: INDICATOR_SIZE / 2,
backgroundColor: "#3366ff",
position: "absolute",
},
});
export default Sticker;
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment