react-native-gradient-wrapper
Version:
A flexible React Native gradient wrapper component with animated border and background gradients.
376 lines (370 loc) • 16.7 kB
JavaScript
import React, { useEffect, useRef, useState } from 'react';
import { View, Animated, Pressable, useColorScheme, StyleSheet, Easing, } from 'react-native';
import LinearGradient from 'react-native-linear-gradient';
const defaultLightBackground = ['#ffffff', '#f2f2f2'];
const defaultDarkBackground = ['#2c3e50', '#1a1a1a'];
const defaultLightBorder = ['#ccc', '#eee'];
const defaultDarkBorder = ['#4b6cb7', '#182848'];
const GradientWrapper = ({ children, style, contentStyle, backgroundGradient, borderGradient, borderTopGradient, borderBottomGradient, borderLeftGradient, borderRightGradient, borderWidth = 2, borderRadius = 10, backgroundRotation = 'none', borderRotation = 'none', backgroundRotationSpeed = 4000, borderRotationSpeed = 3000, stopBackgroundAfter, stopBorderAfter, onPress, onLongPress, enableFeedback = true, angle, borderLocations, backgroundLocations, theme = 'auto', animated = false, }) => {
const systemTheme = useColorScheme();
const isDark = theme === 'dark' || (theme === 'auto' && systemTheme === 'dark');
const rotateBgAnim = useRef(new Animated.Value(0)).current;
const rotateBorderAnim = useRef(new Animated.Value(0)).current;
const scaleAnim = useRef(new Animated.Value(1)).current;
const [borderRotationStopped, setBorderRotationStopped] = useState(false);
const [animatedColorValue] = useState(new Animated.Value(0));
const [animatedGradient, setAnimatedGradient] = useState([]);
const buildRgbString = (r, g, b) => `rgb(${Math.round(r)},${Math.round(g)},${Math.round(b)})`;
const spinBg = rotateBgAnim.interpolate({
inputRange: [0, 1],
outputRange: backgroundRotation === 'anticlockwise' ? ['360deg', '0deg'] : ['0deg', '360deg'],
});
const spinBorder = rotateBorderAnim.interpolate({
inputRange: [0, 1],
outputRange: borderRotation === 'anticlockwise' ? ['360deg', '0deg'] : ['0deg', '360deg'],
});
const parseColorToRgb = (color) => {
color = color.trim();
// HEX: #RGB or #RRGGBB
if (color.startsWith('#')) {
let cleaned = color.slice(1);
if (cleaned.length === 3) {
cleaned = cleaned.split('').map(c => c + c).join('');
}
if (cleaned.length !== 6)
throw new Error(`Invalid hex color: ${color}`);
return {
r: parseInt(cleaned.slice(0, 2), 16),
g: parseInt(cleaned.slice(2, 4), 16),
b: parseInt(cleaned.slice(4, 6), 16),
};
}
// RGB or RGBA
const rgbMatch = color.match(/^rgba?\(([^)]+)\)$/);
if (rgbMatch) {
const [r, g, b] = rgbMatch[1]
.split(',')
.slice(0, 3)
.map(v => parseInt(v.trim(), 10));
if ([r, g, b].some(n => isNaN(n)))
throw new Error(`Invalid RGB values: ${color}`);
return { r, g, b };
}
throw new Error(`Unsupported color format: ${color}`);
};
useEffect(() => {
if (!animated || !backgroundGradient || backgroundGradient.length < 2)
return;
const speed = backgroundRotationSpeed || 500;
const extendedColors = [...backgroundGradient, backgroundGradient[0]];
const shiftedColors = [...backgroundGradient.slice(1), backgroundGradient[0], backgroundGradient[1]];
const steps = extendedColors.length;
animatedColorValue.setValue(0);
const loop = Animated.loop(Animated.timing(animatedColorValue, {
toValue: steps - 1,
duration: (steps - 0.2) * speed,
easing: Easing.inOut(Easing.ease),
useNativeDriver: false,
}));
loop.start();
const rgbList = extendedColors.map(parseColorToRgb);
const rgbShifted = shiftedColors.map(parseColorToRgb);
const inputRange = rgbList.map((_, i) => i);
const r1 = animatedColorValue.interpolate({
inputRange,
outputRange: rgbList.map(c => c.r),
});
const g1 = animatedColorValue.interpolate({
inputRange,
outputRange: rgbList.map(c => c.g),
});
const b1 = animatedColorValue.interpolate({
inputRange,
outputRange: rgbList.map(c => c.b),
});
const r2 = animatedColorValue.interpolate({
inputRange,
outputRange: rgbShifted.map(c => c.r),
});
const g2 = animatedColorValue.interpolate({
inputRange,
outputRange: rgbShifted.map(c => c.g),
});
const b2 = animatedColorValue.interpolate({
inputRange,
outputRange: rgbShifted.map(c => c.b),
});
const listenerId = animatedColorValue.addListener(({ value }) => {
const index = Math.floor(value);
const fraction = value - index;
const nextIndex = (index + 1) % rgbList.length;
const r = rgbList[index].r + (rgbList[nextIndex].r - rgbList[index].r) * fraction;
const g = rgbList[index].g + (rgbList[nextIndex].g - rgbList[index].g) * fraction;
const b = rgbList[index].b + (rgbList[nextIndex].b - rgbList[index].b) * fraction;
const r2 = rgbList[nextIndex].r + (rgbList[(nextIndex + 1) % rgbList.length].r - rgbList[nextIndex].r) * fraction;
const g2 = rgbList[nextIndex].g + (rgbList[(nextIndex + 1) % rgbList.length].g - rgbList[nextIndex].g) * fraction;
const b2 = rgbList[nextIndex].b + (rgbList[(nextIndex + 1) % rgbList.length].b - rgbList[nextIndex].b) * fraction;
setAnimatedGradient([
buildRgbString(r, g, b),
buildRgbString(r2, g2, b2),
]);
});
return () => {
loop.stop();
animatedColorValue.removeListener(listenerId);
};
}, [animated, backgroundGradient, backgroundRotationSpeed]);
useEffect(() => {
if (backgroundRotation !== 'none') {
Animated.loop(Animated.sequence([
Animated.timing(rotateBgAnim, {
toValue: 1,
duration: backgroundRotationSpeed,
easing: Easing.inOut(Easing.ease),
useNativeDriver: true,
}),
Animated.timing(rotateBgAnim, {
toValue: 0,
duration: backgroundRotationSpeed,
easing: Easing.inOut(Easing.ease),
useNativeDriver: true,
}),
])).start();
if (stopBackgroundAfter) {
setTimeout(() => {
rotateBgAnim.stopAnimation();
}, stopBackgroundAfter);
}
}
if (borderRotation !== 'none') {
let running = true;
const animateLoop = () => {
if (!running)
return;
Animated.sequence([
Animated.timing(rotateBorderAnim, {
toValue: 1,
duration: borderRotationSpeed,
easing: Easing.inOut(Easing.ease),
useNativeDriver: true,
}),
Animated.timing(rotateBorderAnim, {
toValue: 0,
duration: borderRotationSpeed,
easing: Easing.inOut(Easing.ease),
useNativeDriver: true,
}),
]).start(() => {
animateLoop();
});
};
animateLoop();
if (stopBorderAfter) {
setTimeout(() => {
running = false;
Animated.timing(rotateBorderAnim, {
toValue: 0,
duration: 800,
easing: Easing.inOut(Easing.ease),
useNativeDriver: true,
}).start(() => {
setBorderRotationStopped(true);
});
}, stopBorderAfter);
}
}
}, []);
const handlePressIn = () => {
if (enableFeedback) {
Animated.spring(scaleAnim, {
toValue: 0.96,
useNativeDriver: true,
}).start();
}
};
const handlePressOut = () => {
if (enableFeedback) {
Animated.spring(scaleAnim, {
toValue: 1,
useNativeDriver: true,
}).start();
}
};
const appliedBackground = backgroundGradient !== null && backgroundGradient !== void 0 ? backgroundGradient : (isDark ? defaultDarkBackground : defaultLightBackground);
const shouldUseIndividualBorders = () => {
if (borderGradient)
return false;
const individualBorderCount = [
borderTopGradient,
borderBottomGradient,
borderLeftGradient,
borderRightGradient
].filter(Boolean).length;
if (individualBorderCount <= 2)
return true;
const hasVertical = borderTopGradient || borderBottomGradient;
const hasHorizontal = borderLeftGradient || borderRightGradient;
return !(hasVertical && hasHorizontal);
};
const getFullBorderGradient = () => {
if (borderGradient && borderGradient.length > 0) {
return {
colors: borderGradient,
locations: borderLocations && borderLocations.length === borderGradient.length
? borderLocations
: borderGradient.map((_, index) => index / (borderGradient.length - 1)),
};
}
if (!borderTopGradient && !borderBottomGradient && !borderLeftGradient && !borderRightGradient) {
return {
colors: isDark ? defaultDarkBorder : defaultLightBorder,
locations: [0, 1],
};
}
const gradients = [];
// Right (optional)
if (borderRightGradient) {
gradients.push(...borderRightGradient);
}
// Bottom (100%)
if (borderBottomGradient) {
gradients.push(...borderBottomGradient);
}
// Left (optional)
if (borderLeftGradient) {
gradients.push(...borderLeftGradient);
}
// Top (100%)
if (borderTopGradient) {
gradients.push(...borderTopGradient);
}
const total = gradients.length;
const locations = gradients.map((_, index) => index / (total - 1));
return {
colors: gradients,
locations,
};
};
const borderStyles = StyleSheet.create({
top: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: borderWidth,
borderTopLeftRadius: borderRadius,
borderTopRightRadius: borderRadius,
},
bottom: {
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
height: borderWidth,
borderBottomLeftRadius: borderRadius,
borderBottomRightRadius: borderRadius,
},
left: {
position: 'absolute',
top: 0,
bottom: 0,
left: 0,
width: borderWidth,
borderTopLeftRadius: borderRadius,
borderBottomLeftRadius: borderRadius,
},
right: {
position: 'absolute',
top: 0,
bottom: 0,
right: 0,
width: borderWidth,
borderTopRightRadius: borderRadius,
borderBottomRightRadius: borderRadius,
},
});
const useIndividualBorders = shouldUseIndividualBorders();
const fullBorderGradientResult = getFullBorderGradient();
const fullBorderGradient = typeof fullBorderGradientResult === 'object' ? fullBorderGradientResult.colors : fullBorderGradientResult;
const calculatedBorderLocations = typeof fullBorderGradientResult === 'object' ? fullBorderGradientResult.locations : borderLocations;
const appliedTop = borderTopGradient !== null && borderTopGradient !== void 0 ? borderTopGradient : fullBorderGradient;
const appliedBottom = borderBottomGradient !== null && borderBottomGradient !== void 0 ? borderBottomGradient : fullBorderGradient;
const appliedLeft = borderLeftGradient !== null && borderLeftGradient !== void 0 ? borderLeftGradient : fullBorderGradient;
const appliedRight = borderRightGradient !== null && borderRightGradient !== void 0 ? borderRightGradient : fullBorderGradient;
const angleToDirection = (angle) => {
const radians = (angle * Math.PI) / 180;
const x = Math.cos(radians);
const y = Math.sin(radians);
return {
start: { x: 0.5 - x / 2, y: 0.5 - y / 2 },
end: { x: 0.5 + x / 2, y: 0.5 + y / 2 },
};
};
const { start: bgStart, end: bgEnd } = angle !== undefined
? angleToDirection(angle)
: { start: { x: 0, y: 1 }, end: { x: 0, y: 1 } };
const borderContainerStyle = [
{
borderRadius,
overflow: 'hidden',
},
style,
];
const renderStaticBorders = () => {
var _a, _b;
const gradientSpan = 0.95; // 95% width
return (<>
{/* LEFT: Solid Blue */}
{borderLeftGradient && (<View style={[
borderStyles.left,
{ backgroundColor: (_a = borderLeftGradient[0]) !== null && _a !== void 0 ? _a : 'transparent' },
]}/>)}
{/* TOP: Red to Orange */}
{borderTopGradient && (<LinearGradient colors={borderTopGradient} start={{ x: 0, y: 0 }} end={{ x: 1, y: 0 }} style={[
borderStyles.top,
{
width: `${gradientSpan * 100}%`,
alignSelf: 'center',
},
]}/>)}
{/* RIGHT: Solid Yellow */}
{borderRightGradient && (<View style={[
borderStyles.right,
{ backgroundColor: (_b = borderRightGradient[0]) !== null && _b !== void 0 ? _b : 'transparent' },
]}/>)}
{/* BOTTOM: Green Gradient */}
{borderBottomGradient && (<LinearGradient colors={borderBottomGradient} start={{ x: 1, y: 0 }} end={{ x: 0, y: 0 }} style={[
borderStyles.bottom,
{
width: `${gradientSpan * 100}%`,
alignSelf: 'center',
},
]}/>)}
</>);
};
const innerWrapper = (<Animated.View style={[{ transform: [{ scale: scaleAnim }] }, borderContainerStyle]}>
{/* Border */}
{!borderRotationStopped && borderRotation !== 'none' ? (<Animated.View style={[StyleSheet.absoluteFill, { transform: [{ rotate: spinBorder }] }]}>
<LinearGradient colors={fullBorderGradient} locations={calculatedBorderLocations} start={bgStart} end={bgEnd} style={StyleSheet.absoluteFill}/>
</Animated.View>) : !useIndividualBorders ? (<View style={StyleSheet.absoluteFill}>
<LinearGradient colors={fullBorderGradient} locations={calculatedBorderLocations} start={bgStart} end={bgEnd} style={StyleSheet.absoluteFill}/>
</View>) : (renderStaticBorders())}
{/* Background */}
<View style={{
margin: borderWidth,
flex: 1,
borderRadius: borderRadius - borderWidth,
overflow: 'hidden',
}}>
{backgroundRotation !== 'none' ? (<Animated.View style={[StyleSheet.absoluteFill, { transform: [{ rotate: spinBg }] }]}>
<LinearGradient colors={animated && animatedGradient.length > 0 ? animatedGradient : appliedBackground} locations={backgroundLocations} start={bgStart} end={bgEnd} style={StyleSheet.absoluteFill}/>
</Animated.View>) : (<LinearGradient colors={animated && animatedGradient.length > 0 ? animatedGradient : appliedBackground} locations={backgroundLocations} start={bgStart} end={bgEnd} style={StyleSheet.absoluteFill}/>)}
{/* Content */}
<View style={[{ flex: 1 }, contentStyle]}>{children}</View>
</View>
</Animated.View>);
return onPress || onLongPress ? (<Pressable onPress={onPress} onLongPress={onLongPress} onPressIn={handlePressIn} onPressOut={handlePressOut} style={{ flex: 1 }}>
{innerWrapper}
</Pressable>) : (innerWrapper);
};
export default GradientWrapper;