自定义底部导航栏

This commit is contained in:
xianyi
2026-01-13 10:26:04 +08:00
parent 98134c5be9
commit ceee6fe699
5 changed files with 338 additions and 61 deletions

View 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",
},
});