You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
230 lines
6.9 KiB
230 lines
6.9 KiB
import { Ionicons } from '@expo/vector-icons';
|
|
import { Tabs } from 'expo-router/js-tabs';
|
|
import type { BottomTabBarProps } from 'expo-router/js-tabs';
|
|
import { useEffect, useRef } from 'react';
|
|
import { Animated, Easing, Pressable, StyleSheet, View } from 'react-native';
|
|
import { colors } from '../../src/theme';
|
|
|
|
/** 无 index 路由,默认进入「岗位」Tab 作为落地页 */
|
|
export const unstable_settings = {
|
|
initialRouteName: 'jobs',
|
|
};
|
|
|
|
/** 未选中用 -outline 线性,选中用填充实心 */
|
|
const ICON_MAP = {
|
|
jobs: { outline: 'briefcase-outline', filled: 'briefcase' },
|
|
chat: { outline: 'chatbubble-outline', filled: 'chatbubble' },
|
|
growth: { outline: 'trending-up-outline', filled: 'trending-up' },
|
|
profile: { outline: 'person-outline', filled: 'person' },
|
|
} as const;
|
|
|
|
type TabRoute = keyof typeof ICON_MAP;
|
|
|
|
/**
|
|
* 单个 Tab 项:极简图标 + 文字。
|
|
* 点击选中时:
|
|
* - 图标/文字从「灰色描边」平滑渐变到「主色填充」
|
|
* (灰层 + 主色层叠放,opacity 交叉淡入,200ms,纯原生驱动)
|
|
* - 图标上浮 2px(150ms)
|
|
* - 按压反馈:轻微缩放 0.92
|
|
*/
|
|
function TabItem({
|
|
focused,
|
|
route,
|
|
label,
|
|
onPress,
|
|
onLongPress,
|
|
}: {
|
|
focused: boolean;
|
|
route: TabRoute;
|
|
label: string;
|
|
onPress: () => void;
|
|
onLongPress: () => void;
|
|
}) {
|
|
const { outline, filled } = ICON_MAP[route];
|
|
// 图标纵向位移:选中上浮 2px,未选中复位
|
|
const translateY = useRef(new Animated.Value(focused ? -2 : 0)).current;
|
|
// 选中进度:0 = 未选中,1 = 选中
|
|
const progress = useRef(new Animated.Value(focused ? 1 : 0)).current;
|
|
|
|
useEffect(() => {
|
|
const translateAnim = Animated.timing(translateY, {
|
|
toValue: focused ? -2 : 0,
|
|
duration: 150,
|
|
easing: Easing.out(Easing.ease),
|
|
useNativeDriver: true,
|
|
});
|
|
const progressAnim = Animated.timing(progress, {
|
|
toValue: focused ? 1 : 0,
|
|
duration: 200,
|
|
easing: Easing.inOut(Easing.ease),
|
|
useNativeDriver: true,
|
|
});
|
|
|
|
translateAnim.start();
|
|
progressAnim.start();
|
|
|
|
// 清理:组件卸载或 focused 变化前停止动画,避免更新已卸载的节点
|
|
return () => {
|
|
translateAnim.stop();
|
|
progressAnim.stop();
|
|
};
|
|
}, [focused, translateY, progress]);
|
|
|
|
// 未选中层(灰 + 描边)透明度:1 → 0;选中层(主色 + 填充)透明度:0 → 1
|
|
const inactiveOpacity = progress.interpolate({
|
|
inputRange: [0, 1],
|
|
outputRange: [1, 0],
|
|
});
|
|
|
|
return (
|
|
<Pressable
|
|
style={({ pressed }) => [styles.tabItem, pressed && styles.tabItemPressed]}
|
|
onPress={onPress}
|
|
onLongPress={onLongPress}
|
|
accessibilityRole="button"
|
|
accessibilityState={{ selected: focused }}
|
|
>
|
|
<Animated.View style={{ transform: [{ translateY }] }}>
|
|
<View style={styles.iconBox}>
|
|
{/* 未选中层:灰色描边图标 */}
|
|
<Animated.View
|
|
style={[StyleSheet.absoluteFill, styles.center, { opacity: inactiveOpacity }]}
|
|
>
|
|
<Ionicons name={outline} size={22} color={colors.textHint} />
|
|
</Animated.View>
|
|
{/* 选中层:主色填充图标 */}
|
|
<Animated.View style={[StyleSheet.absoluteFill, styles.center, { opacity: progress }]}>
|
|
<Ionicons name={filled} size={22} color={colors.primary} />
|
|
</Animated.View>
|
|
</View>
|
|
</Animated.View>
|
|
|
|
<View style={styles.labelBox}>
|
|
{/* 未选中文字:灰色常规字重 */}
|
|
<Animated.Text style={[styles.tabLabel, styles.labelLayer, { opacity: inactiveOpacity }]}>
|
|
{label}
|
|
</Animated.Text>
|
|
{/* 选中文字:主色加粗 */}
|
|
<Animated.Text
|
|
style={[styles.tabLabel, styles.tabLabelActive, styles.labelLayer, { opacity: progress }]}
|
|
>
|
|
{label}
|
|
</Animated.Text>
|
|
</View>
|
|
</Pressable>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 底部 Tab 栏:占据底部布局空间(非悬浮),纯白底、无分割线,
|
|
* 内容只在 tab 栏上方滚动,不会从图标后面穿出。
|
|
*/
|
|
function TabBar({ state, descriptors, navigation, insets }: BottomTabBarProps) {
|
|
return (
|
|
<View style={[styles.tabBar, { paddingBottom: insets.bottom + 4 }]}>
|
|
{state.routes.map((route, index) => {
|
|
const { options } = descriptors[route.key];
|
|
const focused = state.index === index;
|
|
const label =
|
|
typeof options.tabBarLabel === 'string' ? options.tabBarLabel : options.title ?? '';
|
|
|
|
const onPress = () => {
|
|
const event = navigation.emit({
|
|
type: 'tabPress',
|
|
target: route.key,
|
|
canPreventDefault: true,
|
|
});
|
|
if (!focused && !event.defaultPrevented) {
|
|
navigation.navigate(route.name);
|
|
}
|
|
};
|
|
const onLongPress = () =>
|
|
navigation.emit({ type: 'tabLongPress', target: route.key });
|
|
|
|
return (
|
|
<TabItem
|
|
key={route.key}
|
|
route={route.name as TabRoute}
|
|
focused={focused}
|
|
label={label}
|
|
onPress={onPress}
|
|
onLongPress={onLongPress}
|
|
/>
|
|
);
|
|
})}
|
|
</View>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 底部 Tab 导航:4 个 Tab(岗位/沟通/成长/我的),高级感极简风格
|
|
*/
|
|
export default function TabLayout() {
|
|
/*
|
|
* 这里**不再**写角色。
|
|
* 原实现在 useFocusEffect 里无条件 `setRole('candidate')` —— 只要用户点进求职端 Tab,
|
|
* 本地账号角色就变成求职者,哪怕服务端记录的是企业;下次冷启动会被直接放进求职端。
|
|
* 角色现在只由 AuthContext 按服务器下发(登录 / 选身份 / /auth/me)维护。
|
|
*/
|
|
return (
|
|
<Tabs
|
|
screenOptions={{ headerShown: false }}
|
|
tabBar={(props) => <TabBar {...props} />}
|
|
>
|
|
<Tabs.Screen name="jobs" options={{ title: '岗位' }} />
|
|
<Tabs.Screen name="chat" options={{ title: '沟通' }} />
|
|
<Tabs.Screen name="growth" options={{ title: '成长' }} />
|
|
<Tabs.Screen name="profile" options={{ title: '我的' }} />
|
|
</Tabs>
|
|
);
|
|
}
|
|
|
|
const styles = StyleSheet.create({
|
|
// 纯白底、无分割线、无阴影,占底部布局空间
|
|
tabBar: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
backgroundColor: colors.white,
|
|
paddingTop: 4,
|
|
},
|
|
tabItem: {
|
|
flex: 1,
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
paddingVertical: 2,
|
|
},
|
|
tabItemPressed: {
|
|
transform: [{ scale: 0.92 }],
|
|
},
|
|
iconBox: {
|
|
width: 22,
|
|
height: 22,
|
|
},
|
|
center: {
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
},
|
|
labelBox: {
|
|
height: 14,
|
|
marginTop: 1,
|
|
alignSelf: 'stretch',
|
|
},
|
|
labelLayer: {
|
|
position: 'absolute',
|
|
top: 0,
|
|
left: 0,
|
|
right: 0,
|
|
},
|
|
tabLabel: {
|
|
fontSize: 11,
|
|
lineHeight: 14,
|
|
fontWeight: '400',
|
|
textAlign: 'center',
|
|
color: colors.textHint,
|
|
},
|
|
tabLabelActive: {
|
|
fontWeight: '600',
|
|
color: colors.primary,
|
|
},
|
|
});
|