From a57b5398aca640fdda592a4917d6b6f122318b1b Mon Sep 17 00:00:00 2001 From: Alireza Hadjar Date: Sun, 3 Nov 2024 22:57:17 +0300 Subject: [PATCH] feat: added PI Confetti --- src/PIConfetti.tsx | 324 +++++++++++++++++++++++++++++++++++++++++++++ src/constants.ts | 2 + src/index.tsx | 1 + src/types.ts | 20 ++- src/utils.ts | 24 ++++ 5 files changed, 369 insertions(+), 2 deletions(-) create mode 100644 src/PIConfetti.tsx diff --git a/src/PIConfetti.tsx b/src/PIConfetti.tsx new file mode 100644 index 0000000..8b26907 --- /dev/null +++ b/src/PIConfetti.tsx @@ -0,0 +1,324 @@ +import { + useTexture, + Group, + Rect, + rect, + useRSXformBuffer, + Canvas, + Atlas, +} from '@shopify/react-native-skia'; +import { forwardRef, useCallback, useImperativeHandle, useState } from 'react'; +import { StyleSheet, useWindowDimensions, View } from 'react-native'; +import { + cancelAnimation, + Extrapolation, + interpolate, + runOnJS, + useDerivedValue, + useSharedValue, + withTiming, +} from 'react-native-reanimated'; +import { generatePIBoxesArray } from './utils'; +import { + DEFAULT_BLAST_DURATION, + DEFAULT_BLAST_RADIUS, + DEFAULT_BOXES_COUNT, + DEFAULT_COLORS, + DEFAULT_FALL_DURATION, + DEFAULT_FLAKE_SIZE, +} from './constants'; +import type { ConfettiMethods, PIConfettiProps } from './types'; + +export const PIConfetti = forwardRef( + ( + { + count = DEFAULT_BOXES_COUNT, + flakeSize = DEFAULT_FLAKE_SIZE, + fallDuration = DEFAULT_FALL_DURATION, + blastDuration = DEFAULT_BLAST_DURATION, + colors = DEFAULT_COLORS, + blastPosition: _blastPosition, + onAnimationEnd, + onAnimationStart, + width: _width, + height: _height, + blastRadius = DEFAULT_BLAST_RADIUS, + fadeOutOnEnd = false, + }, + ref + ) => { + const blastProgress = useSharedValue(0); + const fallProgress = useSharedValue(0); + const opacity = useDerivedValue(() => { + if (!fadeOutOnEnd) return 1; + return interpolate( + fallProgress.value, + [0, 1], + [1, 0], + Extrapolation.CLAMP + ); + }, [fadeOutOnEnd]); + const running = useSharedValue(false); + + const { width: DEFAULT_SCREEN_WIDTH, height: DEFAULT_SCREEN_HEIGHT } = + useWindowDimensions(); + const containerWidth = _width || DEFAULT_SCREEN_WIDTH; + const containerHeight = _height || DEFAULT_SCREEN_HEIGHT; + const blastPosition = _blastPosition || { x: containerWidth / 2, y: 150 }; + + const columnsNum = Math.floor(containerWidth / flakeSize.width); + const rowsNum = Math.ceil(count / columnsNum); + const rowHeight = flakeSize.height + 0; + const columnWidth = flakeSize.width; + + const textureSize = { + width: columnWidth * columnsNum, + height: rowHeight * rowsNum, + }; + const [boxes, setBoxes] = useState(() => + generatePIBoxesArray(count, colors) + ); + + const pause = () => { + running.value = false; + cancelAnimation(blastProgress); + cancelAnimation(fallProgress); + }; + + const reset = () => { + pause(); + + blastProgress.value = 0; + fallProgress.value = 0; + }; + + const refreshBoxes = useCallback(() => { + 'worklet'; + + const newBoxes = generatePIBoxesArray(count, colors); + runOnJS(setBoxes)(newBoxes); + }, [count, colors]); + + const JSOnStart = () => onAnimationStart?.(); + const JSOnEnd = () => onAnimationEnd?.(); + + const runBlastAnimation = ({ + blastDuration: _blastDuration, + fallDuration: _fallDuration, + }: { + blastDuration: number; + fallDuration: number; + }) => { + 'worklet'; + if (_blastDuration > 0) + blastProgress.value = withTiming(1, { duration: _blastDuration }); + else blastProgress.value = 1; + if (_fallDuration > 0) + fallProgress.value = withTiming(1, { duration: _fallDuration }, () => { + runOnJS(JSOnEnd)(); + }); + else fallProgress.value = 1; + }; + + const restart = () => { + refreshBoxes(); + running.value = true; + + reset(); + JSOnStart(); + runBlastAnimation({ blastDuration, fallDuration }); + }; + + const resume = () => { + if (running.value) return; + running.value = true; + const blastTimeLeft = (1 - blastProgress.value) * blastDuration; + const fallTimeLeft = (1 - fallProgress.value) * fallDuration; + runBlastAnimation({ + blastDuration: blastTimeLeft, + fallDuration: fallTimeLeft, + }); + }; + + useImperativeHandle(ref, () => ({ + pause, + reset, + resume, + restart, + })); + + const getInitialPosition = (index: number) => { + 'worklet'; + const x = (index % columnsNum) * flakeSize.width; + const y = Math.floor(index / columnsNum) * rowHeight; + + return { x, y }; + }; + + const getPosition = (index: number) => { + 'worklet'; + const centerX = blastPosition.x; // Horizontal center of the container + const centerY = blastPosition.y; // Vertical center of the container + const maxRadius = blastRadius; // Maximum radius for the circle + + // Generate a pseudo-random radius and angle based on index + const radius = Math.sqrt((index + 1) / count) * maxRadius; + const angle = ((index * 137.5) % 360) * (Math.PI / 180); // Using golden angle for uniform distribution + + // Calculate x and y based on radius and angle + const x = centerX + radius * Math.cos(angle); + const y = centerY + radius * Math.sin(angle); + + return { x, y }; + }; + + const texture = useTexture( + + {boxes.map((box, index) => { + const { x, y } = getInitialPosition(index); + + return ( + + ); + })} + , + textureSize + ); + + const sprites = boxes.map((_, index) => { + const { x, y } = getInitialPosition(index); + return rect(x, y, flakeSize.width, flakeSize.height); + }); + + const transforms = useRSXformBuffer(count, (val, i) => { + 'worklet'; + const piece = boxes[i]; + if (!piece) return; + + const { x, y } = getPosition(i); + + let tx = x; + let ty = y; + + const diffX = x - blastPosition.x; + const diffY = y - blastPosition.y; + + const delayedBlastProgress = interpolate( + blastProgress.value, + [piece.delayBlast, 1], + [0, 1], + Extrapolation.IDENTITY + ); + + tx += -diffX * (1 - delayedBlastProgress); + ty += -diffY * (1 - delayedBlastProgress); + + const fallDistance = interpolate( + fallProgress.value, + [0, 1], + [0, containerHeight - blastPosition.y + blastRadius] + ); + + const spreadDistanceX = interpolate( + fallProgress.value, + [0, 1], + [0, piece.randomOffsetX] + ); + const spreadDistanceY = interpolate( + fallProgress.value, + [0, 1], + [0, piece.randomOffsetY] + ); + + tx += spreadDistanceX; + ty += fallDistance + spreadDistanceY; + + // Interpolate between randomX values for smooth left-right movement + const jigglingStartPos = 0.1; + const randomX = interpolate( + fallProgress.value, + [ + 0, + jigglingStartPos, + ...new Array(piece.randomXs.length) + .fill(0) + .map( + (_, index) => + jigglingStartPos + + ((index + 1) * (1 - jigglingStartPos)) / piece.randomXs.length + ), + ], + [0, 0, ...piece.randomXs] // Use the randomX array for horizontal movement + ); + + tx += randomX; + + const rotationDirection = piece.clockwise ? 1 : -1; + const rz = + piece.initialRotation + + interpolate( + fallProgress.value, + [0, 1], + [0, rotationDirection * piece.maxRotation.z], + Extrapolation.CLAMP + ); + const rx = + piece.initialRotation + + interpolate( + fallProgress.value, + [0, 1], + [0, rotationDirection * piece.maxRotation.x], + Extrapolation.CLAMP + ); + + const oscillatingScale = Math.abs(Math.cos(rx)); // Scale goes from 1 -> 0 -> 1 + const blastScale = interpolate( + blastProgress.value, + [0, 0.2, 1], + [0, 1, 1], + Extrapolation.CLAMP + ); + const scale = blastScale * oscillatingScale; + + const px = flakeSize.width / 2; + const py = flakeSize.height / 2; + + // Apply the transformation, including the flipping effect and randomX oscillation + const s = Math.sin(rz) * scale; + const c = Math.cos(rz) * scale; + + // Use the interpolated randomX for horizontal oscillation + val.set(c, s, tx - c * px + s * py, ty - s * px - c * py); + }); + + return ( + + + + + + ); + } +); + +const styles = StyleSheet.create({ + container: { + height: '100%', + width: '100%', + position: 'absolute', + zIndex: 1, + }, + canvasContainer: { + width: '100%', + height: '100%', + }, +}); diff --git a/src/constants.ts b/src/constants.ts index 35250df..48fa1c3 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -24,3 +24,5 @@ export const DEFAULT_AUTOSTART_DELAY = 0; export const DEFAULT_VERTICAL_SPACING = 30; export const RANDOM_INITIAL_Y_JIGGLE = 20; + +export const DEFAULT_BLAST_RADIUS = 180; diff --git a/src/index.tsx b/src/index.tsx index ae74669..eba75ad 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,3 +1,4 @@ export type { ConfettiMethods, ConfettiProps } from './types'; export { Confetti } from './Confetti'; +export { PIConfetti } from './PIConfetti'; diff --git a/src/types.ts b/src/types.ts index 5a81414..624db16 100644 --- a/src/types.ts +++ b/src/types.ts @@ -3,7 +3,7 @@ export type FlakeSize = { height: number; }; -export type CannonPosition = { +export type Position = { x: number; y: number; }; @@ -72,7 +72,23 @@ export type ConfettiProps = { /** * @description An array of positions from which confetti flakes should blast. */ - cannonsPositions?: CannonPosition[]; + cannonsPositions?: Position[]; +}; + +export type PIConfettiProps = Omit< + ConfettiProps, + 'autoPlay' | 'verticalSpacing' | 'autoStartDelay' | 'cannonsPositions' +> & { + /** + * @description The position from which confetti flakes should blast. + * @default { x: containerWidth / 2, y: 150 } + */ + blastPosition?: Position; + /** + * @description The radius of the blast. + * @default 180 + */ + blastRadius?: number; }; export type ConfettiMethods = { diff --git a/src/utils.ts b/src/utils.ts index 605de88..f6731df 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,3 +1,4 @@ +import { vec } from '@shopify/react-native-skia'; import { RANDOM_INITIAL_Y_JIGGLE } from './constants'; export const getRandomBoolean = () => { @@ -40,3 +41,26 @@ export const generateBoxesArray = (count: number, colors: string[]) => { randomOffsetY: getRandomValue(-10, 10), // Random Y offset for initial position })); }; + +export const generatePIBoxesArray = (count: number, colors: string[]) => { + 'worklet'; + return new Array(count).fill(0).map(() => ({ + clockwise: getRandomBoolean(), + maxRotation: { + x: getRandomValue(1 * Math.PI, 3 * Math.PI), + z: getRandomValue(1 * Math.PI, 3 * Math.PI), + }, + color: randomColor(colors), + randomXs: randomXArray(6, -5, 5), // Array of randomX values for horizontal movement + initialRandomY: getRandomValue( + -RANDOM_INITIAL_Y_JIGGLE, + RANDOM_INITIAL_Y_JIGGLE + ), + initialRotation: getRandomValue(0.1 * Math.PI, Math.PI), + randomSpeed: getRandomValue(0.9, 1.3), // Random speed multiplier + randomOffsetX: getRandomValue(-50, 50), // Random X offset for initial position + randomOffsetY: getRandomValue(0, 150), // Random X offset for initial position + delayBlast: getRandomValue(0, 0.6), // Random velocity multiplier + randomAcceleration: vec(getRandomValue(0.1, 0.3), getRandomValue(0.1, 0.3)), // Random acceleration multiplier + })); +};