fix: bug
This commit is contained in:
@@ -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() {
|
||||
</View>
|
||||
)}
|
||||
|
||||
<FlatList
|
||||
data={showTemplateList ? validTemplates : []}
|
||||
keyExtractor={keyExtractor}
|
||||
renderItem={renderTemplateItem}
|
||||
numColumns={NUM_COLUMNS}
|
||||
columnWrapperStyle={styles.columnWrapper}
|
||||
style={styles.scrollView}
|
||||
contentContainerStyle={[styles.scrollContent, { paddingBottom: 60 + insets.bottom + 20 }]}
|
||||
showsVerticalScrollIndicator={false}
|
||||
onScroll={(e) => 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={
|
||||
<RefreshControl
|
||||
refreshing={refreshing}
|
||||
onRefresh={handleRefresh}
|
||||
tintColor="#9966FF"
|
||||
colors={['#9966FF', '#FF6699', '#FF9966']}
|
||||
progressBackgroundColor="#1C1E22"
|
||||
progressViewOffset={10}
|
||||
<PanGestureHandler
|
||||
onGestureEvent={handleGestureEvent}
|
||||
onHandlerStateChange={handleGestureStateChange}
|
||||
activeOffsetX={[-20, 20]}
|
||||
>
|
||||
<View style={styles.scrollView}>
|
||||
<FlatList
|
||||
data={showTemplateList ? validTemplates : []}
|
||||
keyExtractor={keyExtractor}
|
||||
renderItem={renderTemplateItem}
|
||||
numColumns={NUM_COLUMNS}
|
||||
columnWrapperStyle={styles.columnWrapper}
|
||||
style={styles.scrollView}
|
||||
contentContainerStyle={[styles.scrollContent, { paddingBottom: 60 + insets.bottom + 20 }]}
|
||||
showsVerticalScrollIndicator={false}
|
||||
onScroll={(e) => 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={
|
||||
<RefreshControl
|
||||
refreshing={refreshing}
|
||||
onRefresh={handleRefresh}
|
||||
tintColor="#9966FF"
|
||||
colors={['#9966FF', '#FF6699', '#FF9966']}
|
||||
progressBackgroundColor="#1C1E22"
|
||||
progressViewOffset={10}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
</PanGestureHandler>
|
||||
</SafeAreaView>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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<MessageType>(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() {
|
||||
<MessageTabBar activeTab={activeTab} onTabChange={handleTabChange} />
|
||||
|
||||
{/* Message List */}
|
||||
<FlatList
|
||||
testID="message-list"
|
||||
data={filteredMessages}
|
||||
renderItem={renderMessageItem}
|
||||
keyExtractor={(item) => item.id}
|
||||
contentContainerStyle={[
|
||||
styles.listContent,
|
||||
filteredMessages.length === 0 && styles.emptyListContent,
|
||||
]}
|
||||
showsVerticalScrollIndicator={false}
|
||||
onEndReached={handleEndReached}
|
||||
onEndReachedThreshold={0.5}
|
||||
ListFooterComponent={renderFooter}
|
||||
ListEmptyComponent={renderEmptyComponent}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={refreshing}
|
||||
onRefresh={handleRefresh}
|
||||
tintColor="#FFFFFF"
|
||||
colors={['#FFFFFF']}
|
||||
<PanGestureHandler
|
||||
onGestureEvent={handleGestureEvent}
|
||||
onHandlerStateChange={handleGestureStateChange}
|
||||
activeOffsetX={[-20, 20]}
|
||||
>
|
||||
<View style={styles.gestureContainer}>
|
||||
<FlatList
|
||||
testID="message-list"
|
||||
data={filteredMessages}
|
||||
renderItem={renderMessageItem}
|
||||
keyExtractor={(item) => item.id}
|
||||
contentContainerStyle={[
|
||||
styles.listContent,
|
||||
filteredMessages.length === 0 && styles.emptyListContent,
|
||||
]}
|
||||
showsVerticalScrollIndicator={false}
|
||||
onEndReached={handleEndReached}
|
||||
onEndReachedThreshold={0.5}
|
||||
ListFooterComponent={renderFooter}
|
||||
ListEmptyComponent={renderEmptyComponent}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={refreshing}
|
||||
onRefresh={handleRefresh}
|
||||
tintColor="#FFFFFF"
|
||||
colors={['#FFFFFF']}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
</PanGestureHandler>
|
||||
</SafeAreaView>
|
||||
)
|
||||
}
|
||||
@@ -267,6 +300,9 @@ const styles = StyleSheet.create({
|
||||
flex: 1,
|
||||
backgroundColor: '#090A0B',
|
||||
},
|
||||
gestureContainer: {
|
||||
flex: 1,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
|
||||
@@ -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 ? (
|
||||
<MySkeleton />
|
||||
) : activeTab === 'works' ? (
|
||||
<ScrollView
|
||||
{/* 作品九宫格 - 包裹在手势处理器中 */}
|
||||
<PanGestureHandler
|
||||
onGestureEvent={handleGestureEvent}
|
||||
onHandlerStateChange={handleGestureStateChange}
|
||||
activeOffsetX={[-20, 20]}
|
||||
>
|
||||
<View style={styles.gestureContainer}>
|
||||
{activeTab === 'works' && loading ? (
|
||||
<MySkeleton />
|
||||
) : activeTab === 'works' ? (
|
||||
<ScrollView
|
||||
testID="my-scroll-view"
|
||||
style={styles.scrollView}
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
@@ -549,6 +577,8 @@ export default function My() {
|
||||
</View>
|
||||
</ScrollView>
|
||||
) : null}
|
||||
</View>
|
||||
</PanGestureHandler>
|
||||
|
||||
{/* 编辑资料抽屉 */}
|
||||
<EditProfileDrawer
|
||||
@@ -569,6 +599,9 @@ const styles = StyleSheet.create({
|
||||
flex: 1,
|
||||
backgroundColor: '#090A0B',
|
||||
},
|
||||
gestureContainer: {
|
||||
flex: 1,
|
||||
},
|
||||
topBar: {
|
||||
paddingHorizontal: GALLERY_HORIZONTAL_PADDING,
|
||||
paddingTop: 19,
|
||||
|
||||
@@ -15,6 +15,7 @@ import { SafeAreaView } from 'react-native-safe-area-context'
|
||||
import { Image, ImageLoadEventData } from 'expo-image'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Ionicons } from '@expo/vector-icons'
|
||||
import { PanGestureHandler } from 'react-native-gesture-handler'
|
||||
|
||||
import { SameStyleIcon, WhiteStarIcon } from '@/components/icon'
|
||||
import { useRouter } from 'expo-router'
|
||||
@@ -22,6 +23,7 @@ import { useTemplates, type TemplateDetail } from '@/hooks'
|
||||
import { VideoSocialButton } from '@/components/blocks/ui/VideoSocialButton'
|
||||
import { useTemplateLike } from '@/hooks/use-template-like'
|
||||
import { useTemplateFavorite } from '@/hooks/use-template-favorite'
|
||||
import { useSwipeNavigation } from '@/hooks/use-swipe-navigation'
|
||||
import {
|
||||
useTemplateSocialStore,
|
||||
useTemplateLiked,
|
||||
@@ -219,6 +221,7 @@ export const VideoItem = memo(({ item, videoHeight }: { item: TemplateDetail; vi
|
||||
})
|
||||
|
||||
export default function VideoScreen() {
|
||||
const router = useRouter()
|
||||
const flatListRef = useRef<FlatList>(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 }) => (
|
||||
<VideoItem item={item} videoHeight={videoHeight} />
|
||||
), [videoHeight])
|
||||
@@ -310,24 +328,32 @@ export default function VideoScreen() {
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<FlatList
|
||||
ref={flatListRef}
|
||||
data={filteredTemplates}
|
||||
renderItem={renderItem}
|
||||
keyExtractor={keyExtractor}
|
||||
getItemLayout={getItemLayout}
|
||||
pagingEnabled
|
||||
showsVerticalScrollIndicator={false}
|
||||
snapToInterval={videoHeight}
|
||||
snapToAlignment="start"
|
||||
decelerationRate="fast"
|
||||
onEndReached={handleLoadMore}
|
||||
onEndReachedThreshold={0.1}
|
||||
ListFooterComponent={loading ? <PaginationLoader color="#FFE500" /> : null}
|
||||
maxToRenderPerBatch={5}
|
||||
windowSize={7}
|
||||
initialNumToRender={3}
|
||||
/>
|
||||
<PanGestureHandler
|
||||
onGestureEvent={handleGestureEvent}
|
||||
onHandlerStateChange={handleGestureStateChange}
|
||||
activeOffsetX={[-20, 20]}
|
||||
>
|
||||
<View style={styles.gestureContainer}>
|
||||
<FlatList
|
||||
ref={flatListRef}
|
||||
data={filteredTemplates}
|
||||
renderItem={renderItem}
|
||||
keyExtractor={keyExtractor}
|
||||
getItemLayout={getItemLayout}
|
||||
pagingEnabled
|
||||
showsVerticalScrollIndicator={false}
|
||||
snapToInterval={videoHeight}
|
||||
snapToAlignment="start"
|
||||
decelerationRate="fast"
|
||||
onEndReached={handleLoadMore}
|
||||
onEndReachedThreshold={0.1}
|
||||
ListFooterComponent={loading ? <PaginationLoader color="#FFE500" /> : null}
|
||||
maxToRenderPerBatch={5}
|
||||
windowSize={7}
|
||||
initialNumToRender={3}
|
||||
/>
|
||||
</View>
|
||||
</PanGestureHandler>
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
@@ -337,6 +363,9 @@ const styles = StyleSheet.create({
|
||||
flex: 1,
|
||||
backgroundColor: '#090A0B',
|
||||
},
|
||||
gestureContainer: {
|
||||
flex: 1,
|
||||
},
|
||||
centerContainer: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
|
||||
227
hooks/use-swipe-navigation.test.ts
Normal file
227
hooks/use-swipe-navigation.test.ts
Normal file
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
118
hooks/use-swipe-navigation.ts
Normal file
118
hooks/use-swipe-navigation.ts
Normal file
@@ -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 (
|
||||
* <PanGestureHandler
|
||||
* onGestureEvent={handleGestureEvent}
|
||||
* onHandlerStateChange={handleGestureStateChange}
|
||||
* >
|
||||
* <View>{children}</View>
|
||||
* </PanGestureHandler>
|
||||
* )
|
||||
* ```
|
||||
*/
|
||||
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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user