This commit is contained in:
oneao committed 2025-11-03 17:30:08 +08:00
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 AppTabbar from '@/components/custom/app-tabbar'
import type { TabbarItem } from '@/components/custom/tabbar'
import CustomTabbar from '@/components/custom/tabbar'
import { useToast } from '@/utils/toast'
export default function TabsLayout() {
@@ -40,6 +40,6 @@ export default function TabsLayout() {
},
]
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() {
return (
<View>
<Text>Chat</Text>
</View>
<ScrollView contentContainerStyle={{ padding: 16 }}>
{Array.from({ length: 50 }).map((_, i) => (
<Text key={i} style={{ marginBottom: 10, fontSize: 16 }}>
Chat message
{' '}
{i + 1}
</Text>
))}
</ScrollView>
)
}
+38 -6
View File
@@ -1,13 +1,14 @@
import type { ErrorBoundaryProps } from 'expo-router'
import { ToastProvider } from '@gluestack-ui/core/toast/creator'
import { useFonts } from 'expo-font'
import { Stack } from 'expo-router'
import { Stack, useRouter } from 'expo-router'
import * as SplashScreen from 'expo-splash-screen'
import { StatusBar } from 'expo-status-bar'
import { RefreshCw } from 'lucide-react-native'
import { useEffect } from 'react'
import { useEffect, useState } from 'react'
import { EmptyState } from '@/components/common/empty-status'
import { GluestackUIProvider } from '@/components/ui/gluestack-ui-provider'
import { useUserStore } from '@/store/user'
import { ToastProviderWrapper } from '@/utils/toast'
import '@/styles/global.css'
import 'react-native-reanimated'
@@ -31,15 +32,34 @@ export function ErrorBoundary({ error, retry }: ErrorBoundaryProps) {
}
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'),
})
const [storeLoaded, setStoreLoaded] = useState(false)
// 等待 store 恢复数据
useEffect(() => {
if (loaded) {
SplashScreen.hideAsync()
if (token !== undefined) {
setStoreLoaded(true)
}
}, [loaded])
}, [token])
// splash screen 隐藏 & 登录跳转
useEffect(() => {
if (fontsLoaded && storeLoaded) {
SplashScreen.hideAsync()
// 如果没有 token,则跳转登录页
if (token === null) {
router.replace('/auth/login')
}
}
}, [fontsLoaded, storeLoaded, token])
return (
<GluestackUIProvider>
@@ -49,12 +69,24 @@ export default function RootLayout() {
screenOptions={{
headerTitleAlign: 'center',
animation: 'slide_from_right',
gestureEnabled: true, // 启用手势返回
gestureDirection: 'horizontal', // 水平手势
contentStyle: {
backgroundColor: '#fff',
},
}}
>
<Stack.Screen
name="(tabs)"
options={{ headerShown: false }}
/>
<Stack.Screen
name="auth/login"
options={{
title: '登录',
headerShown: true,
}}
/>
<Stack.Screen name="+not-found" />
</Stack>
<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