172 lines
4.1 KiB
TypeScript
172 lines
4.1 KiB
TypeScript
import React, { useRef, useEffect, useCallback } from 'react'
|
|
import {
|
|
View,
|
|
Text,
|
|
Pressable,
|
|
ScrollView,
|
|
StyleSheet,
|
|
ViewStyle,
|
|
Dimensions,
|
|
LayoutChangeEvent,
|
|
} from 'react-native'
|
|
import { DownArrowIcon } from '@/components/icon'
|
|
|
|
const SCREEN_WIDTH = Dimensions.get('window').width
|
|
const ARROW_WIDTH = 48 // 箭头按钮宽度 (padding 8 * 2 + icon ~24 + marginLeft 8)
|
|
const HORIZONTAL_PADDING = 16
|
|
|
|
interface TabNavigationProps {
|
|
tabs: string[]
|
|
activeIndex: number
|
|
onTabPress: (index: number) => void
|
|
showArrow?: boolean
|
|
onArrowPress?: () => void
|
|
isSticky?: boolean
|
|
wrapperStyle?: ViewStyle
|
|
onLayout?: (y: number, height: number) => void
|
|
}
|
|
|
|
export function TabNavigation({
|
|
tabs,
|
|
activeIndex,
|
|
onTabPress,
|
|
showArrow = false,
|
|
onArrowPress,
|
|
isSticky = false,
|
|
wrapperStyle,
|
|
onLayout,
|
|
}: TabNavigationProps): React.ReactNode {
|
|
const scrollViewRef = useRef<ScrollView>(null)
|
|
const tabLayouts = useRef<{ x: number; width: number }[]>([])
|
|
|
|
// 计算可用的滚动区域宽度
|
|
const scrollAreaWidth = SCREEN_WIDTH - HORIZONTAL_PADDING * 2 - (showArrow ? ARROW_WIDTH : 0)
|
|
|
|
// 滚动到激活的 tab 使其居中
|
|
const scrollToActiveTab = useCallback((index: number) => {
|
|
const layout = tabLayouts.current[index]
|
|
if (!layout || !scrollViewRef.current) return
|
|
|
|
// 计算让 tab 居中需要的滚动位置
|
|
const tabCenter = layout.x + layout.width / 2
|
|
const scrollX = tabCenter - scrollAreaWidth / 2
|
|
|
|
scrollViewRef.current.scrollTo({
|
|
x: Math.max(0, scrollX),
|
|
animated: true,
|
|
})
|
|
}, [scrollAreaWidth])
|
|
|
|
// 当 activeIndex 变化时滚动
|
|
useEffect(() => {
|
|
// 延迟执行确保布局已完成
|
|
const timer = setTimeout(() => {
|
|
scrollToActiveTab(activeIndex)
|
|
}, 50)
|
|
return () => clearTimeout(timer)
|
|
}, [activeIndex, scrollToActiveTab])
|
|
|
|
// 记录每个 tab 的布局
|
|
const handleTabLayout = useCallback((index: number, event: LayoutChangeEvent) => {
|
|
const { x, width } = event.nativeEvent.layout
|
|
tabLayouts.current[index] = { x, width }
|
|
}, [])
|
|
|
|
return (
|
|
<View
|
|
testID="tab-navigation"
|
|
style={[
|
|
styles.tabsWrapper,
|
|
isSticky && styles.stickyWrapper,
|
|
wrapperStyle,
|
|
]}
|
|
onLayout={(e) => {
|
|
const { y, height } = e.nativeEvent.layout
|
|
onLayout?.(y, height)
|
|
}}
|
|
>
|
|
<View style={styles.tabsContainer}>
|
|
<ScrollView
|
|
ref={scrollViewRef}
|
|
horizontal
|
|
showsHorizontalScrollIndicator={false}
|
|
contentContainerStyle={styles.tabsScrollContent}
|
|
>
|
|
{tabs.map((tab, index) => (
|
|
<Pressable
|
|
key={index}
|
|
testID={`tab-${index}`}
|
|
style={[styles.tab, activeIndex === index && styles.activeTab]}
|
|
onPress={() => onTabPress(index)}
|
|
onLayout={(e) => handleTabLayout(index, e)}
|
|
>
|
|
<Text
|
|
style={[
|
|
styles.tabText,
|
|
activeIndex === index && styles.activeTabText,
|
|
]}
|
|
>
|
|
{tab}
|
|
</Text>
|
|
</Pressable>
|
|
))}
|
|
</ScrollView>
|
|
{showArrow && (
|
|
<Pressable
|
|
testID="tab-arrow"
|
|
style={styles.tabArrow}
|
|
onPress={onArrowPress}
|
|
>
|
|
<DownArrowIcon />
|
|
</Pressable>
|
|
)}
|
|
</View>
|
|
</View>
|
|
)
|
|
}
|
|
|
|
const styles = StyleSheet.create({
|
|
tabsWrapper: {
|
|
paddingHorizontal: 16,
|
|
paddingVertical: 8,
|
|
backgroundColor: '#090A0B',
|
|
},
|
|
stickyWrapper: {
|
|
position: 'absolute',
|
|
top: 0,
|
|
left: 0,
|
|
right: 0,
|
|
zIndex: 100,
|
|
},
|
|
tabsContainer: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
},
|
|
tabsScrollContent: {
|
|
gap: 8,
|
|
},
|
|
tab: {
|
|
paddingHorizontal: 16,
|
|
paddingVertical: 8,
|
|
borderRadius: 20,
|
|
backgroundColor: '#1C1E22',
|
|
},
|
|
activeTab: {
|
|
backgroundColor: '#F5F5F5',
|
|
},
|
|
tabText: {
|
|
color: '#F5F5F5',
|
|
fontSize: 14,
|
|
fontWeight: '500',
|
|
},
|
|
activeTabText: {
|
|
color: '#090A0B',
|
|
},
|
|
tabArrow: {
|
|
marginLeft: 8,
|
|
padding: 8,
|
|
backgroundColor: '#1C1E22',
|
|
borderRadius: 20,
|
|
},
|
|
})
|