Created
August 11, 2024 23:53
-
-
Save Glazzes/3249c28b8c33e300df0a53273db7dc78 to your computer and use it in GitHub Desktop.
React Native Sticker (For Photo Editor Apps)
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* @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