rn-toastify
Version:
A professional, production-ready toast notification library for React Native. Featuring smooth spring animations, swipe-to-dismiss gestures, progress bars, queue management, and a beautiful design system with light/dark themes.
195 lines (177 loc) • 6.8 kB
JavaScript
import React, { useEffect, useMemo, useCallback, useRef } from 'react';
import { PanResponder, StyleSheet } from 'react-native';
import Animated, {
useSharedValue,
useAnimatedStyle,
withTiming,
withSpring,
withDelay,
runOnJS,
Easing,
interpolate,
cancelAnimation,
} from 'react-native-reanimated';
const SPRING_CONFIG = {
damping: 18,
stiffness: 160,
mass: 0.8,
};
const Toast = ({ visible, duration, position, children, onHide, style, theme }) => {
const opacity = useSharedValue(0);
const translateY = useSharedValue(position === 'top' ? -80 : position === 'center' ? 30 : 80);
const translateX = useSharedValue(0);
const scale = useSharedValue(0.85);
const hideCalledRef = useRef(false);
const positionStyle = useMemo(
() => {
if (position === 'top') return styles.top;
if (position === 'center') return styles.center;
return styles.bottom;
},
[position]
);
const safeOnHide = useCallback(() => {
if (!hideCalledRef.current) {
hideCalledRef.current = true;
onHide?.();
}
}, [onHide]);
useEffect(() => {
hideCalledRef.current = false;
if (visible) {
// Entrance animation — smooth spring with overshoot
opacity.value = withTiming(1, { duration: 280, easing: Easing.out(Easing.cubic) });
translateY.value = withSpring(0, SPRING_CONFIG);
scale.value = withSpring(1, { ...SPRING_CONFIG, damping: 14 });
translateX.value = 0;
// Auto-dismiss timer
if (duration && duration !== Infinity) {
const hideTimeout = setTimeout(() => {
hideToast();
}, duration);
return () => clearTimeout(hideTimeout);
}
} else {
hideToast();
}
}, [visible, duration]);
const hideToast = useCallback(() => {
const exitY = position === 'top' ? -60 : position === 'center' ? 20 : 60;
opacity.value = withTiming(0, {
duration: 250,
easing: Easing.in(Easing.cubic),
});
translateY.value = withTiming(exitY, {
duration: 250,
easing: Easing.in(Easing.cubic),
});
scale.value = withTiming(0.9, {
duration: 250,
easing: Easing.in(Easing.cubic),
}, (finished) => {
// Only fire onHide after animation actually completes
if (finished) {
runOnJS(safeOnHide)();
}
});
}, [position, safeOnHide]);
const animatedStyle = useAnimatedStyle(() => ({
opacity: opacity.value,
transform: [
{ translateY: translateY.value },
{ translateX: translateX.value },
{ scale: scale.value },
],
}));
const panResponder = useMemo(
() =>
PanResponder.create({
onMoveShouldSetPanResponder: (evt, gestureState) => {
// More responsive swipe detection
return Math.abs(gestureState.dx) > 15 || Math.abs(gestureState.dy) > 15;
},
onPanResponderMove: (evt, gestureState) => {
translateX.value = gestureState.dx;
// Add subtle vertical follow for natural feel
if (position === 'top' && gestureState.dy < 0) {
translateY.value = gestureState.dy * 0.3;
} else if (position === 'bottom' && gestureState.dy > 0) {
translateY.value = gestureState.dy * 0.3;
}
// Fade as user swipes further
opacity.value = interpolate(
Math.abs(gestureState.dx),
[0, 150],
[1, 0.3],
'clamp'
);
},
onPanResponderRelease: (evt, gestureState) => {
if (Math.abs(gestureState.dx) > 80) {
// Dismiss: fly out in swipe direction
const direction = gestureState.dx > 0 ? 1 : -1;
translateX.value = withTiming(direction * 400, { duration: 200 });
opacity.value = withTiming(0, { duration: 200 });
scale.value = withTiming(0.85, { duration: 200 }, (finished) => {
if (finished) {
runOnJS(safeOnHide)();
}
});
} else if (position === 'top' && gestureState.dy < -40) {
// Swipe up to dismiss (for top toasts)
translateY.value = withTiming(-100, { duration: 200 });
opacity.value = withTiming(0, { duration: 200 }, (finished) => {
if (finished) runOnJS(safeOnHide)();
});
} else if (position === 'bottom' && gestureState.dy > 40) {
// Swipe down to dismiss (for bottom toasts)
translateY.value = withTiming(100, { duration: 200 });
opacity.value = withTiming(0, { duration: 200 }, (finished) => {
if (finished) runOnJS(safeOnHide)();
});
} else {
// Snap back with spring
translateX.value = withSpring(0, { damping: 15, stiffness: 200 });
translateY.value = withSpring(0, { damping: 15, stiffness: 200 });
opacity.value = withSpring(1);
}
},
}),
[position, safeOnHide]
);
if (!visible) return null;
// If children is a React element, inject theme and duration props
const renderContent = () => {
if (React.isValidElement(children)) {
return React.cloneElement(children, { theme, duration });
}
return children;
};
return (
<Animated.View
{...panResponder.panHandlers}
style={[styles.container, animatedStyle, positionStyle, style]}
>
{renderContent()}
</Animated.View>
);
};
const styles = StyleSheet.create({
container: {
position: 'absolute',
left: 0,
right: 0,
alignItems: 'center',
zIndex: 9999,
},
top: {
top: 0,
},
center: {
// Center positioning handled by container
},
bottom: {
bottom: 20,
},
});
export default React.memo(Toast);