diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx index d967f46..87ebdda 100644 --- a/app/(tabs)/index.tsx +++ b/app/(tabs)/index.tsx @@ -12,6 +12,7 @@ import { Dimensions, } from 'react-native' import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context' +import { PanGestureHandler } from 'react-native-gesture-handler' import { TitleBar, HeroSlider, TabNavigation, TemplateCard, TemplateGrid } from '@/components/blocks/home' import ErrorState from '@/components/ErrorState' @@ -25,6 +26,7 @@ import { useTemplateFilter } from '@/hooks/use-template-filter' import { useUserBalance } from '@/hooks/use-user-balance' import { useTemplateLike, useTemplateFavorite } from '@/hooks' import { useTemplateSocialStore } from '@/stores/templateSocialStore' +import { useSwipeNavigation } from '@/hooks/use-swipe-navigation' import { root } from '@repo/core' import { TemplateSocialController } from '@repo/sdk' import { handleError } from '@/hooks/use-error' @@ -160,6 +162,26 @@ export default function HomeScreen() { } }, [loadingMore, hasMore, isLoading, loadMore]) + // 左右滑动切换分类 + const handleSwipeLeft = useCallback(() => { + if (activeIndex < tabs.length - 1) { + selectTab(activeIndex + 1) + } + }, [activeIndex, tabs.length, selectTab]) + + const handleSwipeRight = useCallback(() => { + if (activeIndex > 0) { + selectTab(activeIndex - 1) + } + }, [activeIndex, selectTab]) + + const { handleGestureEvent, handleGestureStateChange } = useSwipeNavigation({ + onSwipeLeft: handleSwipeLeft, + onSwipeRight: handleSwipeRight, + canSwipeLeft: activeIndex < tabs.length - 1, + canSwipeRight: activeIndex > 0, + }) + // 导航处理 - 使用 useCallback memoize const handlePointsPress = useCallback(() => router.push('/membership' as any), [router]) const handleSearchPress = useCallback(() => router.push('/searchTemplate'), [router]) @@ -377,41 +399,49 @@ export default function HomeScreen() { )} - handleScroll(e.nativeEvent.contentOffset.y)} - scrollEventThrottle={Platform.OS === 'ios' ? 16 : 50} - onEndReached={handleEndReached} - onEndReachedThreshold={0.5} - ListHeaderComponent={ListHeaderComponent} - ListFooterComponent={ListFooterComponent} - removeClippedSubviews={Platform.OS === 'android'} - maxToRenderPerBatch={12} - windowSize={5} - initialNumToRender={12} - getItemLayout={(_, index) => ({ - length: CARD_WIDTH * 1.5, - offset: CARD_WIDTH * 1.5 * Math.floor(index / NUM_COLUMNS), - index, - })} - refreshControl={ - + + handleScroll(e.nativeEvent.contentOffset.y)} + scrollEventThrottle={Platform.OS === 'ios' ? 16 : 50} + onEndReached={handleEndReached} + onEndReachedThreshold={0.5} + ListHeaderComponent={ListHeaderComponent} + ListFooterComponent={ListFooterComponent} + removeClippedSubviews={Platform.OS === 'android'} + maxToRenderPerBatch={12} + windowSize={5} + initialNumToRender={12} + getItemLayout={(_, index) => ({ + length: CARD_WIDTH * 1.5, + offset: CARD_WIDTH * 1.5 * Math.floor(index / NUM_COLUMNS), + index, + })} + refreshControl={ + + } /> - } - /> + + ) } diff --git a/app/(tabs)/message.tsx b/app/(tabs)/message.tsx index 8ae1399..6665c2e 100644 --- a/app/(tabs)/message.tsx +++ b/app/(tabs)/message.tsx @@ -12,16 +12,18 @@ import { StatusBar } from 'expo-status-bar' import { SafeAreaView } from 'react-native-safe-area-context' import { useTranslation } from 'react-i18next' import { useRouter, useFocusEffect } from 'expo-router' +import { PanGestureHandler } from 'react-native-gesture-handler' import { useMessages, type Message } from '@/hooks/use-messages' import { useMessageActions } from '@/hooks/use-message-actions' import { useAnnouncements } from '@/hooks/use-announcements' +import { useSwipeNavigation } from '@/hooks/use-swipe-navigation' import LoadingState from '@/components/LoadingState' import ErrorState from '@/components/ErrorState' import PaginationLoader from '@/components/PaginationLoader' import { MessageCard } from '@/components/message/MessageCard' -import { MessageTabBar, type MessageType } from '@/components/message/MessageTabBar' +import { MessageTabBar, type MessageType, TAB_ITEMS } from '@/components/message/MessageTabBar' import { AnnouncementBanner } from '@/components/message/AnnouncementBanner' import { SwipeToDelete } from '@/components/message/SwipeToDelete' import { MessageEmptyState } from '@/components/message/MessageEmptyState' @@ -50,6 +52,9 @@ export default function MessageScreen() { const router = useRouter() const [activeTab, setActiveTab] = useState(undefined) + // 获取当前 Tab 索引 + const activeTabIndex = TAB_ITEMS.findIndex(tab => tab.type === activeTab) + // Hooks const { messages: allMessages, @@ -95,6 +100,26 @@ export default function MessageScreen() { setActiveTab(type) }, []) + // 左右滑动切换消息类型 Tab + const handleSwipeLeft = useCallback(() => { + if (activeTabIndex < TAB_ITEMS.length - 1) { + setActiveTab(TAB_ITEMS[activeTabIndex + 1].type) + } + }, [activeTabIndex]) + + const handleSwipeRight = useCallback(() => { + if (activeTabIndex > 0) { + setActiveTab(TAB_ITEMS[activeTabIndex - 1].type) + } + }, [activeTabIndex]) + + const { handleGestureEvent, handleGestureStateChange } = useSwipeNavigation({ + onSwipeLeft: handleSwipeLeft, + onSwipeRight: handleSwipeRight, + canSwipeLeft: activeTabIndex < TAB_ITEMS.length - 1, + canSwipeRight: activeTabIndex > 0, + }) + // Handle mark all read const handleMarkAllRead = useCallback(async () => { const unreadMessages = filteredMessages.filter(m => !m.isRead) @@ -235,29 +260,37 @@ export default function MessageScreen() { {/* Message List */} - item.id} - contentContainerStyle={[ - styles.listContent, - filteredMessages.length === 0 && styles.emptyListContent, - ]} - showsVerticalScrollIndicator={false} - onEndReached={handleEndReached} - onEndReachedThreshold={0.5} - ListFooterComponent={renderFooter} - ListEmptyComponent={renderEmptyComponent} - refreshControl={ - + + item.id} + contentContainerStyle={[ + styles.listContent, + filteredMessages.length === 0 && styles.emptyListContent, + ]} + showsVerticalScrollIndicator={false} + onEndReached={handleEndReached} + onEndReachedThreshold={0.5} + ListFooterComponent={renderFooter} + ListEmptyComponent={renderEmptyComponent} + refreshControl={ + + } /> - } - /> + + ) } @@ -267,6 +300,9 @@ const styles = StyleSheet.create({ flex: 1, backgroundColor: '#090A0B', }, + gestureContainer: { + flex: 1, + }, header: { flexDirection: 'row', justifyContent: 'space-between', diff --git a/app/(tabs)/my.tsx b/app/(tabs)/my.tsx index 23b7c04..f4e0d95 100644 --- a/app/(tabs)/my.tsx +++ b/app/(tabs)/my.tsx @@ -17,6 +17,7 @@ import { SafeAreaView } from 'react-native-safe-area-context' import { Image } from 'expo-image' import { useRouter } from 'expo-router' import { useTranslation } from 'react-i18next' +import { PanGestureHandler } from 'react-native-gesture-handler' import { PointsIcon, SearchIcon, SettingsIcon } from '@/components/icon' import EditProfileDrawer from '@/components/drawer/EditProfileDrawer' import Dropdown from '@/components/ui/dropdown' @@ -30,6 +31,7 @@ import { } from '@/hooks' import { MySkeleton } from '@/components/skeleton/MySkeleton' import { TabNavigation } from '@/components/blocks/home/TabNavigation' +import { useSwipeNavigation } from '@/hooks/use-swipe-navigation' import { useUserBalance } from '@/hooks/use-user-balance' @@ -115,6 +117,26 @@ export default function My() { else if (index === 2) setActiveTab('likes') }, []) + // 左右滑动切换 Tab + const handleSwipeLeft = useCallback(() => { + if (activeTabIndex < tabs.length - 1) { + handleTabPress(activeTabIndex + 1) + } + }, [activeTabIndex, tabs.length, handleTabPress]) + + const handleSwipeRight = useCallback(() => { + if (activeTabIndex > 0) { + handleTabPress(activeTabIndex - 1) + } + }, [activeTabIndex, handleTabPress]) + + const { handleGestureEvent, handleGestureStateChange } = useSwipeNavigation({ + onSwipeLeft: handleSwipeLeft, + onSwipeRight: handleSwipeRight, + canSwipeLeft: activeTabIndex < tabs.length - 1, + canSwipeRight: activeTabIndex > 0, + }) + // 调试日志 useEffect(() => { console.log('📊 作品列表状态:', { @@ -346,11 +368,17 @@ export default function My() { onTabPress={handleTabPress} /> - {/* 作品九宫格 */} - {activeTab === 'works' && loading ? ( - - ) : activeTab === 'works' ? ( - + + {activeTab === 'works' && loading ? ( + + ) : activeTab === 'works' ? ( + ) : null} + + {/* 编辑资料抽屉 */} (null) const videoHeight = screenHeight - TAB_BAR_HEIGHT const PAGE_SIZE = 10 // 每页10个数据 @@ -247,6 +250,21 @@ export default function VideoScreen() { loadMore() }, [hasMore, loading, loadMore, templates.length]) + // 左右滑动切换底部 Tab 导航 + // 视频页是第二个 Tab (index=1),左滑去消息页,右滑去首页 + const handleSwipeLeft = useCallback(() => { + router.push('/(tabs)/message') + }, [router]) + + const handleSwipeRight = useCallback(() => { + router.push('/(tabs)/') + }, [router]) + + const { handleGestureEvent, handleGestureStateChange } = useSwipeNavigation({ + onSwipeLeft: handleSwipeLeft, + onSwipeRight: handleSwipeRight, + }) + const renderItem = useCallback(({ item }: { item: TemplateDetail }) => ( ), [videoHeight]) @@ -310,24 +328,32 @@ export default function VideoScreen() { return ( - : null} - maxToRenderPerBatch={5} - windowSize={7} - initialNumToRender={3} - /> + + + : null} + maxToRenderPerBatch={5} + windowSize={7} + initialNumToRender={3} + /> + + ) } @@ -337,6 +363,9 @@ const styles = StyleSheet.create({ flex: 1, backgroundColor: '#090A0B', }, + gestureContainer: { + flex: 1, + }, centerContainer: { flex: 1, alignItems: 'center', diff --git a/hooks/use-swipe-navigation.test.ts b/hooks/use-swipe-navigation.test.ts new file mode 100644 index 0000000..ec51297 --- /dev/null +++ b/hooks/use-swipe-navigation.test.ts @@ -0,0 +1,227 @@ +/** + * @file use-swipe-navigation.test.ts + * @description Tests for useSwipeNavigation hook + * + * This hook provides swipe gesture handling for tab navigation. + * It calculates swipe direction and triggers tab changes based on gesture velocity and distance. + */ + +import { renderHook, act } from '@testing-library/react-native' +import { useSwipeNavigation } from './use-swipe-navigation' + +describe('useSwipeNavigation', () => { + const mockOnSwipeLeft = jest.fn() + const mockOnSwipeRight = jest.fn() + + beforeEach(() => { + jest.clearAllMocks() + }) + + describe('initialization', () => { + it('should initialize with default values', () => { + const { result } = renderHook(() => + useSwipeNavigation({ + onSwipeLeft: mockOnSwipeLeft, + onSwipeRight: mockOnSwipeRight, + }) + ) + + expect(result.current.handleGestureEvent).toBeDefined() + expect(result.current.handleGestureStateChange).toBeDefined() + }) + + it('should accept custom threshold', () => { + const { result } = renderHook(() => + useSwipeNavigation({ + onSwipeLeft: mockOnSwipeLeft, + onSwipeRight: mockOnSwipeRight, + threshold: 100, + }) + ) + + expect(result.current.handleGestureEvent).toBeDefined() + }) + + it('should accept custom velocity threshold', () => { + const { result } = renderHook(() => + useSwipeNavigation({ + onSwipeLeft: mockOnSwipeLeft, + onSwipeRight: mockOnSwipeRight, + velocityThreshold: 500, + }) + ) + + expect(result.current.handleGestureEvent).toBeDefined() + }) + }) + + describe('swipe detection', () => { + it('should call onSwipeLeft when swiping left with sufficient distance', () => { + const { result } = renderHook(() => + useSwipeNavigation({ + onSwipeLeft: mockOnSwipeLeft, + onSwipeRight: mockOnSwipeRight, + threshold: 50, + }) + ) + + // Simulate gesture state change with left swipe + act(() => { + result.current.handleGestureStateChange({ + nativeEvent: { + state: 5, // END state + translationX: -100, + velocityX: -200, + }, + } as any) + }) + + expect(mockOnSwipeLeft).toHaveBeenCalledTimes(1) + expect(mockOnSwipeRight).not.toHaveBeenCalled() + }) + + it('should call onSwipeRight when swiping right with sufficient distance', () => { + const { result } = renderHook(() => + useSwipeNavigation({ + onSwipeLeft: mockOnSwipeLeft, + onSwipeRight: mockOnSwipeRight, + threshold: 50, + }) + ) + + // Simulate gesture state change with right swipe + act(() => { + result.current.handleGestureStateChange({ + nativeEvent: { + state: 5, // END state + translationX: 100, + velocityX: 200, + }, + } as any) + }) + + expect(mockOnSwipeRight).toHaveBeenCalledTimes(1) + expect(mockOnSwipeLeft).not.toHaveBeenCalled() + }) + + it('should call onSwipeLeft when velocity is high even with small distance', () => { + const { result } = renderHook(() => + useSwipeNavigation({ + onSwipeLeft: mockOnSwipeLeft, + onSwipeRight: mockOnSwipeRight, + threshold: 100, + velocityThreshold: 300, + }) + ) + + // Simulate gesture with high velocity but small distance + act(() => { + result.current.handleGestureStateChange({ + nativeEvent: { + state: 5, // END state + translationX: -30, + velocityX: -500, + }, + } as any) + }) + + expect(mockOnSwipeLeft).toHaveBeenCalledTimes(1) + }) + + it('should not trigger swipe when distance and velocity are below thresholds', () => { + const { result } = renderHook(() => + useSwipeNavigation({ + onSwipeLeft: mockOnSwipeLeft, + onSwipeRight: mockOnSwipeRight, + threshold: 100, + velocityThreshold: 500, + }) + ) + + // Simulate gesture with small distance and low velocity + act(() => { + result.current.handleGestureStateChange({ + nativeEvent: { + state: 5, // END state + translationX: -30, + velocityX: -100, + }, + } as any) + }) + + expect(mockOnSwipeLeft).not.toHaveBeenCalled() + expect(mockOnSwipeRight).not.toHaveBeenCalled() + }) + }) + + describe('disabled state', () => { + it('should not trigger swipe when disabled', () => { + const { result } = renderHook(() => + useSwipeNavigation({ + onSwipeLeft: mockOnSwipeLeft, + onSwipeRight: mockOnSwipeRight, + enabled: false, + }) + ) + + act(() => { + result.current.handleGestureStateChange({ + nativeEvent: { + state: 5, // END state + translationX: -100, + velocityX: -500, + }, + } as any) + }) + + expect(mockOnSwipeLeft).not.toHaveBeenCalled() + expect(mockOnSwipeRight).not.toHaveBeenCalled() + }) + }) + + describe('boundary conditions', () => { + it('should not call onSwipeLeft when canSwipeLeft is false', () => { + const { result } = renderHook(() => + useSwipeNavigation({ + onSwipeLeft: mockOnSwipeLeft, + onSwipeRight: mockOnSwipeRight, + canSwipeLeft: false, + }) + ) + + act(() => { + result.current.handleGestureStateChange({ + nativeEvent: { + state: 5, // END state + translationX: -100, + velocityX: -500, + }, + } as any) + }) + + expect(mockOnSwipeLeft).not.toHaveBeenCalled() + }) + + it('should not call onSwipeRight when canSwipeRight is false', () => { + const { result } = renderHook(() => + useSwipeNavigation({ + onSwipeLeft: mockOnSwipeLeft, + onSwipeRight: mockOnSwipeRight, + canSwipeRight: false, + }) + ) + + act(() => { + result.current.handleGestureStateChange({ + nativeEvent: { + state: 5, // END state + translationX: 100, + velocityX: 500, + }, + } as any) + }) + + expect(mockOnSwipeRight).not.toHaveBeenCalled() + }) + }) +}) diff --git a/hooks/use-swipe-navigation.ts b/hooks/use-swipe-navigation.ts new file mode 100644 index 0000000..6a975a4 --- /dev/null +++ b/hooks/use-swipe-navigation.ts @@ -0,0 +1,118 @@ +/** + * @file use-swipe-navigation.ts + * @description Hook for handling swipe gestures to navigate between tabs + * + * This hook provides gesture handlers for PanGestureHandler that detect + * horizontal swipes and trigger navigation callbacks. + */ + +import { useCallback, useRef } from 'react' +import { Dimensions } from 'react-native' +import type { PanGestureHandlerGestureEvent, PanGestureHandlerStateChangeEvent } from 'react-native-gesture-handler' + +const SCREEN_WIDTH = Dimensions.get('window').width +const DEFAULT_THRESHOLD = SCREEN_WIDTH * 0.2 // 20% of screen width +const DEFAULT_VELOCITY_THRESHOLD = 300 // pixels per second + +// Gesture handler state constants (to avoid import issues in tests) +const GESTURE_STATE_END = 5 + +export interface UseSwipeNavigationOptions { + /** Callback when user swipes left (to go to next tab) */ + onSwipeLeft: () => void + /** Callback when user swipes right (to go to previous tab) */ + onSwipeRight: () => void + /** Minimum distance to trigger swipe (default: 20% of screen width) */ + threshold?: number + /** Minimum velocity to trigger swipe regardless of distance (default: 300) */ + velocityThreshold?: number + /** Whether swipe navigation is enabled (default: true) */ + enabled?: boolean + /** Whether user can swipe left (default: true) */ + canSwipeLeft?: boolean + /** Whether user can swipe right (default: true) */ + canSwipeRight?: boolean +} + +export interface UseSwipeNavigationReturn { + /** Handler for gesture events (for tracking) */ + handleGestureEvent: (event: PanGestureHandlerGestureEvent) => void + /** Handler for gesture state changes (for triggering navigation) */ + handleGestureStateChange: (event: PanGestureHandlerStateChangeEvent) => void +} + +/** + * Hook for handling swipe gestures to navigate between tabs + * + * @example + * ```tsx + * const { handleGestureEvent, handleGestureStateChange } = useSwipeNavigation({ + * onSwipeLeft: () => setActiveTab(prev => prev + 1), + * onSwipeRight: () => setActiveTab(prev => prev - 1), + * canSwipeLeft: activeTab < tabs.length - 1, + * canSwipeRight: activeTab > 0, + * }) + * + * return ( + * + * {children} + * + * ) + * ``` + */ +export function useSwipeNavigation({ + onSwipeLeft, + onSwipeRight, + threshold = DEFAULT_THRESHOLD, + velocityThreshold = DEFAULT_VELOCITY_THRESHOLD, + enabled = true, + canSwipeLeft = true, + canSwipeRight = true, +}: UseSwipeNavigationOptions): UseSwipeNavigationReturn { + // Track translation for potential animation use + const translationX = useRef(0) + + const handleGestureEvent = useCallback( + (event: PanGestureHandlerGestureEvent) => { + if (!enabled) return + translationX.current = event.nativeEvent.translationX + }, + [enabled] + ) + + const handleGestureStateChange = useCallback( + (event: PanGestureHandlerStateChangeEvent) => { + if (!enabled) return + + const { state, translationX: tx, velocityX } = event.nativeEvent + + // Only process when gesture ends + if (state !== GESTURE_STATE_END) return + + const isSwipeLeft = tx < 0 + const isSwipeRight = tx > 0 + const absTranslation = Math.abs(tx) + const absVelocity = Math.abs(velocityX) + + // Check if swipe meets threshold (distance OR velocity) + const meetsThreshold = absTranslation >= threshold || absVelocity >= velocityThreshold + + if (!meetsThreshold) return + + if (isSwipeLeft && canSwipeLeft) { + onSwipeLeft() + } else if (isSwipeRight && canSwipeRight) { + onSwipeRight() + } + }, + [enabled, threshold, velocityThreshold, canSwipeLeft, canSwipeRight, onSwipeLeft, onSwipeRight] + ) + + return { + handleGestureEvent, + handleGestureStateChange, + } +}