This commit is contained in:
oneao committed 2025-11-19 17:30:44 +08:00
1 parent 7419021927
commit 49285f1df2
16 files changed
+1184 -283

No files matched your search

@@ -13,22 +13,26 @@ interface CustomHeaderProps {
left?: React.ReactNode
right?: React.ReactNode
safeArea?: boolean
screenProps?: StackScreenProps
className?: string
style?: ViewStyle
}
export default function CustomHeader({
showHeader = true,
title,
left,
right,
safeArea = true,
className = '',
style,
screenProps,
}: CustomHeaderProps) {
const safeAreaInsets = useSafeAreaInsets()
export default function CustomHeader(props: CustomHeaderProps) {
const {
showHeader = true,
title,
left,
right,
safeArea = true,
screenProps,
className = '',
style,
} = props
const insets = useSafeAreaInsets()
const router = useRouter()
if (!showHeader) {
@@ -37,56 +41,63 @@ export default function CustomHeader({
{...screenProps}
options={{
...screenProps?.options,
headerShown: false,
headerShown: true,
header: () => (
<View
className={`${className}`}
style={[
{ paddingTop: safeArea ? insets.top : 0 },
style,
]}
/>
),
}}
/>
)
}
return (
<>
<Stack.Screen
{...screenProps}
options={{
...screenProps?.options,
headerShown: false,
}}
/>
<View
className={`flex-row items-center h-24 px-2 ${className}`}
style={[
{ paddingTop: safeArea ? safeAreaInsets.top : 0 },
style,
]}
>
<View className="w-10 h-full justify-center">
{left || (
<TouchableOpacity onPress={() => router.back()}>
<LucideIcon icon="ArrowLeft" size={24} clickable={false} />
</TouchableOpacity>
)}
</View>
<View className="flex-1 items-center justify-center">
{typeof title === 'string'
? (
<Text className="text-xl">{title}</Text>
)
: (
title
<Stack.Screen
{...screenProps}
options={{
...screenProps?.options,
headerShown: true,
header: () => (
<View
className={`flex-row items-center h-24 px-2 ${className}`}
style={[
{ paddingTop: safeArea ? insets.top : 0 },
style,
]}
>
{/* 左侧 */}
<View className="w-10 h-full justify-center">
{left || (
<TouchableOpacity onPress={() => router.back()}>
<LucideIcon icon="ArrowLeft" size={24} clickable={false} />
</TouchableOpacity>
)}
</View>
</View>
<View className="w-10 h-full items-center justify-center">
{right || (
<Image
source={require('@/static/images/logo.png')}
style={{ width: 22, height: 22 }}
/>
)}
</View>
</View>
</>
{/* 标题 */}
<View className="flex-1 items-center justify-center">
{typeof title === 'string'
? <Text className="text-xl">{title}</Text>
: title}
</View>
{/* 右侧 */}
<View className="w-10 h-full items-center justify-center">
{right || (
<Image
source={require('@/static/images/logo.png')}
style={{ width: 22, height: 22 }}
/>
)}
</View>
</View>
),
}}
/>
)
}
@@ -0,0 +1,130 @@
import type { ViewStyle } from 'react-native'
import * as React from 'react'
import { Animated, Text, TouchableOpacity, View } from 'react-native'
import PagerView from 'react-native-pager-view'
export interface PagerRoute {
key: string
title: string
element?: React.ReactNode
}
interface PagerTabsProps {
routes: PagerRoute[]
left?: React.ReactNode
right?: React.ReactNode
initialIndex?: number
onChange?: (key: string) => void
indicatorWidth?: number
className?: string
style?: ViewStyle
}
export default function PagerTabs({
routes,
left,
right,
initialIndex = 0,
onChange,
indicatorWidth = 38,
className,
style,
}: PagerTabsProps) {
const pagerRef = React.useRef<PagerView>(null)
const [index, setIndex] = React.useState(initialIndex)
const [tabWidth, setTabWidth] = React.useState(0)
const scrollX = React.useRef(new Animated.Value(0)).current
const onTabPress = (i: number) => {
pagerRef.current?.setPage(i)
}
const onPageScroll = (e: any) => {
const { position, offset } = e.nativeEvent
scrollX.setValue(position + offset)
}
const onPageSelected = (position: number) => {
setIndex(position)
if (onChange && routes[position]) {
onChange(routes[position].key)
}
}
const tabWidthPerTab = tabWidth / routes.length
const indicatorTranslateX = scrollX.interpolate({
inputRange: routes.map((_, i) => i),
outputRange: routes.map(
(_, i) => i * tabWidthPerTab + (tabWidthPerTab - indicatorWidth) / 2,
),
extrapolate: 'clamp',
})
return (
<View
className={`${className} flex-1`}
style={[
style,
]}
>
{/* Header */}
<View className="flex flex-row items-center">
{left}
{/* Tabs */}
<View
className="flex-1 flex-row relative"
onLayout={(e) => {
const w = e.nativeEvent.layout.width
if (w !== tabWidth)
setTabWidth(w)
}}
>
{routes.map((r, i) => (
<TouchableOpacity
key={r.key}
className="flex-1 items-center justify-center py-1"
onPress={() => onTabPress(i)}
>
<Text
className={`text-lg ${index === i ? 'text-[#333] font-bold' : 'text-gray-500'}`}
>
{r.title}
</Text>
</TouchableOpacity>
))}
{tabWidth > 0 && (
<Animated.View
className="absolute bottom-0 h-0.5 bg-black"
style={{
width: indicatorWidth,
transform: [{ translateX: indicatorTranslateX }],
}}
/>
)}
</View>
{right}
</View>
{/* Pager */}
<PagerView
ref={pagerRef}
style={{ flex: 1 }}
initialPage={initialIndex}
onPageScroll={onPageScroll}
onPageSelected={e => onPageSelected(e.nativeEvent.position)}
>
{routes.map(r => (
<View key={r.key} className="flex-1">
{r.element ?? null}
</View>
))}
</PagerView>
</View>
)
}
@@ -0,0 +1,168 @@
import type { ReactNode } from 'react'
import type { LayoutChangeEvent } from 'react-native'
import React, { createContext, useContext, useEffect, useRef, useState } from 'react'
import {
Animated,
LayoutChangeEvent as RNLayoutChangeEvent,
ScrollView,
StyleSheet,
Text,
TouchableOpacity,
View,
} from 'react-native'
import PagerView from 'react-native-pager-view'
interface TabsContextType {
pagerRef: React.RefObject<PagerView>
scrollX: Animated.Value
index: number
setIndex: (i: number) => void
}
const TabsContext = createContext<TabsContextType | null>(null)
interface TabsProviderProps {
children: ReactNode
}
export function TabsProvider({ children }: TabsProviderProps) {
const pagerRef = useRef<PagerView>(null!)
const scrollX = useRef(new Animated.Value(0)).current
const [index, setIndex] = useState(0)
return (
<TabsContext.Provider value={{ pagerRef, scrollX, index, setIndex }}>
{children}
</TabsContext.Provider>
)
}
// ---------------- Tabs (Header) ----------------
interface TabsProps {
tabs: string[]
leftContent?: ReactNode
rightContent?: ReactNode
}
function useSafeTabsContext(): TabsContextType {
const ctx = useContext(TabsContext)
if (!ctx) {
const pagerRef = useRef<PagerView>(null!)
const scrollX = useRef(new Animated.Value(0)).current
const [index, setIndex] = useState(0)
return { pagerRef, scrollX, index, setIndex }
}
return ctx
}
TabsProvider.Tabs = function Tabs({ tabs, leftContent, rightContent }: TabsProps) {
const ctx = useSafeTabsContext()
const tabLayouts = useRef<{ x: number, width: number }[]>([])
const scrollRef = useRef<ScrollView>(null)
const onTabLayout = (e: LayoutChangeEvent, i: number) => {
const { x, width } = e.nativeEvent.layout
tabLayouts.current[i] = { x, width }
}
const measured = tabLayouts.current.length === tabs.length
const underlineX = measured
? ctx.scrollX.interpolate({
inputRange: tabs.map((_, i) => i),
outputRange: tabLayouts.current.map(t => t.x),
})
: 0
const underlineWidth = measured
? ctx.scrollX.interpolate({
inputRange: tabs.map((_, i) => i),
outputRange: tabLayouts.current.map(t => t.width),
})
: 0
// 自动滚动 ScrollView 保持选中 Tab 可见
useEffect(() => {
if (!measured || !scrollRef.current)
return
const layout = tabLayouts.current[ctx.index]
if (!layout)
return
scrollRef.current.scrollTo({ x: Math.max(layout.x - 50, 0), animated: true })
}, [ctx.index, measured])
return (
<View style={{ flexDirection: 'row', alignItems: 'center', borderBottomWidth: 1, borderColor: '#eee' }}>
{leftContent && <View>{leftContent}</View>}
<ScrollView
ref={scrollRef}
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={{ flexDirection: 'row', position: 'relative' }}
style={{ flex: 1 }}
>
{tabs.map((tab, i) => (
<TouchableOpacity
key={i}
onPress={() => {
ctx.pagerRef.current?.setPage(i)
ctx.setIndex(i)
}}
onLayout={e => onTabLayout(e, i)}
style={{ paddingHorizontal: 20, paddingVertical: 12 }}
>
<Text style={{ color: ctx.index === i ? '#000' : '#999' }}>{tab}</Text>
</TouchableOpacity>
))}
{measured && (
<Animated.View
style={[
styles.underline,
{ width: underlineWidth, transform: [{ translateX: underlineX }] },
]}
/>
)}
</ScrollView>
{rightContent && <View>{rightContent}</View>}
</View>
)
}
// ---------------- Content ----------------
TabsProvider.Content = function Content({ tabs }: { tabs: string[] }) {
const ctx = useSafeTabsContext()
return (
<PagerView
ref={ctx.pagerRef}
style={{ flex: 1 }}
initialPage={0}
onPageScroll={e =>
ctx.scrollX.setValue(e.nativeEvent.position + e.nativeEvent.offset)}
onPageSelected={e => ctx.setIndex(e.nativeEvent.position)}
>
{tabs.map((tab, i) => (
<View key={i} style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
<Text style={{ fontSize: 20 }}>
{tab}
{' '}
页面内容
</Text>
</View>
))}
</PagerView>
)
}
const styles = StyleSheet.create({
underline: {
position: 'absolute',
bottom: 0,
height: 2,
backgroundColor: '#007AFF',
},
})
@@ -94,70 +94,27 @@ export default function CustomTabbar({ data }: AppTabbarProps) {
item.onPress()
}
// 定义 JS 线程安全函数
const handlePanTab = (tabName: string) => {
const tab = data.find(item => item.name === tabName)
if (!tab)
return
updateSwipeDirection(tabName)
setActiveTab(tab.name)
if (typeof tab.onPress === 'function') {
tab.onPress()
}
if (tab.href && tab.href !== pathname) {
router.push(tab.href)
}
}
// 手势滑动
const panGesture = Gesture.Pan()
.activeOffsetX([-20, 20])
.failOffsetY([-10, 10])
.onEnd((event) => {
const threshold = 60
const currentIndex = data.findIndex(item => item.name === activeTab)
let targetIndex: number | undefined
if (event.translationX < -threshold && currentIndex < data.length - 1) {
targetIndex = currentIndex + 1
}
else if (event.translationX > threshold && currentIndex > 0) {
targetIndex = currentIndex - 1
}
if (targetIndex !== undefined) {
const tabName = data[targetIndex].name
scheduleOnRN(handlePanTab, tabName)
}
})
return (
<GestureHandlerRootView style={{ flex: 1 }}>
<Tabs>
<GestureDetector gesture={panGesture}>
<View style={{ flex: 1 }}>
{(() => {
const currentTab = data.find(d => d.name === activeTab)
if (!currentTab)
return null
<View style={{ flex: 1 }}>
{(() => {
const currentTab = data.find(d => d.name === activeTab)
if (!currentTab)
return null
return (
<Animated.View
style={{
flex: 1,
backgroundColor: 'transparent',
}}
>
<TabSlot />
</Animated.View>
)
})()}
</View>
</GestureDetector>
return (
<Animated.View
style={{
flex: 1,
backgroundColor: 'transparent',
}}
>
<TabSlot />
</Animated.View>
)
})()}
</View>
<TabList asChild>
<View style={{ flexDirection: 'row', height: 48, alignItems: 'center' }}>