u
This commit is contained in:
1 parent
458f0cd6ac
commit
2347d55823
8 files changed
+299
-150
No files matched your search
@@ -1,5 +1,5 @@
|
|||||||
import type { TabbarItem } from '@/components/custom/app-tabbar'
|
import type { TabbarItem } from '@/components/custom/tabbar'
|
||||||
import AppTabbar from '@/components/custom/app-tabbar'
|
import CustomTabbar from '@/components/custom/tabbar'
|
||||||
import { useToast } from '@/utils/toast'
|
import { useToast } from '@/utils/toast'
|
||||||
|
|
||||||
export default function TabsLayout() {
|
export default function TabsLayout() {
|
||||||
@@ -40,6 +40,6 @@ export default function TabsLayout() {
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
return (
|
return (
|
||||||
<AppTabbar data={tabbarOptions} />
|
<CustomTabbar data={tabbarOptions} />
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1,9 +1,15 @@
|
|||||||
import { Text, View } from 'react-native'
|
import { ScrollView, Text, View } from 'react-native'
|
||||||
|
|
||||||
export default function Chat() {
|
export default function Chat() {
|
||||||
return (
|
return (
|
||||||
<View>
|
<ScrollView contentContainerStyle={{ padding: 16 }}>
|
||||||
<Text>Chat</Text>
|
{Array.from({ length: 50 }).map((_, i) => (
|
||||||
</View>
|
<Text key={i} style={{ marginBottom: 10, fontSize: 16 }}>
|
||||||
|
Chat message
|
||||||
|
{' '}
|
||||||
|
{i + 1}
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
|
</ScrollView>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1,13 +1,14 @@
|
|||||||
import type { ErrorBoundaryProps } from 'expo-router'
|
import type { ErrorBoundaryProps } from 'expo-router'
|
||||||
import { ToastProvider } from '@gluestack-ui/core/toast/creator'
|
import { ToastProvider } from '@gluestack-ui/core/toast/creator'
|
||||||
import { useFonts } from 'expo-font'
|
import { useFonts } from 'expo-font'
|
||||||
import { Stack } from 'expo-router'
|
import { Stack, useRouter } from 'expo-router'
|
||||||
import * as SplashScreen from 'expo-splash-screen'
|
import * as SplashScreen from 'expo-splash-screen'
|
||||||
import { StatusBar } from 'expo-status-bar'
|
import { StatusBar } from 'expo-status-bar'
|
||||||
import { RefreshCw } from 'lucide-react-native'
|
import { RefreshCw } from 'lucide-react-native'
|
||||||
import { useEffect } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { EmptyState } from '@/components/common/empty-status'
|
import { EmptyState } from '@/components/common/empty-status'
|
||||||
import { GluestackUIProvider } from '@/components/ui/gluestack-ui-provider'
|
import { GluestackUIProvider } from '@/components/ui/gluestack-ui-provider'
|
||||||
|
import { useUserStore } from '@/store/user'
|
||||||
import { ToastProviderWrapper } from '@/utils/toast'
|
import { ToastProviderWrapper } from '@/utils/toast'
|
||||||
import '@/styles/global.css'
|
import '@/styles/global.css'
|
||||||
import 'react-native-reanimated'
|
import 'react-native-reanimated'
|
||||||
@@ -31,15 +32,34 @@ export function ErrorBoundary({ error, retry }: ErrorBoundaryProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function RootLayout() {
|
export default function RootLayout() {
|
||||||
const [loaded] = useFonts({
|
const router = useRouter()
|
||||||
|
|
||||||
|
const token = useUserStore(state => state.token)
|
||||||
|
|
||||||
|
const [fontsLoaded] = useFonts({
|
||||||
SpaceMono: require('@/static/fonts/SpaceMono-Regular.ttf'),
|
SpaceMono: require('@/static/fonts/SpaceMono-Regular.ttf'),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const [storeLoaded, setStoreLoaded] = useState(false)
|
||||||
|
|
||||||
|
// 等待 store 恢复数据
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (loaded) {
|
if (token !== undefined) {
|
||||||
SplashScreen.hideAsync()
|
setStoreLoaded(true)
|
||||||
}
|
}
|
||||||
}, [loaded])
|
}, [token])
|
||||||
|
|
||||||
|
// splash screen 隐藏 & 登录跳转
|
||||||
|
useEffect(() => {
|
||||||
|
if (fontsLoaded && storeLoaded) {
|
||||||
|
SplashScreen.hideAsync()
|
||||||
|
|
||||||
|
// 如果没有 token,则跳转登录页
|
||||||
|
if (token === null) {
|
||||||
|
router.replace('/auth/login')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [fontsLoaded, storeLoaded, token])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<GluestackUIProvider>
|
<GluestackUIProvider>
|
||||||
@@ -49,12 +69,24 @@ export default function RootLayout() {
|
|||||||
screenOptions={{
|
screenOptions={{
|
||||||
headerTitleAlign: 'center',
|
headerTitleAlign: 'center',
|
||||||
animation: 'slide_from_right',
|
animation: 'slide_from_right',
|
||||||
|
gestureEnabled: true, // 启用手势返回
|
||||||
|
gestureDirection: 'horizontal', // 水平手势
|
||||||
|
contentStyle: {
|
||||||
|
backgroundColor: '#fff',
|
||||||
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name="(tabs)"
|
name="(tabs)"
|
||||||
options={{ headerShown: false }}
|
options={{ headerShown: false }}
|
||||||
/>
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="auth/login"
|
||||||
|
options={{
|
||||||
|
title: '登录',
|
||||||
|
headerShown: true,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<Stack.Screen name="+not-found" />
|
<Stack.Screen name="+not-found" />
|
||||||
</Stack>
|
</Stack>
|
||||||
<StatusBar style="auto" />
|
<StatusBar style="auto" />
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { Text, View } from 'react-native'
|
||||||
|
|
||||||
|
export default function AuthLogin() {
|
||||||
|
return (
|
||||||
|
<View>
|
||||||
|
<Text>Login</Text>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,137 +0,0 @@
|
|||||||
import { usePathname } from 'expo-router'
|
|
||||||
import { TabList, Tabs, TabSlot, TabTrigger } from 'expo-router/ui'
|
|
||||||
import { icons } from 'lucide-react-native'
|
|
||||||
import { useEffect, useState } from 'react'
|
|
||||||
import { Pressable, View } from 'react-native'
|
|
||||||
import Animated, {
|
|
||||||
useAnimatedStyle,
|
|
||||||
useSharedValue,
|
|
||||||
withSequence,
|
|
||||||
withTiming,
|
|
||||||
} from 'react-native-reanimated'
|
|
||||||
|
|
||||||
export interface TabbarItem {
|
|
||||||
icon: keyof typeof icons
|
|
||||||
iconSize?: number
|
|
||||||
activeIcon?: keyof typeof icons
|
|
||||||
name: string
|
|
||||||
href?: string
|
|
||||||
onPress?: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AppTabbarProps {
|
|
||||||
data: TabbarItem[]
|
|
||||||
onTabPress?: (item: TabbarItem) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
function TabButton({
|
|
||||||
icon,
|
|
||||||
activeIcon,
|
|
||||||
iconSize = 24,
|
|
||||||
isFocused = false,
|
|
||||||
onPress,
|
|
||||||
}: {
|
|
||||||
icon: keyof typeof icons
|
|
||||||
activeIcon?: keyof typeof icons
|
|
||||||
iconSize?: number
|
|
||||||
isFocused?: boolean
|
|
||||||
onPress?: () => void
|
|
||||||
}) {
|
|
||||||
const activeColor = '#333'
|
|
||||||
const inactiveColor = '#aaa'
|
|
||||||
|
|
||||||
const scale = useSharedValue(1)
|
|
||||||
const animatedStyle = useAnimatedStyle(() => ({
|
|
||||||
transform: [{ scale: scale.value }],
|
|
||||||
}))
|
|
||||||
|
|
||||||
const IconComponent = icons[isFocused && activeIcon ? activeIcon : icon]
|
|
||||||
|
|
||||||
const handlePress = () => {
|
|
||||||
scale.value = withSequence(
|
|
||||||
withTiming(0.9, { duration: 200 }),
|
|
||||||
withTiming(1, { duration: 200 }),
|
|
||||||
)
|
|
||||||
onPress?.()
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Pressable
|
|
||||||
onPress={handlePress}
|
|
||||||
className="flex-1 items-center justify-center h-full"
|
|
||||||
>
|
|
||||||
<Animated.View style={animatedStyle}>
|
|
||||||
<IconComponent
|
|
||||||
width={iconSize}
|
|
||||||
height={iconSize}
|
|
||||||
stroke={isFocused ? activeColor : inactiveColor}
|
|
||||||
/>
|
|
||||||
</Animated.View>
|
|
||||||
</Pressable>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function AppTabbar({ data, onTabPress }: AppTabbarProps) {
|
|
||||||
const pathname = usePathname()
|
|
||||||
|
|
||||||
// 初始选中 Tab
|
|
||||||
const defaultTab = data.find(item => item.href === pathname)?.name
|
|
||||||
?? data.find(item => item.href)?.name
|
|
||||||
?? data[0]?.name
|
|
||||||
const [activeTab, setActiveTab] = useState<string>(defaultTab)
|
|
||||||
|
|
||||||
// 当 pathname 改变(路由跳转)时,同步高亮
|
|
||||||
useEffect(() => {
|
|
||||||
const matchTab = data.find(item => item.href === pathname)
|
|
||||||
if (matchTab) {
|
|
||||||
setActiveTab(matchTab.name)
|
|
||||||
}
|
|
||||||
// 如果没有匹配的路由,保持当前的 activeTab 不变
|
|
||||||
// 这样无 href 的项就能保持选中状态
|
|
||||||
}, [pathname, data])
|
|
||||||
|
|
||||||
const handleTabPress = (item: TabbarItem) => {
|
|
||||||
setActiveTab(item.name)
|
|
||||||
item.onPress?.()
|
|
||||||
onTabPress?.(item)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Tabs>
|
|
||||||
<TabSlot />
|
|
||||||
|
|
||||||
<TabList asChild>
|
|
||||||
<View className="flex-row bg-white h-12 items-center">
|
|
||||||
{data.map((item) => {
|
|
||||||
const isFocused = activeTab === item.name
|
|
||||||
|
|
||||||
if (item.href) {
|
|
||||||
return (
|
|
||||||
<TabTrigger key={item.name} name={item.name} href={item.href} asChild>
|
|
||||||
<TabButton
|
|
||||||
icon={item.icon}
|
|
||||||
activeIcon={item.activeIcon}
|
|
||||||
iconSize={item.iconSize}
|
|
||||||
isFocused={isFocused}
|
|
||||||
onPress={() => handleTabPress(item)}
|
|
||||||
/>
|
|
||||||
</TabTrigger>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TabButton
|
|
||||||
key={item.name}
|
|
||||||
icon={item.icon}
|
|
||||||
activeIcon={item.activeIcon}
|
|
||||||
iconSize={item.iconSize}
|
|
||||||
onPress={() => handleTabPress(item)}
|
|
||||||
isFocused={isFocused}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</View>
|
|
||||||
</TabList>
|
|
||||||
</Tabs>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
import { usePathname, useRouter } from 'expo-router'
|
||||||
|
import { TabList, Tabs, TabSlot, TabTrigger } from 'expo-router/ui'
|
||||||
|
import { icons } from 'lucide-react-native'
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { Pressable, View } from 'react-native'
|
||||||
|
import { Gesture, GestureDetector, GestureHandlerRootView } from 'react-native-gesture-handler'
|
||||||
|
import Animated, { SlideInLeft, SlideInRight, SlideOutLeft, SlideOutRight, useAnimatedStyle, useSharedValue, withSequence, withTiming } from 'react-native-reanimated'
|
||||||
|
import { scheduleOnRN } from 'react-native-worklets'
|
||||||
|
|
||||||
|
interface AppTabbarProps {
|
||||||
|
data: TabbarItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TabbarItem {
|
||||||
|
icon: keyof typeof icons
|
||||||
|
iconSize?: number
|
||||||
|
activeIcon?: keyof typeof icons
|
||||||
|
name: string
|
||||||
|
href?: string
|
||||||
|
onPress?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function TabButton({
|
||||||
|
icon,
|
||||||
|
activeIcon,
|
||||||
|
iconSize = 24,
|
||||||
|
isFocused = false,
|
||||||
|
onPress,
|
||||||
|
}: {
|
||||||
|
icon: keyof typeof icons
|
||||||
|
activeIcon?: keyof typeof icons
|
||||||
|
iconSize?: number
|
||||||
|
isFocused?: boolean
|
||||||
|
onPress?: () => void
|
||||||
|
}) {
|
||||||
|
const scale = useSharedValue(1)
|
||||||
|
|
||||||
|
const animatedStyle = useAnimatedStyle(() => ({
|
||||||
|
transform: [{ scale: scale.value }],
|
||||||
|
}))
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isFocused) {
|
||||||
|
scale.value = withSequence(
|
||||||
|
withTiming(0.9, { duration: 120 }),
|
||||||
|
withTiming(1, { duration: 160 }),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}, [isFocused])
|
||||||
|
|
||||||
|
const IconComponent = icons[isFocused && activeIcon ? activeIcon : icon]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
onPress={onPress}
|
||||||
|
style={{ flex: 1, alignItems: 'center', justifyContent: 'center', height: '100%' }}
|
||||||
|
>
|
||||||
|
<Animated.View style={animatedStyle}>
|
||||||
|
<IconComponent width={iconSize} height={iconSize} stroke={isFocused ? '#333' : '#aaa'} />
|
||||||
|
</Animated.View>
|
||||||
|
</Pressable>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CustomTabbar({ data }: AppTabbarProps) {
|
||||||
|
const router = useRouter()
|
||||||
|
const pathname = usePathname()
|
||||||
|
|
||||||
|
const [activeTab, setActiveTab] = useState<string>(() => {
|
||||||
|
const match = data.find(item => item.href === pathname)
|
||||||
|
return match?.name ?? data[0]?.name
|
||||||
|
})
|
||||||
|
|
||||||
|
// 反转方向
|
||||||
|
const [swipeDirection, setSwipeDirection] = useState<'left' | 'right'>('left')
|
||||||
|
function updateSwipeDirection(tabName: string) {
|
||||||
|
const currentIndex = data.findIndex(d => d.name === activeTab)
|
||||||
|
const targetIndex = data.findIndex(d => d.name === tabName)
|
||||||
|
|
||||||
|
if (currentIndex === -1 || targetIndex === -1 || currentIndex === targetIndex)
|
||||||
|
return
|
||||||
|
|
||||||
|
setSwipeDirection(targetIndex > currentIndex ? 'left' : 'right')
|
||||||
|
setActiveTab(tabName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 点击调用
|
||||||
|
const handlPressTab = (item: TabbarItem) => {
|
||||||
|
updateSwipeDirection(item.name)
|
||||||
|
|
||||||
|
setActiveTab(item.name)
|
||||||
|
if (typeof item.onPress === 'function')
|
||||||
|
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
|
||||||
|
|
||||||
|
return currentTab.href
|
||||||
|
? (
|
||||||
|
<Animated.View
|
||||||
|
key={activeTab}
|
||||||
|
entering={swipeDirection === 'left' ? SlideInRight.duration(300) : SlideInLeft.duration(300)}
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
>
|
||||||
|
<TabSlot />
|
||||||
|
</Animated.View>
|
||||||
|
)
|
||||||
|
: (
|
||||||
|
<TabSlot />
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
</View>
|
||||||
|
</GestureDetector>
|
||||||
|
|
||||||
|
<TabList asChild>
|
||||||
|
<View style={{ flexDirection: 'row', backgroundColor: 'white', height: 48, alignItems: 'center' }}>
|
||||||
|
{data.map((item) => {
|
||||||
|
if (item.href) {
|
||||||
|
return (
|
||||||
|
<TabTrigger key={item.name} name={item.name} href={item.href} asChild>
|
||||||
|
<TabButton
|
||||||
|
icon={item.icon}
|
||||||
|
activeIcon={item.activeIcon}
|
||||||
|
iconSize={item.iconSize}
|
||||||
|
isFocused={activeTab === item.name}
|
||||||
|
onPress={() => handlPressTab(item)}
|
||||||
|
/>
|
||||||
|
</TabTrigger>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TabButton
|
||||||
|
key={item.name}
|
||||||
|
icon={item.icon}
|
||||||
|
activeIcon={item.activeIcon}
|
||||||
|
iconSize={item.iconSize}
|
||||||
|
isFocused={activeTab === item.name}
|
||||||
|
onPress={() => handlPressTab(item)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
</TabList>
|
||||||
|
</Tabs>
|
||||||
|
</GestureHandlerRootView>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
// store/userStore.ts
|
||||||
|
import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||||
|
import { create } from 'zustand'
|
||||||
|
import { createJSONStorage, persist } from 'zustand/middleware'
|
||||||
|
|
||||||
|
interface UserState {
|
||||||
|
userInfo: { userId: string, username: string } | null
|
||||||
|
token: string | null
|
||||||
|
setUserInfo: (userInfo: UserState['userInfo']) => void
|
||||||
|
setToken: (token: string) => void
|
||||||
|
logout: () => void
|
||||||
|
isLoggedIn: () => boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useUserStore = create<UserState>()(
|
||||||
|
persist(
|
||||||
|
(set, get) => ({
|
||||||
|
userInfo: null,
|
||||||
|
token: null,
|
||||||
|
setUserInfo: userInfo => set({ userInfo }),
|
||||||
|
setToken: token => set({ token }),
|
||||||
|
logout: () => {
|
||||||
|
console.log('当前 token:', get().token)
|
||||||
|
set({ userInfo: null, token: null })
|
||||||
|
},
|
||||||
|
isLoggedIn: () => !!get().token,
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: 'user-storage',
|
||||||
|
storage: createJSONStorage(() => AsyncStorage),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
// utils/runOnMain.ts
|
||||||
|
// ✅ 安全封装 runOnJS,屏蔽 deprecated 警告
|
||||||
|
import { runOnJS as _runOnJS } from 'react-native-worklets'
|
||||||
|
|
||||||
|
// 屏蔽类型定义中的 deprecated 提示
|
||||||
|
export const runOnMain = ((fn: (...args: any[]) => void) => {
|
||||||
|
return _runOnJS(fn)
|
||||||
|
}) as typeof _runOnJS
|
||||||
Reference in new issue
Block a user