expo-popcore-old/components/CategoryTabs.tsx

163 lines
3.8 KiB
TypeScript

import React, { useMemo } from 'react';
import {
View,
Text,
ScrollView,
StyleSheet,
StyleProp,
ViewStyle,
TouchableOpacity,
useWindowDimensions,
} from 'react-native';
import Animated, {
useAnimatedStyle,
withTiming,
useDerivedValue,
} from 'react-native-reanimated';
import { Colors, Spacing, FontSize, Animation } from '@/constants/theme';
interface Category {
id: string;
name: string;
}
interface CategoryTabsProps {
categories?: Category[];
activeId?: string;
onChange?: (category: Category) => void;
style?: StyleProp<ViewStyle>;
}
const DEFAULT_CATEGORIES: Category[] = [
{ id: 'all', name: '全部' },
{ id: 'finance', name: '财经' },
{ id: 'entertainment', name: '娱乐' },
{ id: 'education', name: '教育' },
{ id: 'technology', name: '科技' },
{ id: 'sports', name: '体育' },
{ id: 'lifestyle', name: '生活' },
{ id: 'travel', name: '旅行' },
];
const CategoryTabs: React.FC<CategoryTabsProps> = ({
categories = DEFAULT_CATEGORIES,
activeId = 'all',
onChange,
style,
}) => {
const { width: screenWidth } = useWindowDimensions();
const activeIndex = useMemo(() => {
return categories.findIndex(cat => cat.id === activeId);
}, [categories, activeId]);
const indicatorPosition = useDerivedValue(() => {
if (categories.length === 0) return 0;
const activeIndexSafe = activeIndex >= 0 ? activeIndex : 0;
const padding = 16;
const gap = 8;
let offset = padding;
for (let i = 0; i < activeIndexSafe; i++) {
const item = categories[i];
const itemWidth = Math.min(
Math.max(44, item.name.length * 16 + 32),
screenWidth / 3
);
offset += itemWidth + gap;
}
return offset;
}, [activeIndex, categories, screenWidth]);
const activeCategory = categories[activeIndex] || categories[0];
const indicatorStyle = useAnimatedStyle(() => {
if (!activeCategory) {
return { width: 0, left: 0 };
}
const width = Math.min(
Math.max(44, activeCategory.name.length * 16 + 32),
screenWidth / 3
);
return {
width: withTiming(width, { duration: Animation.duration.normal }),
left: withTiming(indicatorPosition.value, { duration: Animation.duration.normal }),
};
});
const renderCategory = (category: Category, index: number) => {
const isActive = category.id === activeId;
return (
<TouchableOpacity
key={category.id}
style={styles.tabItem}
onPress={() => onChange?.(category)}
activeOpacity={0.7}
>
<Text style={[styles.tabText, isActive && styles.activeTabText]}>
{category.name}
</Text>
</TouchableOpacity>
);
};
return (
<View style={[styles.container, style]}>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.scrollContent}
decelerationRate="fast"
snapToInterval={1}
>
{categories.map(renderCategory)}
</ScrollView>
<Animated.View style={[styles.indicator, indicatorStyle]} />
</View>
);
};
const styles = StyleSheet.create({
container: {
backgroundColor: Colors.background.secondary,
width: '100%',
},
scrollContent: {
paddingHorizontal: Spacing.md,
paddingVertical: Spacing.sm,
gap: Spacing.sm,
},
tabItem: {
minWidth: 44,
paddingHorizontal: Spacing.md,
paddingVertical: Spacing.sm,
height: 40,
justifyContent: 'center',
alignItems: 'center',
},
tabText: {
fontSize: FontSize.sm,
color: Colors.text.secondary,
fontWeight: '500',
},
activeTabText: {
color: Colors.brand.primary,
fontWeight: '600',
},
indicator: {
position: 'absolute',
bottom: 0,
height: 2,
backgroundColor: Colors.brand.primary,
borderRadius: 1,
},
});
export default React.memo(CategoryTabs);