expo-popcore-app/components/blocks/home/TabNavigation.tsx

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,
},
})