From fa45d075a4a415b6213369a9d56688d0020fac65 Mon Sep 17 00:00:00 2001 From: Avishai Nudelman Date: Thu, 24 Apr 2025 11:31:31 +0300 Subject: [PATCH 1/2] Swipeable cards --- .../swipeableCards/SwipeableCard.tsx | 180 ++++++++++++++++++ src/components/swipeableCards/index.tsx | 58 ++++++ 2 files changed, 238 insertions(+) create mode 100644 src/components/swipeableCards/SwipeableCard.tsx create mode 100644 src/components/swipeableCards/index.tsx diff --git a/src/components/swipeableCards/SwipeableCard.tsx b/src/components/swipeableCards/SwipeableCard.tsx new file mode 100644 index 0000000000..7db2013f19 --- /dev/null +++ b/src/components/swipeableCards/SwipeableCard.tsx @@ -0,0 +1,180 @@ +import {StyleSheet, useWindowDimensions, type ViewStyle} from 'react-native'; +import React, {useEffect, useState} from 'react'; +import Animated, {Extrapolation, interpolate, runOnJS, type SharedValue, useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated'; +import {Gesture, GestureDetector} from 'react-native-gesture-handler'; +import {Colors} from '../../style'; + +export interface SwipeableCardProps { + children: JSX.Element | JSX.Element[]; + onSwipe?: { + right?: () => void; + left?: () => void; + }; + style?: { + rightCardContainer?: ViewStyle; + leftCardContainer?: ViewStyle; + }; + cardContent?: { + right?: JSX.Element | JSX.Element[]; + left?: JSX.Element | JSX.Element[]; + }; + itemIndex: number; + currentIndex: number; + animatedValue: SharedValue; +} + +const SwipeableCard = ({ + children, onSwipe, cardContent, itemIndex, currentIndex, animatedValue +}: SwipeableCardProps) => { + const [swipeStatus, setSwipeStatus] = useState<'right' | 'left'>(); + + useEffect(() => { + if (swipeStatus) { + swipeStatus === 'right' ? onSwipe?.right?.() : onSwipe?.left?.(); + } + }, [onSwipe, swipeStatus]); + + const {width} = useWindowDimensions(); + const translationX = useSharedValue(0); + const translationY = useSharedValue(0); + const direction = useSharedValue(0); + + const diffFromFocusItem = Math.abs(itemIndex - currentIndex); + + const pan = Gesture.Pan() + .activeOffsetX([-6, 6]) + .onUpdate(e => { + const isSwipeRight = e.translationX > 0; + + // direction 1 is right, -1 is left + direction.value = isSwipeRight ? 1 : -1; + + translationX.value = e.translationX; + translationY.value = e.translationY; + }) + .onEnd(e => { + if (Math.abs(e.translationX) > 150 || Math.abs(e.velocityX) > 1000) { + translationX.value = withTiming(width * direction.value * 1.5, {duration: 200}, () => { + const isSwipeRight = e.translationX > 0; + runOnJS(setSwipeStatus)(isSwipeRight ? 'right' : 'left'); + animatedValue.value = withTiming(currentIndex + 1); + }); + } else { + translationX.value = withTiming(0, {duration: 500}); + translationY.value = withTiming(0, {duration: 500}); + } + }); + + const animatedStyle = useAnimatedStyle(() => { + const currentItem = itemIndex === currentIndex; + + const rotateZ = interpolate(Math.abs(translationX.value), + [0, width], + [0, 40]); + + const blurTranslateY = interpolate(animatedValue.value, + [itemIndex - 1, itemIndex], + [-21, 0]); + + const scale = interpolate(animatedValue.value, + [itemIndex - 1, itemIndex], + [0.95, 1]); + + return { + transform: [ + {translateY: currentItem ? translationY.value : blurTranslateY}, + {translateX: translationX.value}, + {rotateZ: `${direction.value * rotateZ}deg`}, + {scale} + ] + }; + }); + + const mainContentAnimatedStyle = useAnimatedStyle(() => { + return { + opacity: interpolate(Math.abs(translationX.value), + [0, width * 0.25], + [1, 0]) + }; + }); + + const acceptCardAnimatedStyle = useAnimatedStyle(() => { + return { + opacity: interpolate(translationX.value, + [0, width * 0.25], + [0, 1], + Extrapolation.CLAMP) + }; + }); + + const declineCardAnimatedStyle = useAnimatedStyle(() => { + return { + opacity: interpolate(translationX.value, + [-1 * width * 0.25, 0], + [1, 0], + Extrapolation.CLAMP) + }; + }); + + return ( + !swipeStatus && + + + + {children} + + + {cardContent?.right} + + + {cardContent?.left} + + + + ); +}; + +export default SwipeableCard; + +const styles = StyleSheet.create({ + container: { + borderColor: Colors.$backgroundNeutral, + borderWidth: 1, + position: 'absolute', + width: '94%', + height: '90%', + marginHorizontal: '3%', + marginTop: '8%', + marginBottom: '2%', + borderRadius: 28, + shadowColor: Colors.$backgroundDarkActive, + shadowOffset: {height: 4, width: 4}, + shadowRadius: 4, + shadowOpacity: 1, + elevation: 4, + justifyContent: 'center', + alignItems: 'center', + paddingTop: 4 + }, + card: { + ...StyleSheet.absoluteFillObject, + borderRadius: 28, + overflow: 'hidden' + }, + mainCard: { + zIndex: 100, + backgroundColor: Colors.$backgroundDefault + }, + acceptCard: { + zIndex: 50 + }, + text: { + color: Colors.$textDefault + } +}); diff --git a/src/components/swipeableCards/index.tsx b/src/components/swipeableCards/index.tsx new file mode 100644 index 0000000000..121d50a7d7 --- /dev/null +++ b/src/components/swipeableCards/index.tsx @@ -0,0 +1,58 @@ +import React from 'react'; +import SwipeableCard, {type SwipeableCardProps} from './SwipeableCard'; +import {useSharedValue} from 'react-native-reanimated'; +import View from '../view'; + +interface SwipeableCardsViewProps { + currentItem: T; + nextItems: T[]; + onSwipe?: SwipeableCardProps['onSwipe']; + currentIndex: number; + renderPlaceHolderCard: () => JSX.Element; + renderRightCard: () => JSX.Element; + renderLeftCard: () => JSX.Element; + renderMainCard: () => JSX.Element; +} + +const SwipeableCardsView = (props: SwipeableCardsViewProps) => { + const { + currentItem, nextItems, onSwipe, currentIndex, renderPlaceHolderCard, + renderRightCard, renderLeftCard, renderMainCard + } = props; + + const animatedValue = useSharedValue(0); + + return ( + + {nextItems.map((nextItem, index) => ( + nextItem && ( + + {renderPlaceHolderCard()} + + ) + ))} + + + {renderMainCard()} + + + + + ); +}; + +export default SwipeableCardsView; From 36759c81864052fcdcb3ade01307ef3658f15658 Mon Sep 17 00:00:00 2001 From: Avishai Nudelman Date: Thu, 24 Apr 2025 11:43:42 +0300 Subject: [PATCH 2/2] pre-push script adjustments --- .../swipeableCards/SwipeableCard.tsx | 39 ++++++++++--------- src/components/swipeableCards/index.tsx | 24 ++++++------ 2 files changed, 31 insertions(+), 32 deletions(-) diff --git a/src/components/swipeableCards/SwipeableCard.tsx b/src/components/swipeableCards/SwipeableCard.tsx index 7db2013f19..675f6bfde0 100644 --- a/src/components/swipeableCards/SwipeableCard.tsx +++ b/src/components/swipeableCards/SwipeableCard.tsx @@ -117,26 +117,27 @@ const SwipeableCard = ({ }); return ( - !swipeStatus && - - - - {children} + swipeStatus ? : ( + + + + {children} + + + {cardContent?.right} + + + {cardContent?.left} + - - {cardContent?.right} - - - {cardContent?.left} - - - + + ) ); }; diff --git a/src/components/swipeableCards/index.tsx b/src/components/swipeableCards/index.tsx index 121d50a7d7..50d4c77f9c 100644 --- a/src/components/swipeableCards/index.tsx +++ b/src/components/swipeableCards/index.tsx @@ -8,7 +8,7 @@ interface SwipeableCardsViewProps { nextItems: T[]; onSwipe?: SwipeableCardProps['onSwipe']; currentIndex: number; - renderPlaceHolderCard: () => JSX.Element; + renderNextCard: (nextItem: T) => JSX.Element; renderRightCard: () => JSX.Element; renderLeftCard: () => JSX.Element; renderMainCard: () => JSX.Element; @@ -16,7 +16,7 @@ interface SwipeableCardsViewProps { const SwipeableCardsView = (props: SwipeableCardsViewProps) => { const { - currentItem, nextItems, onSwipe, currentIndex, renderPlaceHolderCard, + currentItem, nextItems, onSwipe, currentIndex, renderNextCard, renderRightCard, renderLeftCard, renderMainCard } = props; @@ -24,17 +24,15 @@ const SwipeableCardsView = (props: SwipeableCardsViewProps) => { return ( - {nextItems.map((nextItem, index) => ( - nextItem && ( - - {renderPlaceHolderCard()} - - ) + {nextItems.filter(Boolean).map((nextItem, index) => ( + + {renderNextCard(nextItem)} + ))}