import { Image } from "expo-image"; import React, { useEffect, useMemo } from "react"; import { Dimensions, StyleSheet, Text, View } from "react-native"; import { Gesture, GestureDetector } from "react-native-gesture-handler"; import Animated, { Extrapolation, interpolate, type SharedValue, useAnimatedStyle, useDerivedValue, useSharedValue, withSpring, } from "react-native-reanimated"; import { useTheme } from "../../theme"; // --- Constants remain the same --- const { width: SCREEN_WIDTH } = Dimensions.get("window"); const CARD_WIDTH_FACTOR = 0.5; const CARD_ASPECT_RATIO = 1.35; const SIDE_CARD_SCALE_FACTOR = 0.85; const CARD_WIDTH = SCREEN_WIDTH * CARD_WIDTH_FACTOR; const CARD_HEIGHT = CARD_WIDTH * CARD_ASPECT_RATIO; const MAX_X_FACTOR = 0.5 / CARD_WIDTH_FACTOR - SIDE_CARD_SCALE_FACTOR / 2; const SIDE_CARD_TRANSLATE_X_FACTOR = Math.min(0.32, MAX_X_FACTOR); const SIDE_CARD_TRANSLATE_X = CARD_WIDTH * SIDE_CARD_TRANSLATE_X_FACTOR; const ACTIVE_CARD_SCALE = 1.0; const SPRING_CONFIG = { damping: 18, stiffness: 120, mass: 0.6 }; const SIDE_CARD_ROTATION_DEGREES = 7; const VISIBILITY_WINDOW = 3; // Render active card + 2 on each side // --- Interfaces remain the same --- interface CarouselItemOriginal { id: string; image: string; title?: string; } interface CarouselItemVirtual extends CarouselItemOriginal { uniqueId: string; } interface CarouselCardStackProps { data: CarouselItemOriginal[]; cardHeight?: number; cardWidth?: number; backgroundColor?: string; } const createVirtualData = ( originalData: CarouselItemOriginal[] ): CarouselItemVirtual[] => { if (!originalData || originalData.length === 0) return []; const prefixItems = ( items: CarouselItemOriginal[], segmentPrefix: string ): CarouselItemVirtual[] => items.map((item, idx) => ({ ...item, uniqueId: `${segmentPrefix}-${item.id}-${idx}`, })); const prevSegment = prefixItems(originalData, "prev"); const currSegment = prefixItems(originalData, "curr"); const nextSegment = prefixItems(originalData, "next"); return [...prevSegment, ...currSegment, ...nextSegment]; }; const CarouselCard = React.memo( ({ item, index, activeIndex, gestureTranslateX, cardWidth, cardHeight, virtualDataLength, }: { item: CarouselItemVirtual; index: number; activeIndex: SharedValue; gestureTranslateX: SharedValue; cardWidth: number; cardHeight: number; virtualDataLength: number; }) => { const animatedStyle = useAnimatedStyle(() => { const currentCardDragOffset = gestureTranslateX.value / cardWidth; const displayOffset = index - (activeIndex.value - currentCardDragOffset); // --- THE CORE CHANGE IS HERE --- // Check if the card is within our visibility window on the UI thread. const isVisible = Math.abs(displayOffset) < VISIBILITY_WINDOW; // If not visible, we don't need to calculate transforms. // `display: 'none'` is highly performant as it removes the item from the layout. if (!isVisible) { return { display: "none" }; } // Calculations now only run for visible cards const scale = interpolate( displayOffset, [-1, 0, 1], [SIDE_CARD_SCALE_FACTOR, ACTIVE_CARD_SCALE, SIDE_CARD_SCALE_FACTOR], Extrapolation.CLAMP ); const translateX = interpolate( displayOffset, [-1, 0, 1], [-SIDE_CARD_TRANSLATE_X, 0, SIDE_CARD_TRANSLATE_X], Extrapolation.CLAMP ); const rotation = interpolate( displayOffset, [-1, 0, 1], [-SIDE_CARD_ROTATION_DEGREES, 0, SIDE_CARD_ROTATION_DEGREES], Extrapolation.CLAMP ); const opacity = interpolate( Math.abs(displayOffset), [0, 1, 2], [1, 1, 0], // Fade out the outermost cards Extrapolation.CLAMP ); const snappedDisplayOffset = index - Math.round(activeIndex.value); const zIndex = virtualDataLength - Math.abs(snappedDisplayOffset); return { display: "flex", // Ensure it's visible transform: [{ translateX }, { scale }, { rotateZ: `${rotation}deg` }], opacity, zIndex, }; }); return ( {item.title && ( {item.title} )} ); } ); CarouselCard.displayName = "CarouselCard" export const CarouselCardStack: React.FC = ({ data: originalData, cardHeight = CARD_HEIGHT, cardWidth = CARD_WIDTH, backgroundColor = "transparent", }) => { const { theme } = useTheme(); const N_original = originalData.length; const virtualData = useMemo(() => createVirtualData(originalData), [originalData]); const activeIndex = useSharedValue(N_original > 0 ? N_original : 0); const gestureTranslateX = useSharedValue(0); useEffect(() => { activeIndex.value = N_original > 0 ? N_original : 0; gestureTranslateX.value = 0; }, [N_original, activeIndex, gestureTranslateX]); const handleLoopReset = () => { "worklet"; if (N_original === 0) return; const currentValue = Math.round(activeIndex.value); if (currentValue >= N_original * 2) { activeIndex.value = currentValue - N_original; } else if (currentValue < N_original) { activeIndex.value = currentValue + N_original; } }; useDerivedValue(() => { if (gestureTranslateX.value === 0) { handleLoopReset(); } }); const panGesture = Gesture.Pan() .activeOffsetX([-10, 10]) .onUpdate((event) => { gestureTranslateX.value = event.translationX; }) .onEnd((event) => { if (virtualData.length === 0) return; const threshold = cardWidth / 3; let newTargetVirtualIndex = Math.round(activeIndex.value); if (event.translationX < -threshold) { newTargetVirtualIndex++; } else if (event.translationX > threshold) { newTargetVirtualIndex--; } activeIndex.value = withSpring(newTargetVirtualIndex, SPRING_CONFIG); gestureTranslateX.value = withSpring(0, SPRING_CONFIG); }); const activeDotIndex = useDerivedValue(() => { if (N_original === 0) return 0; return (Math.round(activeIndex.value) % N_original + N_original) % N_original; }); if (virtualData.length === 0) { /* ... */ } return ( {/* We map over all data, but the logic inside CarouselCard handles visibility */} {virtualData.map((item, index) => ( ))} {/* Pagination dots remain the same */} {N_original > 0 && ( {originalData.map((_, i) => { const isActiveDot = useDerivedValue(() => activeDotIndex.value === i); const dotStyle = useAnimatedStyle(() => ({ width: withSpring(isActiveDot.value ? 24 : 8, SPRING_CONFIG), height: 8, borderRadius: 4, backgroundColor: isActiveDot.value ? theme.primary : "#FFFFFF", marginHorizontal: 4, })); return ; })} )} ); }; const styles = StyleSheet.create({ container: { alignItems: "center", justifyContent: "center", backgroundColor: "transparent", }, cardContainer: { width: SCREEN_WIDTH, alignItems: "center", justifyContent: "center", }, card: { position: "absolute", borderRadius: 16, overflow: "hidden", }, cardImage: { width: "100%", height: "100%", }, titleContainer: { position: "absolute", bottom: 10, left: 10, right: 10, backgroundColor: "rgba(0,0,0,0.5)", padding: 8, borderRadius: 8, }, cardTitle: { color: "white", fontSize: 16, fontWeight: "bold", }, paginationContainer: { flexDirection: "row", justifyContent: "center", alignItems: "center", marginTop: 20, marginBottom: 10, }, emptyText: { color: "#FFFFFF", fontSize: 16, }, }); export default CarouselCardStack;