351 lines
9.8 KiB
TypeScript
351 lines
9.8 KiB
TypeScript
import { useEffect, useRef, useState } from "react"
|
|
import {
|
|
Animated,
|
|
Dimensions,
|
|
Platform,
|
|
Pressable,
|
|
TextStyle,
|
|
TouchableOpacity,
|
|
View,
|
|
ViewStyle,
|
|
} from "react-native"
|
|
import { BottomTabBarProps, createBottomTabNavigator } from "@react-navigation/bottom-tabs"
|
|
import { useNavigation } from "@react-navigation/native"
|
|
import { NativeStackNavigationProp } from "@react-navigation/native-stack"
|
|
import {
|
|
PanGestureHandler,
|
|
PanGestureHandlerStateChangeEvent,
|
|
State,
|
|
} from "react-native-gesture-handler"
|
|
import { useSafeAreaInsets } from "react-native-safe-area-context"
|
|
|
|
import { Avatar } from "@/components/Avatar"
|
|
import { Icon, IconTypes } from "@/components/Icon"
|
|
import { Text } from "@/components/Text"
|
|
import { useAuth } from "@/context/AuthContext"
|
|
import { translate } from "@/i18n/translate"
|
|
import { CommunityScreen } from "@/screens/CommunityScreen"
|
|
import { ShowroomScreen } from "@/screens/ShowroomScreen/ShowroomScreen"
|
|
import { useAppTheme } from "@/theme/context"
|
|
import type { ThemedStyle } from "@/theme/types"
|
|
import { s, fs } from "@/utils/responsive"
|
|
|
|
import type { AppStackParamList, MainTabParamList } from "./navigationTypes"
|
|
|
|
const Tab = createBottomTabNavigator<MainTabParamList>()
|
|
|
|
const { width: SCREEN_WIDTH } = Dimensions.get("window")
|
|
|
|
// Tab configuration
|
|
const TAB_CONFIG: { name: keyof MainTabParamList; icon: IconTypes }[] = [
|
|
{ name: "Showroom", icon: "home" },
|
|
{ name: "Community", icon: "barChart" },
|
|
]
|
|
|
|
/**
|
|
* Header left component with profile avatar button
|
|
*/
|
|
function HeaderProfileButton() {
|
|
const { user } = useAuth()
|
|
const navigation = useNavigation<NativeStackNavigationProp<AppStackParamList>>()
|
|
|
|
const handlePress = () => {
|
|
navigation.navigate("Profile")
|
|
}
|
|
|
|
return (
|
|
<TouchableOpacity onPress={handlePress} activeOpacity={0.7}>
|
|
<Avatar
|
|
uri={user?.profile?.avatar}
|
|
fallback={user?.profile?.nickname || user?.username || "U"}
|
|
size={s(36)}
|
|
/>
|
|
</TouchableOpacity>
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Custom floating capsule tab bar with glass effect and gesture support
|
|
*/
|
|
function FloatingTabBar({ state, navigation }: BottomTabBarProps) {
|
|
const insets = useSafeAreaInsets()
|
|
const { themed, theme } = useAppTheme()
|
|
|
|
const tabIndex = state.index
|
|
const tabCount = TAB_CONFIG.length
|
|
const containerWidth = SCREEN_WIDTH - theme.spacing.xl * 2
|
|
const buttonWidth = (containerWidth - theme.spacing.xs * 2) / tabCount
|
|
|
|
// 动画值
|
|
const basePos = useRef(new Animated.Value(tabIndex)).current
|
|
const gestureX = useRef(new Animated.Value(0)).current
|
|
|
|
// 视觉上的活跃索引,用于控制图标颜色
|
|
const [visualIndex, setVisualIndex] = useState(tabIndex)
|
|
|
|
// 同步系统索引到视觉索引
|
|
useEffect(() => {
|
|
setVisualIndex(tabIndex)
|
|
Animated.spring(basePos, {
|
|
toValue: tabIndex,
|
|
useNativeDriver: true,
|
|
tension: 100,
|
|
friction: 12,
|
|
}).start()
|
|
}, [tabIndex, basePos])
|
|
|
|
// 监听手势位移,实时更新图标视觉状态
|
|
useEffect(() => {
|
|
const listenerId = gestureX.addListener(({ value }) => {
|
|
// 计算当前指示器中心位置对应的索引
|
|
const currentIndicatorPos = tabIndex * buttonWidth + value
|
|
const newVisualIndex = Math.max(
|
|
0,
|
|
Math.min(TAB_CONFIG.length - 1, Math.round(currentIndicatorPos / buttonWidth)),
|
|
)
|
|
|
|
if (newVisualIndex !== visualIndex) {
|
|
setVisualIndex(newVisualIndex)
|
|
}
|
|
})
|
|
return () => gestureX.removeListener(listenerId)
|
|
}, [tabIndex, visualIndex, buttonWidth, gestureX])
|
|
|
|
// 手势结束处理
|
|
const onHandlerStateChange = (event: PanGestureHandlerStateChangeEvent) => {
|
|
if (event.nativeEvent.state === State.END) {
|
|
const { translationX, velocityX } = event.nativeEvent
|
|
const movedTabs = Math.round(translationX / buttonWidth)
|
|
|
|
// 快速滑动检测
|
|
let finalMovedTabs = movedTabs
|
|
if (Math.abs(velocityX) > 500 && Math.abs(translationX) > 20) {
|
|
finalMovedTabs = velocityX > 0 ? -1 : 1 // 注意方向:向右滑是上一个,向左滑是下一个
|
|
}
|
|
|
|
// 计算新索引
|
|
let newIndex = tabIndex + finalMovedTabs
|
|
newIndex = Math.max(0, Math.min(TAB_CONFIG.length - 1, newIndex))
|
|
|
|
if (newIndex !== tabIndex) {
|
|
gestureX.flattenOffset()
|
|
requestAnimationFrame(() => {
|
|
navigation.navigate(TAB_CONFIG[newIndex].name)
|
|
})
|
|
}
|
|
|
|
// 弹簧动画复位
|
|
Animated.spring(gestureX, {
|
|
toValue: 0,
|
|
useNativeDriver: true,
|
|
tension: 100,
|
|
friction: 15,
|
|
}).start()
|
|
}
|
|
}
|
|
|
|
// 指示器位置 = 基准位置 + 手势偏移
|
|
const indicatorTranslateX = Animated.add(
|
|
basePos.interpolate({
|
|
inputRange: TAB_CONFIG.map((_, i) => i),
|
|
outputRange: TAB_CONFIG.map((_, i) => theme.spacing.xs + i * buttonWidth),
|
|
}),
|
|
gestureX,
|
|
)
|
|
|
|
return (
|
|
<View style={$tabBarOverlay}>
|
|
<View style={[themed($tabBarContainer), { bottom: insets.bottom + theme.spacing.sm }]}>
|
|
<PanGestureHandler
|
|
onGestureEvent={Animated.event([{ nativeEvent: { translationX: gestureX } }], {
|
|
useNativeDriver: true,
|
|
})}
|
|
onHandlerStateChange={onHandlerStateChange}
|
|
activeOffsetX={[-15, 15]}
|
|
>
|
|
<Animated.View style={themed($tabBarGlass)}>
|
|
{/* 滑动指示器 */}
|
|
<Animated.View
|
|
style={[
|
|
themed($tabIndicator),
|
|
{
|
|
width: buttonWidth,
|
|
transform: [{ translateX: indicatorTranslateX }],
|
|
},
|
|
]}
|
|
/>
|
|
|
|
{/* Tab 按钮 */}
|
|
{TAB_CONFIG.map((tab, index) => {
|
|
// 图标颜色取决于 visualIndex (手势实时计算) 而不是 state.index
|
|
const isActive = visualIndex === index
|
|
|
|
return (
|
|
<Pressable
|
|
key={tab.name}
|
|
style={$tabButton}
|
|
onPress={() => {
|
|
if (tabIndex !== index) {
|
|
navigation.navigate(tab.name)
|
|
}
|
|
}}
|
|
>
|
|
<Icon
|
|
icon={tab.icon}
|
|
size={s(24)}
|
|
color={isActive ? theme.colors.tint : theme.colors.textDim}
|
|
strokeWidth={1.5}
|
|
/>
|
|
</Pressable>
|
|
)
|
|
})}
|
|
</Animated.View>
|
|
</PanGestureHandler>
|
|
</View>
|
|
</View>
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Custom header component with absolutely centered title
|
|
*/
|
|
function CustomHeader({ title }: { title: string }) {
|
|
const { themed } = useAppTheme()
|
|
const insets = useSafeAreaInsets()
|
|
|
|
return (
|
|
<View
|
|
style={[themed($headerContainer), { paddingTop: insets.top, height: s(56) + insets.top }]}
|
|
>
|
|
{/* Absolutely centered title */}
|
|
<View style={[$headerTitleWrapper, { top: insets.top, height: s(56) }]}>
|
|
<Text style={themed($headerTitle)}>{title}</Text>
|
|
</View>
|
|
{/* Left side - avatar */}
|
|
<View style={$headerLeft}>
|
|
<HeaderProfileButton />
|
|
</View>
|
|
{/* Right side - future icons go here */}
|
|
<View style={$headerRight} />
|
|
</View>
|
|
)
|
|
}
|
|
|
|
/**
|
|
* This is the main navigator with a floating capsule tab bar.
|
|
*/
|
|
export function MainNavigator() {
|
|
return (
|
|
<Tab.Navigator
|
|
screenOptions={{
|
|
header: ({ options }) => <CustomHeader title={options.title || ""} />,
|
|
tabBarHideOnKeyboard: true,
|
|
tabBarStyle: { display: "none" },
|
|
}}
|
|
tabBar={(props) => <FloatingTabBar {...props} />}
|
|
>
|
|
<Tab.Screen
|
|
name="Showroom"
|
|
component={ShowroomScreen}
|
|
options={{
|
|
title: translate("navigator:componentsTab"),
|
|
}}
|
|
/>
|
|
<Tab.Screen
|
|
name="Community"
|
|
component={CommunityScreen}
|
|
options={{
|
|
title: translate("navigator:communityTab"),
|
|
}}
|
|
/>
|
|
</Tab.Navigator>
|
|
)
|
|
}
|
|
|
|
// Styles
|
|
const $headerContainer: ThemedStyle<ViewStyle> = ({ colors, spacing }) => ({
|
|
backgroundColor: colors.background,
|
|
height: s(56),
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
paddingHorizontal: spacing.lg,
|
|
})
|
|
|
|
const $headerTitleWrapper: ViewStyle = {
|
|
position: "absolute",
|
|
left: 0,
|
|
right: 0,
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
pointerEvents: "none",
|
|
}
|
|
|
|
const $headerTitle: ThemedStyle<TextStyle> = ({ colors, typography }) => ({
|
|
color: colors.text,
|
|
fontFamily: typography.primary.medium,
|
|
fontSize: fs(17),
|
|
})
|
|
|
|
const $headerLeft: ViewStyle = {
|
|
zIndex: 1,
|
|
}
|
|
|
|
const $headerRight: ViewStyle = {
|
|
marginLeft: "auto",
|
|
zIndex: 1,
|
|
}
|
|
|
|
const $tabBarOverlay: ViewStyle = {
|
|
position: "absolute",
|
|
bottom: 0,
|
|
left: 0,
|
|
right: 0,
|
|
alignItems: "center",
|
|
}
|
|
|
|
const $tabBarContainer: ThemedStyle<ViewStyle> = ({ spacing }) => ({
|
|
width: SCREEN_WIDTH - spacing.xl * 2,
|
|
alignItems: "center",
|
|
})
|
|
|
|
const $tabBarGlass: ThemedStyle<ViewStyle> = ({ colors, spacing, isDark }) => ({
|
|
flexDirection: "row",
|
|
// 半透明背景模拟毛玻璃效果
|
|
backgroundColor: isDark ? "rgba(30, 30, 30, 0.8)" : "rgba(255, 255, 255, 0.8)",
|
|
borderRadius: s(28),
|
|
height: s(56),
|
|
width: "100%",
|
|
alignItems: "center",
|
|
paddingHorizontal: spacing.xs,
|
|
// iOS 阴影
|
|
...(Platform.OS === "ios" && {
|
|
shadowColor: colors.palette.neutral800,
|
|
shadowOffset: { width: 0, height: s(4) },
|
|
shadowOpacity: 0.15,
|
|
shadowRadius: s(12),
|
|
}),
|
|
// Android 阴影
|
|
elevation: 8,
|
|
// 边框增加玻璃感
|
|
borderWidth: 1,
|
|
borderColor: isDark ? "rgba(255, 255, 255, 0.1)" : "rgba(0, 0, 0, 0.05)",
|
|
})
|
|
|
|
const $tabIndicator: ThemedStyle<ViewStyle> = ({ isDark }) => ({
|
|
position: "absolute",
|
|
height: s(48),
|
|
// 选择器半透明
|
|
backgroundColor: isDark ? "rgba(255, 255, 255, 0.15)" : "rgba(0, 0, 0, 0.08)",
|
|
borderRadius: s(24),
|
|
top: s(4),
|
|
left: 0,
|
|
})
|
|
|
|
const $tabButton: ViewStyle = {
|
|
flex: 1,
|
|
height: "100%",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
zIndex: 1,
|
|
}
|