自定义底部导航栏
This commit is contained in:
296
components/custom-tab-bar.tsx
Normal file
296
components/custom-tab-bar.tsx
Normal file
@@ -0,0 +1,296 @@
|
||||
import { BottomTabBarProps } from "@react-navigation/bottom-tabs";
|
||||
import { BlurView } from "expo-blur";
|
||||
import * as Haptics from "expo-haptics";
|
||||
import React, { useEffect, useRef } from "react";
|
||||
import { LayoutChangeEvent, Platform, StyleSheet, Text, TouchableOpacity, View } from "react-native";
|
||||
import Animated, {
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withTiming,
|
||||
} from "react-native-reanimated";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
|
||||
import { IconSymbol } from "@/components/ui/icon-symbol";
|
||||
import { Colors } from "@/constants/theme";
|
||||
import { useTheme } from "@/context/ThemeContext";
|
||||
|
||||
// 图标名称映射
|
||||
const TAB_ICONS: Record<string, string> = {
|
||||
index: "list",
|
||||
live: "play-circle",
|
||||
upcoming: "calendar",
|
||||
finished: "checkmark-circle",
|
||||
favorite: "star",
|
||||
};
|
||||
|
||||
// 将十六进制颜色转换为带透明度的 rgba
|
||||
function hexToRgba(hex: string, alpha: number): string {
|
||||
// 移除 # 符号
|
||||
const cleanHex = hex.replace("#", "");
|
||||
|
||||
// 处理 3 位和 6 位十六进制颜色
|
||||
const r = cleanHex.length === 3
|
||||
? parseInt(cleanHex[0] + cleanHex[0], 16)
|
||||
: parseInt(cleanHex.substring(0, 2), 16);
|
||||
const g = cleanHex.length === 3
|
||||
? parseInt(cleanHex[1] + cleanHex[1], 16)
|
||||
: parseInt(cleanHex.substring(2, 4), 16);
|
||||
const b = cleanHex.length === 3
|
||||
? parseInt(cleanHex[2] + cleanHex[2], 16)
|
||||
: parseInt(cleanHex.substring(4, 6), 16);
|
||||
|
||||
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
|
||||
}
|
||||
|
||||
interface TabItemProps {
|
||||
route: BottomTabBarProps["state"]["routes"][0];
|
||||
isFocused: boolean;
|
||||
onPress: () => void;
|
||||
label: string;
|
||||
activeColor: string;
|
||||
inactiveColor: string;
|
||||
}
|
||||
|
||||
interface TabItemPropsWithLayout extends TabItemProps {
|
||||
onLayout: (event: LayoutChangeEvent) => void;
|
||||
}
|
||||
|
||||
const TabItem = ({
|
||||
route,
|
||||
isFocused,
|
||||
onPress,
|
||||
label,
|
||||
activeColor,
|
||||
inactiveColor,
|
||||
onLayout,
|
||||
}: TabItemPropsWithLayout) => {
|
||||
const iconName = TAB_ICONS[route.name] || "list";
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPress={onPress}
|
||||
onLayout={onLayout}
|
||||
style={styles.tabItem}
|
||||
activeOpacity={1}
|
||||
>
|
||||
<View style={styles.iconContainer}>
|
||||
<IconSymbol
|
||||
name={iconName as any}
|
||||
size={22}
|
||||
color={isFocused ? activeColor : inactiveColor}
|
||||
/>
|
||||
</View>
|
||||
<Text
|
||||
style={[
|
||||
styles.tabLabel,
|
||||
{ color: isFocused ? activeColor : inactiveColor },
|
||||
]}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
export default function CustomTabBar({
|
||||
state,
|
||||
descriptors,
|
||||
navigation,
|
||||
}: BottomTabBarProps) {
|
||||
const { theme } = useTheme();
|
||||
const insets = useSafeAreaInsets();
|
||||
const isDark = theme === "dark";
|
||||
|
||||
// 图标和文字颜色:light 和 dark 模式都使用主题色
|
||||
const activeColor = Colors.light.tint;
|
||||
const inactiveColor = Colors[theme].tabIconDefault;
|
||||
|
||||
// 胶囊背景颜色:light 模式用浅白色,dark 模式用浅黑色
|
||||
const indicatorBackgroundColor = isDark
|
||||
? "rgba(40, 40, 40, 0.6)" // 浅黑色
|
||||
: "rgba(240, 240, 240, 0.8)"; // 浅白色
|
||||
|
||||
// 计算底部安全区域
|
||||
const bottomInset = Platform.OS === "ios" ? insets.bottom : 20;
|
||||
|
||||
// 胶囊背景位置动画
|
||||
const indicatorPosition = useSharedValue(0);
|
||||
const tabLayouts = useRef<{ [key: number]: { x: number; width: number } }>({});
|
||||
const containerWidth = useRef<number>(0);
|
||||
|
||||
// 胶囊背景宽度(动态,等于每个 tab 的宽度减去左右 margin)
|
||||
const indicatorWidth = useSharedValue(0);
|
||||
|
||||
// 胶囊背景的 margin
|
||||
const INDICATOR_MARGIN_HORIZONTAL = 4; // 左右 margin
|
||||
const INDICATOR_MARGIN_VERTICAL = 4; // 上下 margin(设为 0 让胶囊背景更高)
|
||||
|
||||
// 当选中 tab 改变时,更新胶囊背景位置
|
||||
useEffect(() => {
|
||||
const currentIndex = state.index;
|
||||
const tabLayout = tabLayouts.current[currentIndex];
|
||||
if (tabLayout && containerWidth.current > 0) {
|
||||
// 计算带 margin 的位置和宽度
|
||||
const targetX = tabLayout.x + INDICATOR_MARGIN_HORIZONTAL;
|
||||
const targetWidth = tabLayout.width - INDICATOR_MARGIN_HORIZONTAL * 2;
|
||||
|
||||
// 使用 withTiming 实现平滑的线性移动动画
|
||||
indicatorPosition.value = withTiming(targetX, {
|
||||
duration: 200,
|
||||
});
|
||||
indicatorWidth.value = withTiming(targetWidth, {
|
||||
duration: 200,
|
||||
});
|
||||
}
|
||||
}, [state.index]);
|
||||
|
||||
// 处理容器布局
|
||||
const handleContainerLayout = (event: LayoutChangeEvent) => {
|
||||
containerWidth.current = event.nativeEvent.layout.width;
|
||||
};
|
||||
|
||||
// 处理每个 tab 的布局事件
|
||||
const handleTabLayout = (index: number) => (event: LayoutChangeEvent) => {
|
||||
const { x, width } = event.nativeEvent.layout;
|
||||
tabLayouts.current[index] = { x, width };
|
||||
|
||||
// 如果是当前选中的 tab,立即更新位置
|
||||
if (index === state.index && containerWidth.current > 0) {
|
||||
const INDICATOR_MARGIN_HORIZONTAL = 4;
|
||||
indicatorPosition.value = x + INDICATOR_MARGIN_HORIZONTAL;
|
||||
indicatorWidth.value = width - INDICATOR_MARGIN_HORIZONTAL * 2;
|
||||
}
|
||||
};
|
||||
|
||||
// 胶囊背景动画样式
|
||||
const indicatorStyle = useAnimatedStyle(() => {
|
||||
return {
|
||||
transform: [{ translateX: indicatorPosition.value }],
|
||||
width: indicatorWidth.value,
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
styles.safeArea,
|
||||
{
|
||||
bottom: bottomInset,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<BlurView
|
||||
intensity={80}
|
||||
tint={isDark ? "dark" : "light"}
|
||||
onLayout={handleContainerLayout}
|
||||
style={[
|
||||
styles.tabBarContainer,
|
||||
{
|
||||
backgroundColor: isDark
|
||||
? "rgba(25, 25, 25, 0.75)"
|
||||
: "rgba(255, 255, 255, 0.75)",
|
||||
borderColor: isDark
|
||||
? "rgba(255, 255, 255, 0.1)"
|
||||
: "rgba(0, 0, 0, 0.1)",
|
||||
},
|
||||
]}
|
||||
>
|
||||
{/* 胶囊背景指示器 */}
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.indicator,
|
||||
{
|
||||
backgroundColor: indicatorBackgroundColor,
|
||||
},
|
||||
indicatorStyle,
|
||||
]}
|
||||
/>
|
||||
|
||||
{state.routes.map((route, index) => {
|
||||
const { options } = descriptors[route.key];
|
||||
const isFocused = state.index === index;
|
||||
|
||||
const onPress = () => {
|
||||
// 触发震动反馈 (iOS Impact Light 非常细腻)
|
||||
if (Platform.OS === "ios") {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
}
|
||||
|
||||
const event = navigation.emit({
|
||||
type: "tabPress",
|
||||
target: route.key,
|
||||
canPreventDefault: true,
|
||||
});
|
||||
|
||||
if (!isFocused && !event.defaultPrevented) {
|
||||
navigation.navigate(route.name);
|
||||
}
|
||||
};
|
||||
|
||||
// 获取标签文本,优先使用 tabBarLabel,然后是 title
|
||||
let label: string;
|
||||
if (options.tabBarLabel !== undefined) {
|
||||
label =
|
||||
typeof options.tabBarLabel === "string"
|
||||
? options.tabBarLabel
|
||||
: route.name;
|
||||
} else if (options.title !== undefined) {
|
||||
label = options.title;
|
||||
} else {
|
||||
label = route.name;
|
||||
}
|
||||
|
||||
return (
|
||||
<TabItem
|
||||
key={route.key}
|
||||
route={route}
|
||||
isFocused={isFocused}
|
||||
onPress={onPress}
|
||||
onLayout={handleTabLayout(index)}
|
||||
label={typeof label === "string" ? label : route.name}
|
||||
activeColor={activeColor}
|
||||
inactiveColor={inactiveColor}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</BlurView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
safeArea: {
|
||||
position: "absolute",
|
||||
left: 12,
|
||||
right: 12,
|
||||
},
|
||||
tabBarContainer: {
|
||||
flexDirection: "row",
|
||||
height: 60, // 从 56 增加到 58
|
||||
borderRadius: 30, // 相应调整圆角
|
||||
overflow: "hidden",
|
||||
borderWidth: 0.5,
|
||||
},
|
||||
tabItem: {
|
||||
flex: 1,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
iconContainer: {
|
||||
width: 48,
|
||||
height: 28,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
marginBottom: 2,
|
||||
},
|
||||
indicator: {
|
||||
position: "absolute",
|
||||
height: 52, // 58 (tabBarContainer 高度) - 8 (上下 margin 4px * 2)
|
||||
borderRadius: 26, // 与 tabBarContainer 的 borderRadius 一致
|
||||
top: 4, // 上 margin
|
||||
},
|
||||
tabLabel: {
|
||||
fontSize: 10,
|
||||
fontWeight: "600",
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user