UNPKG

react-native-swipe-cards-stack

Version:

A highly customizable, performant swipeable cards stack component for React Native.

244 lines (242 loc) 11.8 kB
import React, { useCallback, useMemo } from 'react'; import { Animated, PanResponder, StyleSheet, Dimensions, TouchableOpacity, View } from 'react-native'; import { getDirectionFromGesture } from '../utils'; import SwipeIconsRenderer from './SwipeIconsRenderer'; const { width: screenWidth } = Dimensions.get('window'); const SwipeableCard = ({ card, index, isTop, isActive, onSwipe, onTap, tapActiveOpacity = 1, animatedValue, cardStyle, swipeIcons, thresholds, animations, gestures, callbacks, cardDimensions, children, directIcons, }) => { const { horizontal: horizontalThreshold = 120, vertical: verticalThreshold = 120, iconDelay = 80, velocity: velocityThreshold = 0.3, } = thresholds || {}; const { rotationEnabled = true, useNativeDriver = false, } = animations || {}; const { // New swipeDirections-based logic swipeDirections = ['left', 'right', 'up', 'down'], // Default to all directions enableRotation = true, allowPartialSwipe = true, partialSwipeReturnDuration = 300, partialSwipeReturnEasing, } = gestures || {}; // Derive individual direction enables from swipeDirections const enableLeftSwipe = swipeDirections.includes('left'); const enableRightSwipe = swipeDirections.includes('right'); const enableUpSwipe = swipeDirections.includes('up'); const enableDownSwipe = swipeDirections.includes('down'); // Memoized pan responder for performance const panResponder = useMemo(() => PanResponder.create({ onMoveShouldSetPanResponder: (_, gestureState) => { if (!isTop) return false; const { dx, dy } = gestureState; const threshold = gestures?.gestureThreshold || 10; return Math.abs(dx) > threshold || Math.abs(dy) > threshold; }, onPanResponderGrant: () => { if (!isTop) return; animatedValue.setOffset({ x: animatedValue.x._value, y: animatedValue.y._value, }); callbacks?.onSwipeStart?.(card, 'up'); // Default direction, will be updated }, onPanResponderMove: (event, gestureState) => { if (!isTop) return; const { dx, dy } = gestureState; // Allow partial movement in all directions, but limit disabled directions let finalDx = dx; let finalDy = dy; if (allowPartialSwipe) { // Allow small movements in disabled directions (for spring-back effect) const partialLimit = 50; // Maximum partial movement // Left swipe = dx < 0 (negative), Right swipe = dx > 0 (positive) if (!enableLeftSwipe && dx < 0) { finalDx = Math.max(dx, -partialLimit); // Limit leftward movement } if (!enableRightSwipe && dx > 0) { finalDx = Math.min(dx, partialLimit); // Limit rightward movement } if (!enableUpSwipe && dy < 0) { finalDy = Math.max(dy, -partialLimit); // Limit upward movement } if (!enableDownSwipe && dy > 0) { finalDy = Math.min(dy, partialLimit); // Limit downward movement } } else { // Completely block movement in disabled directions if (!enableLeftSwipe && dx < 0) finalDx = 0; // Block leftward movement if (!enableRightSwipe && dx > 0) finalDx = 0; // Block rightward movement if (!enableUpSwipe && dy < 0) finalDy = 0; // Block upward movement if (!enableDownSwipe && dy > 0) finalDy = 0; // Block downward movement } animatedValue.setValue({ x: finalDx, y: finalDy }); }, onPanResponderRelease: (event, gestureState) => { if (!isTop) return; animatedValue.flattenOffset(); const { dx, dy, vx, vy } = gestureState; const velocitySwipe = Math.abs(vx) > velocityThreshold || Math.abs(vy) > velocityThreshold; const direction = getDirectionFromGesture(dx, dy, thresholds || {}); // Check if the direction is enabled const isDirectionEnabled = (direction === 'left' && enableLeftSwipe) || (direction === 'right' && enableRightSwipe) || (direction === 'up' && enableUpSwipe) || (direction === 'down' && enableDownSwipe); // Check for partial swipe in disabled direction (only if allowPartialSwipe is true) const isPartialSwipeInDisabledDirection = allowPartialSwipe && direction && !isDirectionEnabled && (Math.abs(dx) > 30 || Math.abs(dy) > 30); // Minimum movement to consider as partial swipe intent if (direction && isDirectionEnabled && (Math.abs(dx) > horizontalThreshold || Math.abs(dy) > verticalThreshold || velocitySwipe)) { // Trigger swipe callback for enabled directions callbacks?.onSwipeEnd?.(card, direction); onSwipe(direction, card, index); } else { // Check if this is a partial swipe in disabled direction if (isPartialSwipeInDisabledDirection) { // Trigger callback for partial swipe in disabled direction (spring-back moment) onSwipe(direction, card, index); } // Return to center with spring animation const springConfig = { toValue: { x: 0, y: 0 }, useNativeDriver, tension: 100, friction: 8, }; if (partialSwipeReturnEasing) { Animated.timing(animatedValue, { ...springConfig, duration: partialSwipeReturnDuration, easing: partialSwipeReturnEasing, }).start(); } else { Animated.spring(animatedValue, springConfig).start(); } } }, }), [ isTop, card, index, animatedValue, onSwipe, thresholds, gestures, callbacks, horizontalThreshold, verticalThreshold, velocityThreshold, enableLeftSwipe, enableRightSwipe, enableUpSwipe, enableDownSwipe, allowPartialSwipe, partialSwipeReturnDuration, partialSwipeReturnEasing, useNativeDriver ]); // Icon opacity calculations for all directions const iconOpacities = useMemo(() => { // Check if we have direct icons OR legacy icons const hasRightIcon = directIcons?.rightSwipeIcon || swipeIcons?.rightSwipeIcon || swipeIcons?.tickIcon || (swipeIcons?.showRightIcon || swipeIcons?.showTickIcon); const hasLeftIcon = directIcons?.leftSwipeIcon || swipeIcons?.leftSwipeIcon || swipeIcons?.crossIcon || (swipeIcons?.showLeftIcon || swipeIcons?.showCrossIcon); const hasUpIcon = directIcons?.upSwipeIcon || swipeIcons?.upSwipeIcon || swipeIcons?.upIcon || swipeIcons?.showUpIcon; const hasDownIcon = directIcons?.downSwipeIcon || swipeIcons?.downSwipeIcon || swipeIcons?.downIcon || swipeIcons?.showDownIcon; const rightOpacity = isTop && hasRightIcon && enableRightSwipe ? animatedValue.x.interpolate({ inputRange: [-horizontalThreshold, -iconDelay, 0, iconDelay, horizontalThreshold], outputRange: [0, 0, 0, 0, 1], extrapolate: 'clamp', }) : new Animated.Value(0); const leftOpacity = isTop && hasLeftIcon && enableLeftSwipe ? animatedValue.x.interpolate({ inputRange: [-horizontalThreshold, -iconDelay, 0, iconDelay, horizontalThreshold], outputRange: [1, 0, 0, 0, 0], extrapolate: 'clamp', }) : new Animated.Value(0); const upOpacity = isTop && hasUpIcon && enableUpSwipe ? animatedValue.y.interpolate({ inputRange: [-verticalThreshold, -iconDelay, 0, iconDelay, verticalThreshold], outputRange: [1, 0, 0, 0, 0], extrapolate: 'clamp', }) : new Animated.Value(0); const downOpacity = isTop && hasDownIcon && enableDownSwipe ? animatedValue.y.interpolate({ inputRange: [-verticalThreshold, -iconDelay, 0, iconDelay, verticalThreshold], outputRange: [0, 0, 0, 0, 1], extrapolate: 'clamp', }) : new Animated.Value(0); // Legacy support const tickOpacity = rightOpacity; const crossOpacity = leftOpacity; return { rightOpacity, leftOpacity, upOpacity, downOpacity, tickOpacity, crossOpacity }; }, [ isTop, animatedValue, swipeIcons, directIcons, iconDelay, horizontalThreshold, verticalThreshold, enableRightSwipe, enableLeftSwipe, enableUpSwipe, enableDownSwipe ]); // Handle card focus callback const handleCardFocus = useCallback(() => { if (isActive && callbacks?.onCardFocus) { callbacks.onCardFocus(card, index); } }, [isActive, callbacks, card, index]); React.useEffect(() => { handleCardFocus(); }, [handleCardFocus]); const cardStyles = [ styles.card, cardStyle, { width: cardDimensions?.width || screenWidth - 40, height: cardDimensions?.height || 300, transform: isTop ? [ { translateX: animatedValue.x }, { translateY: animatedValue.y }, { rotate: rotationEnabled && enableRotation ? animatedValue.x.interpolate({ inputRange: [-200, 0, 200], outputRange: ['-10deg', '0deg', '10deg'], extrapolate: 'clamp', }) : '0deg', }, ] : [], }, ]; const handleTap = useCallback(() => { if (onTap) { onTap(card, index); } }, [onTap, card, index]); const CardWrapper = onTap ? TouchableOpacity : View; const cardWrapperProps = onTap ? { onPress: handleTap, activeOpacity: tapActiveOpacity, disabled: false, } : {}; return (<Animated.View style={cardStyles} {...(isTop ? panResponder.panHandlers : {})} accessible={true} accessibilityRole="button" accessibilityLabel={`Card ${index + 1}`} accessibilityHint="Swipe left or right to navigate, swipe up for details"> <CardWrapper style={{ width: '100%', height: '100%', }} {...cardWrapperProps}> {children} </CardWrapper> {/* Render swipe icons */} {isTop && (<SwipeIconsRenderer swipeIcons={swipeIcons || {}} iconOpacities={iconOpacities} _animatedValue={animatedValue} enabledDirections={{ left: enableLeftSwipe, right: enableRightSwipe, up: enableUpSwipe, down: enableDownSwipe, }} directIcons={directIcons}/>)} </Animated.View>); }; const styles = StyleSheet.create({ card: { position: 'absolute', // borderRadius removed - let user control it via cardStyle or cardContainerStyle backgroundColor: '#fff', shadowColor: '#000', shadowOffset: { width: 0, height: 2, }, shadowOpacity: 0.25, shadowRadius: 3.84, elevation: 5, }, }); export default SwipeableCard;