297 lines
8.2 KiB
TypeScript
297 lines
8.2 KiB
TypeScript
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",
|
||
},
|
||
});
|