2026-03-09 21:29:03 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* TabBar 标签栏组件 - 美化版
|
|
|
|
|
|
* 用于切换不同标签页,支持多种样式变体
|
|
|
|
|
|
* 新增胶囊式、分段式等现代设计风格
|
|
|
|
|
|
*/
|
|
|
|
|
|
|
2026-03-25 05:16:54 +08:00
|
|
|
|
import React, { ReactNode, useMemo } from 'react';
|
|
|
|
|
|
import { View, TouchableOpacity, StyleSheet, ScrollView, ViewStyle, StyleProp } from 'react-native';
|
2026-03-09 21:29:03 +08:00
|
|
|
|
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
2026-03-25 05:16:54 +08:00
|
|
|
|
import { spacing, fontSizes, borderRadius, useAppColors, type AppColors } from '../../theme';
|
2026-03-09 21:29:03 +08:00
|
|
|
|
import Text from '../common/Text';
|
|
|
|
|
|
|
|
|
|
|
|
type TabBarVariant = 'default' | 'pill' | 'segmented' | 'modern';
|
|
|
|
|
|
|
|
|
|
|
|
interface TabBarProps {
|
|
|
|
|
|
tabs: string[];
|
|
|
|
|
|
activeIndex: number;
|
|
|
|
|
|
onTabChange: (index: number) => void;
|
|
|
|
|
|
scrollable?: boolean;
|
|
|
|
|
|
rightContent?: ReactNode;
|
|
|
|
|
|
variant?: TabBarVariant;
|
|
|
|
|
|
icons?: string[];
|
2026-03-25 01:30:00 +08:00
|
|
|
|
style?: StyleProp<ViewStyle>;
|
2026-03-09 21:29:03 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-25 05:16:54 +08:00
|
|
|
|
function createTabBarStyles(colors: AppColors) {
|
|
|
|
|
|
return StyleSheet.create({
|
|
|
|
|
|
container: {
|
|
|
|
|
|
flexDirection: 'row',
|
|
|
|
|
|
backgroundColor: colors.background.paper,
|
|
|
|
|
|
borderBottomWidth: 1,
|
|
|
|
|
|
borderBottomColor: colors.divider,
|
|
|
|
|
|
alignItems: 'center',
|
|
|
|
|
|
paddingRight: spacing.xs,
|
|
|
|
|
|
},
|
|
|
|
|
|
scrollableContainer: {
|
|
|
|
|
|
flexDirection: 'row',
|
|
|
|
|
|
paddingHorizontal: spacing.md,
|
|
|
|
|
|
backgroundColor: colors.background.paper,
|
|
|
|
|
|
borderBottomWidth: 1,
|
|
|
|
|
|
borderBottomColor: colors.divider,
|
|
|
|
|
|
flex: 1,
|
|
|
|
|
|
},
|
|
|
|
|
|
tab: {
|
|
|
|
|
|
flex: 1,
|
|
|
|
|
|
alignItems: 'center',
|
|
|
|
|
|
justifyContent: 'center',
|
|
|
|
|
|
paddingVertical: spacing.md,
|
|
|
|
|
|
position: 'relative',
|
|
|
|
|
|
},
|
|
|
|
|
|
activeTab: {},
|
|
|
|
|
|
tabText: {
|
|
|
|
|
|
fontWeight: '500',
|
|
|
|
|
|
},
|
|
|
|
|
|
activeTabText: {
|
|
|
|
|
|
fontWeight: '600',
|
|
|
|
|
|
},
|
|
|
|
|
|
activeIndicator: {
|
|
|
|
|
|
position: 'absolute',
|
|
|
|
|
|
bottom: 0,
|
|
|
|
|
|
left: '25%',
|
|
|
|
|
|
right: '25%',
|
|
|
|
|
|
height: 3,
|
|
|
|
|
|
backgroundColor: colors.primary.main,
|
|
|
|
|
|
borderTopLeftRadius: borderRadius.sm,
|
|
|
|
|
|
borderTopRightRadius: borderRadius.sm,
|
|
|
|
|
|
},
|
|
|
|
|
|
rightContent: {
|
|
|
|
|
|
paddingLeft: spacing.sm,
|
|
|
|
|
|
},
|
|
|
|
|
|
pillContainer: {
|
|
|
|
|
|
flexDirection: 'row',
|
|
|
|
|
|
backgroundColor: colors.background.default,
|
|
|
|
|
|
padding: spacing.sm,
|
|
|
|
|
|
gap: spacing.sm,
|
|
|
|
|
|
},
|
|
|
|
|
|
pillTab: {
|
|
|
|
|
|
flex: 1,
|
|
|
|
|
|
alignItems: 'center',
|
|
|
|
|
|
justifyContent: 'center',
|
|
|
|
|
|
paddingVertical: spacing.sm,
|
|
|
|
|
|
paddingHorizontal: spacing.md,
|
|
|
|
|
|
borderRadius: borderRadius.lg,
|
|
|
|
|
|
backgroundColor: colors.background.paper,
|
|
|
|
|
|
},
|
|
|
|
|
|
pillTabActive: {
|
|
|
|
|
|
backgroundColor: colors.primary.main,
|
|
|
|
|
|
},
|
|
|
|
|
|
pillTabText: {
|
|
|
|
|
|
fontWeight: '600',
|
|
|
|
|
|
},
|
|
|
|
|
|
segmentedContainer: {
|
|
|
|
|
|
flexDirection: 'row',
|
|
|
|
|
|
backgroundColor: colors.background.default,
|
|
|
|
|
|
padding: spacing.xs,
|
|
|
|
|
|
marginHorizontal: spacing.md,
|
|
|
|
|
|
marginVertical: spacing.sm,
|
|
|
|
|
|
borderRadius: borderRadius.lg,
|
|
|
|
|
|
},
|
|
|
|
|
|
segmentedTab: {
|
|
|
|
|
|
flex: 1,
|
|
|
|
|
|
alignItems: 'center',
|
|
|
|
|
|
justifyContent: 'center',
|
|
|
|
|
|
paddingVertical: spacing.sm,
|
|
|
|
|
|
backgroundColor: 'transparent',
|
|
|
|
|
|
},
|
|
|
|
|
|
segmentedTabActive: {
|
|
|
|
|
|
backgroundColor: colors.background.paper,
|
|
|
|
|
|
borderRadius: borderRadius.md,
|
|
|
|
|
|
shadowColor: colors.chat.shadow,
|
|
|
|
|
|
shadowOffset: { width: 0, height: 1 },
|
|
|
|
|
|
shadowOpacity: 0.1,
|
|
|
|
|
|
shadowRadius: 2,
|
|
|
|
|
|
elevation: 2,
|
|
|
|
|
|
},
|
|
|
|
|
|
segmentedTabFirst: {
|
|
|
|
|
|
borderTopLeftRadius: borderRadius.md,
|
|
|
|
|
|
borderBottomLeftRadius: borderRadius.md,
|
|
|
|
|
|
},
|
|
|
|
|
|
segmentedTabLast: {
|
|
|
|
|
|
borderTopRightRadius: borderRadius.md,
|
|
|
|
|
|
borderBottomRightRadius: borderRadius.md,
|
|
|
|
|
|
},
|
|
|
|
|
|
segmentedTabText: {
|
|
|
|
|
|
fontWeight: '600',
|
|
|
|
|
|
},
|
|
|
|
|
|
segmentedTabContent: {
|
|
|
|
|
|
flexDirection: 'row',
|
|
|
|
|
|
alignItems: 'center',
|
|
|
|
|
|
justifyContent: 'center',
|
|
|
|
|
|
},
|
|
|
|
|
|
segmentedTabIcon: {
|
|
|
|
|
|
marginRight: spacing.xs,
|
|
|
|
|
|
},
|
|
|
|
|
|
modernContainer: {
|
|
|
|
|
|
flexDirection: 'row',
|
|
|
|
|
|
backgroundColor: colors.background.paper,
|
|
|
|
|
|
borderRadius: borderRadius.xl,
|
|
|
|
|
|
marginHorizontal: spacing.lg,
|
|
|
|
|
|
marginVertical: spacing.md,
|
|
|
|
|
|
padding: spacing.xs,
|
|
|
|
|
|
shadowColor: colors.chat.shadow,
|
|
|
|
|
|
shadowOffset: { width: 0, height: 2 },
|
|
|
|
|
|
shadowOpacity: 0.08,
|
|
|
|
|
|
shadowRadius: 8,
|
|
|
|
|
|
elevation: 3,
|
|
|
|
|
|
},
|
|
|
|
|
|
modernTab: {
|
|
|
|
|
|
flex: 1,
|
|
|
|
|
|
alignItems: 'center',
|
|
|
|
|
|
justifyContent: 'center',
|
|
|
|
|
|
paddingVertical: spacing.sm,
|
|
|
|
|
|
borderRadius: borderRadius.lg,
|
|
|
|
|
|
position: 'relative',
|
|
|
|
|
|
},
|
|
|
|
|
|
modernTabActive: {
|
|
|
|
|
|
backgroundColor: colors.primary.main + '15',
|
|
|
|
|
|
},
|
|
|
|
|
|
modernTabContent: {
|
|
|
|
|
|
flexDirection: 'row',
|
|
|
|
|
|
alignItems: 'center',
|
|
|
|
|
|
justifyContent: 'center',
|
|
|
|
|
|
},
|
|
|
|
|
|
modernTabIcon: {
|
|
|
|
|
|
marginRight: spacing.xs,
|
|
|
|
|
|
},
|
|
|
|
|
|
modernTabText: {
|
|
|
|
|
|
fontWeight: '500',
|
|
|
|
|
|
fontSize: fontSizes.md,
|
|
|
|
|
|
},
|
|
|
|
|
|
modernTabTextActive: {
|
|
|
|
|
|
fontWeight: '700',
|
|
|
|
|
|
},
|
|
|
|
|
|
modernTabIndicator: {
|
|
|
|
|
|
position: 'absolute',
|
|
|
|
|
|
bottom: 4,
|
|
|
|
|
|
width: 20,
|
|
|
|
|
|
height: 3,
|
|
|
|
|
|
backgroundColor: colors.primary.main,
|
|
|
|
|
|
borderRadius: borderRadius.full,
|
|
|
|
|
|
},
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-09 21:29:03 +08:00
|
|
|
|
const TabBar: React.FC<TabBarProps> = ({
|
|
|
|
|
|
tabs,
|
|
|
|
|
|
activeIndex,
|
|
|
|
|
|
onTabChange,
|
|
|
|
|
|
scrollable = false,
|
|
|
|
|
|
rightContent,
|
|
|
|
|
|
variant = 'default',
|
|
|
|
|
|
icons,
|
2026-03-25 01:30:00 +08:00
|
|
|
|
style,
|
2026-03-09 21:29:03 +08:00
|
|
|
|
}) => {
|
2026-03-25 05:16:54 +08:00
|
|
|
|
const colors = useAppColors();
|
|
|
|
|
|
const styles = useMemo(() => createTabBarStyles(colors), [colors]);
|
|
|
|
|
|
|
2026-03-09 21:29:03 +08:00
|
|
|
|
const renderTabs = () => {
|
|
|
|
|
|
return tabs.map((tab, index) => {
|
|
|
|
|
|
const isActive = index === activeIndex;
|
|
|
|
|
|
const icon = icons?.[index];
|
|
|
|
|
|
|
|
|
|
|
|
if (variant === 'modern') {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<TouchableOpacity
|
|
|
|
|
|
key={index}
|
|
|
|
|
|
style={[styles.modernTab, isActive && styles.modernTabActive]}
|
|
|
|
|
|
onPress={() => onTabChange(index)}
|
|
|
|
|
|
activeOpacity={0.85}
|
|
|
|
|
|
>
|
|
|
|
|
|
<View style={styles.modernTabContent}>
|
|
|
|
|
|
{icon && (
|
|
|
|
|
|
<MaterialCommunityIcons
|
|
|
|
|
|
name={icon as any}
|
|
|
|
|
|
size={18}
|
|
|
|
|
|
color={isActive ? colors.primary.main : colors.text.secondary}
|
|
|
|
|
|
style={styles.modernTabIcon}
|
|
|
|
|
|
/>
|
|
|
|
|
|
)}
|
|
|
|
|
|
<Text
|
|
|
|
|
|
variant="body"
|
|
|
|
|
|
color={isActive ? colors.primary.main : colors.text.secondary}
|
|
|
|
|
|
style={isActive ? [styles.modernTabText, styles.modernTabTextActive] : styles.modernTabText}
|
|
|
|
|
|
>
|
|
|
|
|
|
{tab}
|
|
|
|
|
|
</Text>
|
|
|
|
|
|
</View>
|
|
|
|
|
|
{isActive && <View style={styles.modernTabIndicator} />}
|
|
|
|
|
|
</TouchableOpacity>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (variant === 'pill') {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<TouchableOpacity
|
|
|
|
|
|
key={index}
|
|
|
|
|
|
style={[styles.pillTab, isActive && styles.pillTabActive]}
|
|
|
|
|
|
onPress={() => onTabChange(index)}
|
|
|
|
|
|
activeOpacity={0.8}
|
|
|
|
|
|
>
|
|
|
|
|
|
<Text
|
|
|
|
|
|
variant="body"
|
|
|
|
|
|
color={isActive ? colors.text.inverse : colors.text.secondary}
|
|
|
|
|
|
style={styles.pillTabText}
|
|
|
|
|
|
>
|
|
|
|
|
|
{tab}
|
|
|
|
|
|
</Text>
|
|
|
|
|
|
</TouchableOpacity>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (variant === 'segmented') {
|
|
|
|
|
|
const isFirst = index === 0;
|
|
|
|
|
|
const isLast = index === tabs.length - 1;
|
|
|
|
|
|
return (
|
|
|
|
|
|
<TouchableOpacity
|
|
|
|
|
|
key={index}
|
|
|
|
|
|
style={[
|
|
|
|
|
|
styles.segmentedTab,
|
|
|
|
|
|
isActive && styles.segmentedTabActive,
|
|
|
|
|
|
isFirst && styles.segmentedTabFirst,
|
|
|
|
|
|
isLast && styles.segmentedTabLast,
|
|
|
|
|
|
]}
|
|
|
|
|
|
onPress={() => onTabChange(index)}
|
|
|
|
|
|
activeOpacity={0.8}
|
|
|
|
|
|
>
|
|
|
|
|
|
<View style={styles.segmentedTabContent}>
|
|
|
|
|
|
{icon && (
|
|
|
|
|
|
<MaterialCommunityIcons
|
|
|
|
|
|
name={icon as any}
|
|
|
|
|
|
size={16}
|
|
|
|
|
|
color={isActive ? colors.primary.main : colors.text.secondary}
|
|
|
|
|
|
style={styles.segmentedTabIcon}
|
|
|
|
|
|
/>
|
|
|
|
|
|
)}
|
2026-03-25 05:16:54 +08:00
|
|
|
|
<Text variant="body" color={isActive ? colors.primary.main : colors.text.secondary} style={styles.segmentedTabText}>
|
2026-03-09 21:29:03 +08:00
|
|
|
|
{tab}
|
|
|
|
|
|
</Text>
|
|
|
|
|
|
</View>
|
|
|
|
|
|
</TouchableOpacity>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<TouchableOpacity
|
|
|
|
|
|
key={index}
|
|
|
|
|
|
style={[styles.tab, isActive && styles.activeTab]}
|
|
|
|
|
|
onPress={() => onTabChange(index)}
|
|
|
|
|
|
activeOpacity={0.7}
|
|
|
|
|
|
>
|
|
|
|
|
|
<Text
|
|
|
|
|
|
variant="body"
|
|
|
|
|
|
color={isActive ? colors.primary.main : colors.text.secondary}
|
|
|
|
|
|
style={isActive ? [styles.tabText, styles.activeTabText] : styles.tabText}
|
|
|
|
|
|
>
|
|
|
|
|
|
{tab}
|
|
|
|
|
|
</Text>
|
|
|
|
|
|
{isActive && <View style={styles.activeIndicator} />}
|
|
|
|
|
|
</TouchableOpacity>
|
|
|
|
|
|
);
|
|
|
|
|
|
});
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const getContainerStyle = () => {
|
|
|
|
|
|
switch (variant) {
|
|
|
|
|
|
case 'pill':
|
|
|
|
|
|
return styles.pillContainer;
|
|
|
|
|
|
case 'segmented':
|
|
|
|
|
|
return styles.segmentedContainer;
|
|
|
|
|
|
case 'modern':
|
|
|
|
|
|
return styles.modernContainer;
|
|
|
|
|
|
default:
|
|
|
|
|
|
return styles.container;
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
if (scrollable) {
|
|
|
|
|
|
return (
|
2026-03-25 01:30:00 +08:00
|
|
|
|
<View style={[getContainerStyle(), style]}>
|
2026-03-25 05:16:54 +08:00
|
|
|
|
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.scrollableContainer}>
|
2026-03-09 21:29:03 +08:00
|
|
|
|
{renderTabs()}
|
|
|
|
|
|
</ScrollView>
|
|
|
|
|
|
{rightContent && <View style={styles.rightContent}>{rightContent}</View>}
|
|
|
|
|
|
</View>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
2026-03-25 01:30:00 +08:00
|
|
|
|
<View style={[getContainerStyle(), style]}>
|
2026-03-09 21:29:03 +08:00
|
|
|
|
{renderTabs()}
|
|
|
|
|
|
{rightContent && <View style={styles.rightContent}>{rightContent}</View>}
|
|
|
|
|
|
</View>
|
|
|
|
|
|
);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
export default TabBar;
|