react-native-confetti-cannon
Version:
React Native confetti explosion and fall like iOS does.
240 lines (207 loc) • 6.49 kB
JavaScript
// @flow
import * as React from 'react';
import { Animated, Dimensions, Easing, Platform } from 'react-native';
import type { CompositeAnimation } from 'react-native/Libraries/Animated/src/AnimatedImplementation';
import type { EndResult } from 'react-native/Libraries/Animated/src/animations/Animation';
import Confetti from './components/confetti';
import { randomValue, randomColor } from './utils';
type Props = {|
count: number,
origin: {
x: number,
y: number
},
explosionSpeed?: number,
fallSpeed?: number,
colors?: Array<string>,
fadeOut?: boolean,
autoStart?: boolean,
autoStartDelay?: number,
onAnimationStart?: () => void,
onAnimationResume?: () => void,
onAnimationStop?: () => void,
onAnimationEnd?: () => void,
testID?: string
|};
type Item = {|
leftDelta: number,
topDelta: number,
swingDelta: number,
speedDelta: {
rotateX: number,
rotateY: number,
rotateZ: number
},
color: string
|};
type State = {|
items: Array<Item>
|};
export const TOP_MIN = 0.7;
export const DEFAULT_COLORS: Array<string> =[
'#e67e22',
'#2ecc71',
'#3498db',
'#84AAC2',
'#E6D68D',
'#F67933',
'#42A858',
'#4F50A2',
'#A86BB7',
'#e74c3c',
'#1abc9c'
];
export const DEFAULT_EXPLOSION_SPEED = 350;
export const DEFAULT_FALL_SPEED = 3000;
class Explosion extends React.PureComponent<Props, State> {
props: Props;
state: State = {
items: []
};
start: () => void;
resume: () => void;
stop: () => void;
sequence: CompositeAnimation | null;
items: Array<Item> = [];
animation: Animated.Value = new Animated.Value(0);
constructor(props: Props) {
super(props);
const { colors = DEFAULT_COLORS } = props;
this.start = this.start.bind(this);
this.resume = this.resume.bind(this);
this.stop = this.stop.bind(this);
this.state.items = this.getItems(colors);
}
componentDidMount = () => {
const { autoStart = true, autoStartDelay = 0 } = this.props;
if (autoStart) {
setTimeout(this.start, autoStartDelay);
}
};
componentDidUpdate = ({ count: prevCount, colors: prevColors = DEFAULT_COLORS }: Props) => {
const { count, colors = DEFAULT_COLORS } = this.props;
if (count !== prevCount || colors !== prevColors) {
this.setState({
items: this.getItems(prevColors)
});
}
};
getItems = (prevColors: Array<string>): Array<Item> => {
const { count, colors = DEFAULT_COLORS } = this.props;
const { items } = this.state;
const difference = items.length < count ? count - items.length : 0;
const newItems = Array(difference).fill().map((): Item => ({
leftDelta: randomValue(0, 1),
topDelta: randomValue(TOP_MIN, 1),
swingDelta: randomValue(0.2, 1),
speedDelta: {
rotateX: randomValue(0.3, 1),
rotateY: randomValue(0.3, 1),
rotateZ: randomValue(0.3, 1)
},
color: randomColor(colors)
}));
return items
.slice(0, count)
.concat(newItems)
.map(item => ({
...item,
color: prevColors !== colors ? randomColor(colors) : item.color
}));
};
start = (resume?: boolean = false) => {
const {
explosionSpeed = DEFAULT_EXPLOSION_SPEED,
fallSpeed = DEFAULT_FALL_SPEED,
onAnimationStart,
onAnimationResume,
onAnimationEnd
} = this.props;
if (resume) {
onAnimationResume && onAnimationResume();
} else {
this.sequence = Animated.sequence([
Animated.timing(this.animation, {toValue: 0, duration: 0, useNativeDriver: true}),
Animated.timing(this.animation, {
toValue: 1,
duration: explosionSpeed,
easing: Easing.out(Easing.quad),
useNativeDriver: true
}),
Animated.timing(this.animation, {
toValue: 2,
duration: fallSpeed,
easing: Easing.quad,
useNativeDriver: true
}),
]);
onAnimationStart && onAnimationStart();
}
this.sequence && this.sequence.start(({finished}: EndResult) => {
if (finished) {
onAnimationEnd && onAnimationEnd();
}
});
};
resume = () => this.start(true);
stop = () => {
const { onAnimationStop } = this.props;
onAnimationStop && onAnimationStop();
this.sequence && this.sequence.stop();
};
render() {
const { origin, fadeOut } = this.props;
const { items } = this.state;
const { height, width } = Dimensions.get('window');
return (
<React.Fragment>
{items.map((item: Item, index: number) => {
const left = this.animation.interpolate({
inputRange: [0, 1, 2],
outputRange: [origin.x, item.leftDelta * width, item.leftDelta * width]
});
const top = this.animation.interpolate({
inputRange: [0, 1, 1 + item.topDelta, 2],
outputRange: [-origin.y, -item.topDelta * height, 0, 0]
});
const rotateX = this.animation.interpolate({
inputRange: [0, 2],
outputRange: ['0deg', `${item.speedDelta.rotateX * 360 * 10}deg`]
});
const rotateY = this.animation.interpolate({
inputRange: [0, 2],
outputRange: ['0deg', `${item.speedDelta.rotateY * 360 * 5}deg`]
});
const rotateZ = this.animation.interpolate({
inputRange: [0, 2],
outputRange: ['0deg', `${item.speedDelta.rotateZ * 360 * 2}deg`]
});
const translateX = this.animation.interpolate({
inputRange: [0, 0.4, 1.2, 2],
outputRange: [0, -(item.swingDelta * 30), (item.swingDelta * 30), 0]
});
const opacity = this.animation.interpolate({
inputRange: [0, 1, 1.8, 2],
outputRange: [1, 1, 1, fadeOut ? 0 : 1]
});
const containerTransform = [{translateX: left}, {translateY: top}];
const transform = [{rotateX}, {rotateY}, {rotate: rotateZ}, {translateX}];
if (Platform.OS === 'android') {
transform.push({ perspective: 100 });
}
return (
<Confetti
color={item.color}
containerTransform={containerTransform}
transform={transform}
opacity={opacity}
key={index}
testID={`confetti-${index + 1}`}
/>
);
})}
</React.Fragment>
);
}
}
export default Explosion;