u
This commit is contained in:
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' }}>
|
||||
|
||||
Reference in new issue
Block a user