fix: bug
This commit is contained in:
@@ -12,6 +12,7 @@ import {
|
|||||||
Dimensions,
|
Dimensions,
|
||||||
} from 'react-native'
|
} from 'react-native'
|
||||||
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'
|
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 { TitleBar, HeroSlider, TabNavigation, TemplateCard, TemplateGrid } from '@/components/blocks/home'
|
||||||
import ErrorState from '@/components/ErrorState'
|
import ErrorState from '@/components/ErrorState'
|
||||||
@@ -25,6 +26,7 @@ import { useTemplateFilter } from '@/hooks/use-template-filter'
|
|||||||
import { useUserBalance } from '@/hooks/use-user-balance'
|
import { useUserBalance } from '@/hooks/use-user-balance'
|
||||||
import { useTemplateLike, useTemplateFavorite } from '@/hooks'
|
import { useTemplateLike, useTemplateFavorite } from '@/hooks'
|
||||||
import { useTemplateSocialStore } from '@/stores/templateSocialStore'
|
import { useTemplateSocialStore } from '@/stores/templateSocialStore'
|
||||||
|
import { useSwipeNavigation } from '@/hooks/use-swipe-navigation'
|
||||||
import { root } from '@repo/core'
|
import { root } from '@repo/core'
|
||||||
import { TemplateSocialController } from '@repo/sdk'
|
import { TemplateSocialController } from '@repo/sdk'
|
||||||
import { handleError } from '@/hooks/use-error'
|
import { handleError } from '@/hooks/use-error'
|
||||||
@@ -160,6 +162,26 @@ export default function HomeScreen() {
|
|||||||
}
|
}
|
||||||
}, [loadingMore, hasMore, isLoading, loadMore])
|
}, [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
|
// 导航处理 - 使用 useCallback memoize
|
||||||
const handlePointsPress = useCallback(() => router.push('/membership' as any), [router])
|
const handlePointsPress = useCallback(() => router.push('/membership' as any), [router])
|
||||||
const handleSearchPress = useCallback(() => router.push('/searchTemplate'), [router])
|
const handleSearchPress = useCallback(() => router.push('/searchTemplate'), [router])
|
||||||
@@ -377,41 +399,49 @@ export default function HomeScreen() {
|
|||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<FlatList
|
<PanGestureHandler
|
||||||
data={showTemplateList ? validTemplates : []}
|
onGestureEvent={handleGestureEvent}
|
||||||
keyExtractor={keyExtractor}
|
onHandlerStateChange={handleGestureStateChange}
|
||||||
renderItem={renderTemplateItem}
|
activeOffsetX={[-20, 20]}
|
||||||
numColumns={NUM_COLUMNS}
|
>
|
||||||
columnWrapperStyle={styles.columnWrapper}
|
<View style={styles.scrollView}>
|
||||||
style={styles.scrollView}
|
<FlatList
|
||||||
contentContainerStyle={[styles.scrollContent, { paddingBottom: 60 + insets.bottom + 20 }]}
|
data={showTemplateList ? validTemplates : []}
|
||||||
showsVerticalScrollIndicator={false}
|
keyExtractor={keyExtractor}
|
||||||
onScroll={(e) => handleScroll(e.nativeEvent.contentOffset.y)}
|
renderItem={renderTemplateItem}
|
||||||
scrollEventThrottle={Platform.OS === 'ios' ? 16 : 50}
|
numColumns={NUM_COLUMNS}
|
||||||
onEndReached={handleEndReached}
|
columnWrapperStyle={styles.columnWrapper}
|
||||||
onEndReachedThreshold={0.5}
|
style={styles.scrollView}
|
||||||
ListHeaderComponent={ListHeaderComponent}
|
contentContainerStyle={[styles.scrollContent, { paddingBottom: 60 + insets.bottom + 20 }]}
|
||||||
ListFooterComponent={ListFooterComponent}
|
showsVerticalScrollIndicator={false}
|
||||||
removeClippedSubviews={Platform.OS === 'android'}
|
onScroll={(e) => handleScroll(e.nativeEvent.contentOffset.y)}
|
||||||
maxToRenderPerBatch={12}
|
scrollEventThrottle={Platform.OS === 'ios' ? 16 : 50}
|
||||||
windowSize={5}
|
onEndReached={handleEndReached}
|
||||||
initialNumToRender={12}
|
onEndReachedThreshold={0.5}
|
||||||
getItemLayout={(_, index) => ({
|
ListHeaderComponent={ListHeaderComponent}
|
||||||
length: CARD_WIDTH * 1.5,
|
ListFooterComponent={ListFooterComponent}
|
||||||
offset: CARD_WIDTH * 1.5 * Math.floor(index / NUM_COLUMNS),
|
removeClippedSubviews={Platform.OS === 'android'}
|
||||||
index,
|
maxToRenderPerBatch={12}
|
||||||
})}
|
windowSize={5}
|
||||||
refreshControl={
|
initialNumToRender={12}
|
||||||
<RefreshControl
|
getItemLayout={(_, index) => ({
|
||||||
refreshing={refreshing}
|
length: CARD_WIDTH * 1.5,
|
||||||
onRefresh={handleRefresh}
|
offset: CARD_WIDTH * 1.5 * Math.floor(index / NUM_COLUMNS),
|
||||||
tintColor="#9966FF"
|
index,
|
||||||
colors={['#9966FF', '#FF6699', '#FF9966']}
|
})}
|
||||||
progressBackgroundColor="#1C1E22"
|
refreshControl={
|
||||||
progressViewOffset={10}
|
<RefreshControl
|
||||||
|
refreshing={refreshing}
|
||||||
|
onRefresh={handleRefresh}
|
||||||
|
tintColor="#9966FF"
|
||||||
|
colors={['#9966FF', '#FF6699', '#FF9966']}
|
||||||
|
progressBackgroundColor="#1C1E22"
|
||||||
|
progressViewOffset={10}
|
||||||
|
/>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
}
|
</View>
|
||||||
/>
|
</PanGestureHandler>
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,16 +12,18 @@ import { StatusBar } from 'expo-status-bar'
|
|||||||
import { SafeAreaView } from 'react-native-safe-area-context'
|
import { SafeAreaView } from 'react-native-safe-area-context'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { useRouter, useFocusEffect } from 'expo-router'
|
import { useRouter, useFocusEffect } from 'expo-router'
|
||||||
|
import { PanGestureHandler } from 'react-native-gesture-handler'
|
||||||
|
|
||||||
import { useMessages, type Message } from '@/hooks/use-messages'
|
import { useMessages, type Message } from '@/hooks/use-messages'
|
||||||
import { useMessageActions } from '@/hooks/use-message-actions'
|
import { useMessageActions } from '@/hooks/use-message-actions'
|
||||||
import { useAnnouncements } from '@/hooks/use-announcements'
|
import { useAnnouncements } from '@/hooks/use-announcements'
|
||||||
|
import { useSwipeNavigation } from '@/hooks/use-swipe-navigation'
|
||||||
import LoadingState from '@/components/LoadingState'
|
import LoadingState from '@/components/LoadingState'
|
||||||
import ErrorState from '@/components/ErrorState'
|
import ErrorState from '@/components/ErrorState'
|
||||||
import PaginationLoader from '@/components/PaginationLoader'
|
import PaginationLoader from '@/components/PaginationLoader'
|
||||||
|
|
||||||
import { MessageCard } from '@/components/message/MessageCard'
|
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 { AnnouncementBanner } from '@/components/message/AnnouncementBanner'
|
||||||
import { SwipeToDelete } from '@/components/message/SwipeToDelete'
|
import { SwipeToDelete } from '@/components/message/SwipeToDelete'
|
||||||
import { MessageEmptyState } from '@/components/message/MessageEmptyState'
|
import { MessageEmptyState } from '@/components/message/MessageEmptyState'
|
||||||
@@ -50,6 +52,9 @@ export default function MessageScreen() {
|
|||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const [activeTab, setActiveTab] = useState<MessageType>(undefined)
|
const [activeTab, setActiveTab] = useState<MessageType>(undefined)
|
||||||
|
|
||||||
|
// 获取当前 Tab 索引
|
||||||
|
const activeTabIndex = TAB_ITEMS.findIndex(tab => tab.type === activeTab)
|
||||||
|
|
||||||
// Hooks
|
// Hooks
|
||||||
const {
|
const {
|
||||||
messages: allMessages,
|
messages: allMessages,
|
||||||
@@ -95,6 +100,26 @@ export default function MessageScreen() {
|
|||||||
setActiveTab(type)
|
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
|
// Handle mark all read
|
||||||
const handleMarkAllRead = useCallback(async () => {
|
const handleMarkAllRead = useCallback(async () => {
|
||||||
const unreadMessages = filteredMessages.filter(m => !m.isRead)
|
const unreadMessages = filteredMessages.filter(m => !m.isRead)
|
||||||
@@ -235,29 +260,37 @@ export default function MessageScreen() {
|
|||||||
<MessageTabBar activeTab={activeTab} onTabChange={handleTabChange} />
|
<MessageTabBar activeTab={activeTab} onTabChange={handleTabChange} />
|
||||||
|
|
||||||
{/* Message List */}
|
{/* Message List */}
|
||||||
<FlatList
|
<PanGestureHandler
|
||||||
testID="message-list"
|
onGestureEvent={handleGestureEvent}
|
||||||
data={filteredMessages}
|
onHandlerStateChange={handleGestureStateChange}
|
||||||
renderItem={renderMessageItem}
|
activeOffsetX={[-20, 20]}
|
||||||
keyExtractor={(item) => item.id}
|
>
|
||||||
contentContainerStyle={[
|
<View style={styles.gestureContainer}>
|
||||||
styles.listContent,
|
<FlatList
|
||||||
filteredMessages.length === 0 && styles.emptyListContent,
|
testID="message-list"
|
||||||
]}
|
data={filteredMessages}
|
||||||
showsVerticalScrollIndicator={false}
|
renderItem={renderMessageItem}
|
||||||
onEndReached={handleEndReached}
|
keyExtractor={(item) => item.id}
|
||||||
onEndReachedThreshold={0.5}
|
contentContainerStyle={[
|
||||||
ListFooterComponent={renderFooter}
|
styles.listContent,
|
||||||
ListEmptyComponent={renderEmptyComponent}
|
filteredMessages.length === 0 && styles.emptyListContent,
|
||||||
refreshControl={
|
]}
|
||||||
<RefreshControl
|
showsVerticalScrollIndicator={false}
|
||||||
refreshing={refreshing}
|
onEndReached={handleEndReached}
|
||||||
onRefresh={handleRefresh}
|
onEndReachedThreshold={0.5}
|
||||||
tintColor="#FFFFFF"
|
ListFooterComponent={renderFooter}
|
||||||
colors={['#FFFFFF']}
|
ListEmptyComponent={renderEmptyComponent}
|
||||||
|
refreshControl={
|
||||||
|
<RefreshControl
|
||||||
|
refreshing={refreshing}
|
||||||
|
onRefresh={handleRefresh}
|
||||||
|
tintColor="#FFFFFF"
|
||||||
|
colors={['#FFFFFF']}
|
||||||
|
/>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
}
|
</View>
|
||||||
/>
|
</PanGestureHandler>
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -267,6 +300,9 @@ const styles = StyleSheet.create({
|
|||||||
flex: 1,
|
flex: 1,
|
||||||
backgroundColor: '#090A0B',
|
backgroundColor: '#090A0B',
|
||||||
},
|
},
|
||||||
|
gestureContainer: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
header: {
|
header: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { SafeAreaView } from 'react-native-safe-area-context'
|
|||||||
import { Image } from 'expo-image'
|
import { Image } from 'expo-image'
|
||||||
import { useRouter } from 'expo-router'
|
import { useRouter } from 'expo-router'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { PanGestureHandler } from 'react-native-gesture-handler'
|
||||||
import { PointsIcon, SearchIcon, SettingsIcon } from '@/components/icon'
|
import { PointsIcon, SearchIcon, SettingsIcon } from '@/components/icon'
|
||||||
import EditProfileDrawer from '@/components/drawer/EditProfileDrawer'
|
import EditProfileDrawer from '@/components/drawer/EditProfileDrawer'
|
||||||
import Dropdown from '@/components/ui/dropdown'
|
import Dropdown from '@/components/ui/dropdown'
|
||||||
@@ -30,6 +31,7 @@ import {
|
|||||||
} from '@/hooks'
|
} from '@/hooks'
|
||||||
import { MySkeleton } from '@/components/skeleton/MySkeleton'
|
import { MySkeleton } from '@/components/skeleton/MySkeleton'
|
||||||
import { TabNavigation } from '@/components/blocks/home/TabNavigation'
|
import { TabNavigation } from '@/components/blocks/home/TabNavigation'
|
||||||
|
import { useSwipeNavigation } from '@/hooks/use-swipe-navigation'
|
||||||
|
|
||||||
import { useUserBalance } from '@/hooks/use-user-balance'
|
import { useUserBalance } from '@/hooks/use-user-balance'
|
||||||
|
|
||||||
@@ -115,6 +117,26 @@ export default function My() {
|
|||||||
else if (index === 2) setActiveTab('likes')
|
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(() => {
|
useEffect(() => {
|
||||||
console.log('📊 作品列表状态:', {
|
console.log('📊 作品列表状态:', {
|
||||||
@@ -346,11 +368,17 @@ export default function My() {
|
|||||||
onTabPress={handleTabPress}
|
onTabPress={handleTabPress}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 作品九宫格 */}
|
{/* 作品九宫格 - 包裹在手势处理器中 */}
|
||||||
{activeTab === 'works' && loading ? (
|
<PanGestureHandler
|
||||||
<MySkeleton />
|
onGestureEvent={handleGestureEvent}
|
||||||
) : activeTab === 'works' ? (
|
onHandlerStateChange={handleGestureStateChange}
|
||||||
<ScrollView
|
activeOffsetX={[-20, 20]}
|
||||||
|
>
|
||||||
|
<View style={styles.gestureContainer}>
|
||||||
|
{activeTab === 'works' && loading ? (
|
||||||
|
<MySkeleton />
|
||||||
|
) : activeTab === 'works' ? (
|
||||||
|
<ScrollView
|
||||||
testID="my-scroll-view"
|
testID="my-scroll-view"
|
||||||
style={styles.scrollView}
|
style={styles.scrollView}
|
||||||
contentContainerStyle={styles.scrollContent}
|
contentContainerStyle={styles.scrollContent}
|
||||||
@@ -549,6 +577,8 @@ export default function My() {
|
|||||||
</View>
|
</View>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
) : null}
|
) : null}
|
||||||
|
</View>
|
||||||
|
</PanGestureHandler>
|
||||||
|
|
||||||
{/* 编辑资料抽屉 */}
|
{/* 编辑资料抽屉 */}
|
||||||
<EditProfileDrawer
|
<EditProfileDrawer
|
||||||
@@ -569,6 +599,9 @@ const styles = StyleSheet.create({
|
|||||||
flex: 1,
|
flex: 1,
|
||||||
backgroundColor: '#090A0B',
|
backgroundColor: '#090A0B',
|
||||||
},
|
},
|
||||||
|
gestureContainer: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
topBar: {
|
topBar: {
|
||||||
paddingHorizontal: GALLERY_HORIZONTAL_PADDING,
|
paddingHorizontal: GALLERY_HORIZONTAL_PADDING,
|
||||||
paddingTop: 19,
|
paddingTop: 19,
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { SafeAreaView } from 'react-native-safe-area-context'
|
|||||||
import { Image, ImageLoadEventData } from 'expo-image'
|
import { Image, ImageLoadEventData } from 'expo-image'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { Ionicons } from '@expo/vector-icons'
|
import { Ionicons } from '@expo/vector-icons'
|
||||||
|
import { PanGestureHandler } from 'react-native-gesture-handler'
|
||||||
|
|
||||||
import { SameStyleIcon, WhiteStarIcon } from '@/components/icon'
|
import { SameStyleIcon, WhiteStarIcon } from '@/components/icon'
|
||||||
import { useRouter } from 'expo-router'
|
import { useRouter } from 'expo-router'
|
||||||
@@ -22,6 +23,7 @@ import { useTemplates, type TemplateDetail } from '@/hooks'
|
|||||||
import { VideoSocialButton } from '@/components/blocks/ui/VideoSocialButton'
|
import { VideoSocialButton } from '@/components/blocks/ui/VideoSocialButton'
|
||||||
import { useTemplateLike } from '@/hooks/use-template-like'
|
import { useTemplateLike } from '@/hooks/use-template-like'
|
||||||
import { useTemplateFavorite } from '@/hooks/use-template-favorite'
|
import { useTemplateFavorite } from '@/hooks/use-template-favorite'
|
||||||
|
import { useSwipeNavigation } from '@/hooks/use-swipe-navigation'
|
||||||
import {
|
import {
|
||||||
useTemplateSocialStore,
|
useTemplateSocialStore,
|
||||||
useTemplateLiked,
|
useTemplateLiked,
|
||||||
@@ -219,6 +221,7 @@ export const VideoItem = memo(({ item, videoHeight }: { item: TemplateDetail; vi
|
|||||||
})
|
})
|
||||||
|
|
||||||
export default function VideoScreen() {
|
export default function VideoScreen() {
|
||||||
|
const router = useRouter()
|
||||||
const flatListRef = useRef<FlatList>(null)
|
const flatListRef = useRef<FlatList>(null)
|
||||||
const videoHeight = screenHeight - TAB_BAR_HEIGHT
|
const videoHeight = screenHeight - TAB_BAR_HEIGHT
|
||||||
const PAGE_SIZE = 10 // 每页10个数据
|
const PAGE_SIZE = 10 // 每页10个数据
|
||||||
@@ -247,6 +250,21 @@ export default function VideoScreen() {
|
|||||||
loadMore()
|
loadMore()
|
||||||
}, [hasMore, loading, loadMore, templates.length])
|
}, [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 }) => (
|
const renderItem = useCallback(({ item }: { item: TemplateDetail }) => (
|
||||||
<VideoItem item={item} videoHeight={videoHeight} />
|
<VideoItem item={item} videoHeight={videoHeight} />
|
||||||
), [videoHeight])
|
), [videoHeight])
|
||||||
@@ -310,24 +328,32 @@ export default function VideoScreen() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout>
|
<Layout>
|
||||||
<FlatList
|
<PanGestureHandler
|
||||||
ref={flatListRef}
|
onGestureEvent={handleGestureEvent}
|
||||||
data={filteredTemplates}
|
onHandlerStateChange={handleGestureStateChange}
|
||||||
renderItem={renderItem}
|
activeOffsetX={[-20, 20]}
|
||||||
keyExtractor={keyExtractor}
|
>
|
||||||
getItemLayout={getItemLayout}
|
<View style={styles.gestureContainer}>
|
||||||
pagingEnabled
|
<FlatList
|
||||||
showsVerticalScrollIndicator={false}
|
ref={flatListRef}
|
||||||
snapToInterval={videoHeight}
|
data={filteredTemplates}
|
||||||
snapToAlignment="start"
|
renderItem={renderItem}
|
||||||
decelerationRate="fast"
|
keyExtractor={keyExtractor}
|
||||||
onEndReached={handleLoadMore}
|
getItemLayout={getItemLayout}
|
||||||
onEndReachedThreshold={0.1}
|
pagingEnabled
|
||||||
ListFooterComponent={loading ? <PaginationLoader color="#FFE500" /> : null}
|
showsVerticalScrollIndicator={false}
|
||||||
maxToRenderPerBatch={5}
|
snapToInterval={videoHeight}
|
||||||
windowSize={7}
|
snapToAlignment="start"
|
||||||
initialNumToRender={3}
|
decelerationRate="fast"
|
||||||
/>
|
onEndReached={handleLoadMore}
|
||||||
|
onEndReachedThreshold={0.1}
|
||||||
|
ListFooterComponent={loading ? <PaginationLoader color="#FFE500" /> : null}
|
||||||
|
maxToRenderPerBatch={5}
|
||||||
|
windowSize={7}
|
||||||
|
initialNumToRender={3}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</PanGestureHandler>
|
||||||
</Layout>
|
</Layout>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -337,6 +363,9 @@ const styles = StyleSheet.create({
|
|||||||
flex: 1,
|
flex: 1,
|
||||||
backgroundColor: '#090A0B',
|
backgroundColor: '#090A0B',
|
||||||
},
|
},
|
||||||
|
gestureContainer: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
centerContainer: {
|
centerContainer: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
alignItems: 'center',
|
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