@eavfw/react-native-curved-tab-bar
Version:
A beautiful animated curved tab bar for React Native apps with customizable floating action button
538 lines (513 loc) • 15.5 kB
JavaScript
import React, { useState, useEffect, useRef } from 'react';
import { View, TouchableOpacity, Text, StyleSheet, Dimensions } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import Svg, { Path, Circle, Text as SvgText } from 'react-native-svg';
import Animated, { useSharedValue, useAnimatedProps, useAnimatedStyle, withTiming, withRepeat, withSequence, Easing, runOnJS } from 'react-native-reanimated';
import { Platform } from 'react-native';
// Try to import Haptics for native platforms, but handle web gracefully
let Haptics = null;
if (Platform.OS !== 'web') {
try {
// Dynamic import for Expo Haptics - only works on native platforms
Haptics = require('expo-haptics');
} catch (e) {
console.warn('expo-haptics is not available, haptic feedback will be disabled');
}
}
const {
width
} = Dimensions.get('window');
// Create animated version of the SVG Path
const AnimatedPath = Animated.createAnimatedComponent(Path);
const CurvedTabBar = ({
state,
descriptors,
navigation,
backgroundColor = '#FFFFFF',
strokeColor = 'rgba(0,0,0,0.05)',
strokeWidth = 0.5,
tabBarHeight = 80,
showFAB = true,
fabSize = 60,
fabColor = '#00C09A',
fabIcon,
onFabPress,
curveHeight: initialCurveHeight = 14,
debug = false,
fabTabIndex = 2,
style,
fabStyle,
animateOnMount = true
}) => {
const insets = useSafeAreaInsets();
const [showDebug, setShowDebug] = useState(debug);
const [fabVisible, setFabVisible] = useState(!animateOnMount);
// Ref to track if initial animation has run
const initialAnimationCompleted = useRef(false);
const TAB_BAR_HEIGHT = tabBarHeight;
const FAB_SIZE = fabSize;
const tabBarStyle = {
height: TAB_BAR_HEIGHT + insets.bottom,
paddingBottom: insets.bottom
};
// SVG viewBox parameters
const viewBoxWidth = 100;
const viewBoxHeight = 34;
// Calculate dimensions for better positioning of the FAB
const fabTopOffset = 20;
const leftControlXStart = 42;
const rightControlXStart = 58;
// Animated shared values for the curve
const curveStartLeft = useSharedValue(30);
const curveStartRight = useSharedValue(70);
const curveHeight = useSharedValue(0); // Start flat
const curveMidpointY = useSharedValue(0); // Start flat
const leftControlX = useSharedValue(leftControlXStart);
const rightControlX = useSharedValue(rightControlXStart);
const fabScale = useSharedValue(0); // Start at 0 (invisible)
const fabOpacity = useSharedValue(0); // Start fully transparent
// Function to handle animation completion
const onCurveAnimationComplete = () => {
setFabVisible(true);
};
// Initial animation on component mount - only runs once
useEffect(() => {
if (initialAnimationCompleted.current || !animateOnMount) return;
// Start the animation after a short delay
setTimeout(() => {
curveHeight.value = withTiming(initialCurveHeight, {
duration: 800,
easing: Easing.elastic(1.5)
}, finished => {
if (finished) {
// When curve animation finishes, mark as complete and reveal the FAB
initialAnimationCompleted.current = true;
runOnJS(onCurveAnimationComplete)();
}
});
curveMidpointY.value = withTiming(initialCurveHeight, {
duration: 800,
easing: Easing.elastic(1.5)
});
}, 300);
}, [animateOnMount, initialCurveHeight, curveHeight, curveMidpointY]); // Include all dependencies
// Handle fab animation separately - only runs when fabVisible changes to true
useEffect(() => {
if (fabVisible && initialAnimationCompleted.current) {
// Animate the FAB in
fabOpacity.value = withTiming(1, {
duration: 200
});
fabScale.value = withSequence(withTiming(1.2, {
duration: 300,
easing: Easing.out(Easing.cubic)
}), withTiming(1, {
duration: 200,
easing: Easing.elastic(3)
}));
// After FAB appears, start the subtle breathing animation
setTimeout(() => {
fabScale.value = withRepeat(withSequence(withTiming(1.05, {
duration: 1500,
easing: Easing.inOut(Easing.sin)
}), withTiming(1, {
duration: 1500,
easing: Easing.inOut(Easing.sin)
})), -1,
// Infinite repeat
true // Reverse
);
}, 800);
}
}, [fabVisible, fabOpacity, fabScale]); // Include animated values in dependencies
// Tab selection animation
useEffect(() => {
const currentIndex = state.index;
// Skip animation if fabTabIndex is hidden or selected tab is the FAB
if (currentIndex === fabTabIndex) return;
if (currentIndex < fabTabIndex) {
// Left side tabs selected, shift curve slightly left
leftControlX.value = withTiming(leftControlXStart - currentIndex * 3, {
duration: 300
});
rightControlX.value = withTiming(rightControlXStart - currentIndex * 2, {
duration: 300
});
} else if (currentIndex > fabTabIndex) {
// Right side tabs selected, shift curve slightly right
const adjustedIndex = currentIndex > fabTabIndex ? currentIndex - 1 : currentIndex;
leftControlX.value = withTiming(leftControlXStart + (adjustedIndex - fabTabIndex) * 2, {
duration: 300
});
rightControlX.value = withTiming(rightControlXStart + (adjustedIndex - fabTabIndex) * 3, {
duration: 300
});
}
}, [state.index, fabTabIndex, leftControlXStart, rightControlXStart, leftControlX, rightControlX]);
// Define points for debugging (in viewBox coordinate space)
const debugPoints = [{
x: 0,
y: 0,
label: 'M0,0',
color: '#FF3333'
}, {
x: 0,
y: viewBoxHeight,
label: `L0,${viewBoxHeight}`,
color: '#FF9900'
}, {
x: viewBoxWidth,
y: viewBoxHeight,
label: `L100,${viewBoxHeight}`,
color: '#FF9900'
}, {
x: viewBoxWidth,
y: 0,
label: 'L100,0',
color: '#FF9900'
},
// Control points for first curve section
{
x: 100,
y: 0,
label: 'C100,0',
color: '#33CC33'
}, {
x: curveStartRight.value,
y: 0,
label: `${curveStartRight.value},0`,
color: '#33CC33'
}, {
x: curveStartRight.value,
y: 0,
label: `${curveStartRight.value},0`,
color: '#33CC33'
},
// Control points for second curve section (these are animated)
{
x: rightControlX.value,
y: 0,
label: `C${rightControlX.value},0`,
color: '#FF33CC'
}, {
x: rightControlX.value,
y: curveHeight.value,
label: `${rightControlX.value},${curveHeight.value}`,
color: '#FF33CC'
}, {
x: 50,
y: curveMidpointY.value,
label: `50,${curveMidpointY.value}`,
color: '#FF33CC'
},
// Control points for third curve section (these are animated)
{
x: leftControlX.value,
y: curveHeight.value,
label: `C${leftControlX.value},${curveHeight.value}`,
color: '#9933CC'
}, {
x: leftControlX.value,
y: 0,
label: `${leftControlX.value},0`,
color: '#9933CC'
}, {
x: curveStartLeft.value,
y: 0,
label: `${curveStartLeft.value},0`,
color: '#9933CC'
},
// Control points for final curve section
{
x: curveStartLeft.value,
y: 0,
label: `C${curveStartLeft.value},0`,
color: '#3366FF'
}, {
x: 0,
y: 0,
label: '0,0',
color: '#3366FF'
}, {
x: 0,
y: 0,
label: '0,0',
color: '#3366FF'
}];
// Create animated props for the path
const animatedPathProps = useAnimatedProps(() => {
const path = `
M0,0
L0,${viewBoxHeight}
L${viewBoxWidth},${viewBoxHeight}
L${viewBoxWidth},0
C${viewBoxWidth},0 ${curveStartRight.value},0 ${curveStartRight.value},0
C${rightControlX.value},0 ${rightControlX.value},${curveHeight.value} 50,${curveMidpointY.value}
C${leftControlX.value},${curveHeight.value} ${leftControlX.value},0 ${curveStartLeft.value},0
C${curveStartLeft.value},0 0,0 0,0 Z
`;
return {
d: path
};
});
// Animated props for the FAB
const animatedFabStyle = useAnimatedStyle(() => {
return {
opacity: fabOpacity.value,
transform: [{
scale: fabScale.value
}, {
translateY: withTiming(fabOpacity.value * 0, {
duration: 300
})
} // Small bounce up effect
]
};
});
// Function to handle FAB press with animation
const handleFabPress = () => {
// Trigger haptic feedback on native platforms
if (Platform.OS !== 'web' && Haptics) {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
}
// Create a pulse animation on the curve
const pulse = () => {
curveHeight.value = withSequence(withTiming(curveHeight.value + 4, {
duration: 150,
easing: Easing.inOut(Easing.quad)
}), withTiming(initialCurveHeight, {
duration: 300,
easing: Easing.elastic(2)
}));
curveMidpointY.value = withSequence(withTiming(curveMidpointY.value + 4, {
duration: 150,
easing: Easing.inOut(Easing.quad)
}), withTiming(initialCurveHeight, {
duration: 300,
easing: Easing.elastic(2)
}));
};
// Create a squeeze animation
const squeeze = () => {
curveStartLeft.value = withSequence(withTiming(curveStartLeft.value + 3, {
duration: 200
}), withTiming(30, {
duration: 300,
easing: Easing.bezier(0.34, 1.56, 0.64, 1)
}));
curveStartRight.value = withSequence(withTiming(curveStartRight.value - 3, {
duration: 200
}), withTiming(70, {
duration: 300,
easing: Easing.bezier(0.34, 1.56, 0.64, 1)
}));
};
// Run both animations
pulse();
squeeze();
// Add a pop animation for the FAB button
fabScale.value = withSequence(withTiming(1.2, {
duration: 150,
easing: Easing.inOut(Easing.quad)
}), withTiming(1, {
duration: 300,
easing: Easing.elastic(4)
}));
// Call the custom onFabPress function if provided
if (onFabPress) {
onFabPress();
}
};
// Toggle debug mode
const toggleDebug = () => {
setShowDebug(!showDebug);
};
// Long press on FAB to enable debug mode
const handleLongPress = () => {
setShowDebug(!showDebug);
};
return /*#__PURE__*/React.createElement(View, {
style: [styles.container, tabBarStyle, style]
}, /*#__PURE__*/React.createElement(View, {
style: styles.tabBarContainer
}, /*#__PURE__*/React.createElement(Svg, {
width: width,
height: TAB_BAR_HEIGHT,
viewBox: `0 0 ${viewBoxWidth} ${viewBoxHeight}`,
preserveAspectRatio: "none",
style: styles.tabBarBackground
}, /*#__PURE__*/React.createElement(AnimatedPath, {
animatedProps: animatedPathProps,
fill: backgroundColor,
stroke: strokeColor,
strokeWidth: strokeWidth
}), showDebug && debugPoints.map((point, index) => /*#__PURE__*/React.createElement(React.Fragment, {
key: index
}, /*#__PURE__*/React.createElement(Circle, {
cx: typeof point.x === 'number' ? point.x : point.x,
cy: typeof point.y === 'number' ? point.y : point.y,
r: 1,
fill: point.color,
stroke: "#000",
strokeWidth: 0.2
}), /*#__PURE__*/React.createElement(SvgText, {
x: typeof point.x === 'number' ? point.x : point.x,
y: (typeof point.y === 'number' ? point.y : point.y) - 1.5,
fill: point.color,
stroke: "#FFF",
strokeWidth: 0.1,
fontSize: "1.8",
textAnchor: "middle"
}, point.label))))), showDebug && /*#__PURE__*/React.createElement(TouchableOpacity, {
style: styles.debugButton,
onPress: toggleDebug
}, /*#__PURE__*/React.createElement(Text, {
style: styles.debugButtonText
}, "Hide Debug")), showFAB && /*#__PURE__*/React.createElement(Animated.View, {
style: [styles.fabContainer, {
bottom: TAB_BAR_HEIGHT - fabTopOffset
}, animatedFabStyle, fabStyle]
}, /*#__PURE__*/React.createElement(TouchableOpacity, {
onPress: handleFabPress,
onLongPress: handleLongPress,
activeOpacity: 0.8
}, /*#__PURE__*/React.createElement(View, {
style: [styles.fab, {
width: FAB_SIZE,
height: FAB_SIZE,
borderRadius: FAB_SIZE / 2,
backgroundColor: fabColor
}]
}, fabIcon))), /*#__PURE__*/React.createElement(View, {
style: styles.tabBar
}, state.routes.map((route, index) => {
const descriptor = descriptors[route.key];
if (!descriptor) return null;
const {
options
} = descriptor;
const label = options.title || route.name;
const isFocused = state.index === index;
// Skip FAB position if showFAB is true
if (showFAB && index === fabTabIndex) {
return /*#__PURE__*/React.createElement(View, {
key: route.key,
style: styles.emptyTab
});
}
const onPress = () => {
const event = navigation.emit({
type: 'tabPress',
target: route.key,
canPreventDefault: true
});
if (!isFocused && !event.defaultPrevented) {
navigation.navigate(route.name);
}
};
// Adjust spacing for tabs to account for the center FAB
const tabPositionStyle = showFAB ? {
// If after FAB tab (index > fabTabIndex), add extra space to account for FAB
marginLeft: index > fabTabIndex ? 10 : 0,
marginRight: index < fabTabIndex ? 10 : 0
} : {};
return /*#__PURE__*/React.createElement(TouchableOpacity, {
key: route.key,
accessibilityRole: "button",
accessibilityState: isFocused ? {
selected: true
} : {},
accessibilityLabel: options.tabBarAccessibilityLabel,
testID: options.tabBarTestID,
onPress: onPress,
style: [styles.tabItem, tabPositionStyle]
}, options.tabBarIcon && options.tabBarIcon({
focused: isFocused,
color: isFocused ? '#000' : '#999',
size: 24
}), /*#__PURE__*/React.createElement(Text, {
style: [styles.tabLabel, isFocused ? styles.tabLabelFocused : styles.tabLabelInactive],
numberOfLines: 1
}, label));
})));
};
const styles = StyleSheet.create({
container: {
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
backgroundColor: 'transparent'
},
tabBarContainer: {
position: 'absolute',
bottom: 0,
left: 0,
right: 0
},
tabBarBackground: {
position: 'absolute',
bottom: 0
},
fabContainer: {
position: 'absolute',
alignSelf: 'center',
zIndex: 10
},
fab: {
justifyContent: 'center',
alignItems: 'center',
shadowColor: '#000',
shadowOffset: {
width: 0,
height: 3
},
shadowOpacity: 0.3,
shadowRadius: 4,
elevation: 8,
flexDirection: 'row',
gap: 8
},
tabBar: {
flexDirection: 'row',
justifyContent: 'space-around',
alignItems: 'flex-end',
height: 80
},
tabItem: {
flex: 1,
alignItems: 'center',
justifyContent: 'flex-end',
marginBottom: 10
},
emptyTab: {
flex: 1
},
tabLabel: {
fontSize: 12,
marginTop: 2,
textAlign: 'center'
},
tabLabelFocused: {
color: '#000'
},
tabLabelInactive: {
color: '#999'
},
debugButton: {
position: 'absolute',
top: 5,
right: 10,
backgroundColor: 'rgba(0,0,0,0.5)',
paddingHorizontal: 8,
paddingVertical: 4,
borderRadius: 4,
zIndex: 999
},
debugButtonText: {
color: 'white',
fontSize: 10
}
});
export default CurvedTabBar;
//# sourceMappingURL=CurvedTabBar.js.map