UNPKG

@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
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