Skip to content

Commit

Permalink
feat: added PI Confetti
Browse files Browse the repository at this point in the history
  • Loading branch information
AlirezaHadjar committed Nov 3, 2024
1 parent e46d88e commit a57b539
Show file tree
Hide file tree
Showing 5 changed files with 369 additions and 2 deletions.
324 changes: 324 additions & 0 deletions src/PIConfetti.tsx
Original file line number Diff line number Diff line change
@@ -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<ConfettiMethods, PIConfettiProps>(
(
{
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(
<Group>
{boxes.map((box, index) => {
const { x, y } = getInitialPosition(index);

return (
<Rect
key={index}
rect={rect(x, y, flakeSize.width, flakeSize.height)}
color={box.color}
/>
);
})}
</Group>,
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 (
<View pointerEvents="none" style={styles.container}>
<Canvas style={styles.canvasContainer}>
<Atlas
image={texture}
sprites={sprites}
transforms={transforms}
opacity={opacity}
/>
</Canvas>
</View>
);
}
);

const styles = StyleSheet.create({
container: {
height: '100%',
width: '100%',
position: 'absolute',
zIndex: 1,
},
canvasContainer: {
width: '100%',
height: '100%',
},
});
2 changes: 2 additions & 0 deletions src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
1 change: 1 addition & 0 deletions src/index.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export type { ConfettiMethods, ConfettiProps } from './types';

export { Confetti } from './Confetti';
export { PIConfetti } from './PIConfetti';
20 changes: 18 additions & 2 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ export type FlakeSize = {
height: number;
};

export type CannonPosition = {
export type Position = {
x: number;
y: number;
};
Expand Down Expand Up @@ -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 = {
Expand Down
Loading

0 comments on commit a57b539

Please sign in to comment.