Files
physical-expo/components/custom-tab-bar.tsx
2026-01-13 10:26:04 +08:00

297 lines
8.2 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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",
},
});