This commit is contained in:
imeepos
2026-01-29 16:42:42 +08:00
parent 1ccdc23355
commit a087cafc99
6 changed files with 553 additions and 80 deletions

View File

@@ -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>
)
}

View File

@@ -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',

View File

@@ -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,

View File

@@ -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',

View 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()
})
})
})

View 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,
}
}