diff --git a/src/components/CarouselLayout.tsx b/src/components/CarouselLayout.tsx index f8fb5df3..06c6076d 100644 --- a/src/components/CarouselLayout.tsx +++ b/src/components/CarouselLayout.tsx @@ -196,6 +196,7 @@ export const CarouselLayout = React.forwardRef((_props, ref) loop={loop} size={size} windowSize={windowSize} + defaultIndex={defaultIndex} autoFillData={autoFillData} offsetX={offsetX} handlerOffset={handlerOffset} diff --git a/src/components/ItemRenderer.tsx b/src/components/ItemRenderer.tsx index 6ceb1156..d548674a 100644 --- a/src/components/ItemRenderer.tsx +++ b/src/components/ItemRenderer.tsx @@ -9,7 +9,7 @@ import type { TAnimationStyle } from "./ItemLayout"; import { ItemLayout } from "./ItemLayout"; import type { VisibleRanges } from "../hooks/useVisibleRanges"; -import { useVisibleRanges } from "../hooks/useVisibleRanges"; +import { computeVisibleRanges, useVisibleRanges } from "../hooks/useVisibleRanges"; import type { CarouselRenderItem } from "../types"; import { computedRealIndexWithAutoFillData } from "../utils/computed-with-auto-fill-data"; @@ -20,6 +20,7 @@ interface Props { loop: boolean; size: number; windowSize?: number; + defaultIndex: number; autoFillData: boolean; offsetX: SharedValue; handlerOffset: SharedValue; @@ -33,6 +34,7 @@ export const ItemRenderer: FC = (props) => { data, size, windowSize, + defaultIndex, handlerOffset, offsetX, dataLength, @@ -54,11 +56,14 @@ export const ItemRenderer: FC = (props) => { // Initialize with a sensible default to avoid blank render on first frame const initialRanges: VisibleRanges = React.useMemo( - () => ({ - negativeRange: [0, 0], - positiveRange: [0, Math.min(dataLength - 1, (windowSize ?? dataLength) - 1)], - }), - [dataLength, windowSize] + () => + computeVisibleRanges({ + total: dataLength, + windowSize, + currentIndex: defaultIndex, + loop, + }), + [dataLength, defaultIndex, loop, windowSize] ); const [displayedItems, setDisplayedItems] = React.useState(initialRanges); diff --git a/src/components/issue-899-parallax-reverse-loop.test.tsx b/src/components/issue-899-parallax-reverse-loop.test.tsx new file mode 100644 index 00000000..a07007a9 --- /dev/null +++ b/src/components/issue-899-parallax-reverse-loop.test.tsx @@ -0,0 +1,131 @@ +import React from "react"; +import { StyleSheet } from "react-native"; +import type { PanGesture } from "react-native-gesture-handler"; +import { Gesture, State } from "react-native-gesture-handler"; +import Animated, { useDerivedValue, useSharedValue } from "react-native-reanimated"; + +import { act, render, waitFor } from "@testing-library/react-native"; +import { fireGestureHandler, getByGestureTestId } from "react-native-gesture-handler/jest-utils"; + +import Carousel from "./Carousel"; + +{ + const cfg = (global as any).__reanimatedLoggerConfig as + | { logFunction: (data: { level: number; message: string }) => void } + | undefined; + if (cfg) { + const originalLog = cfg.logFunction; + cfg.logFunction = (data) => { + if (data.message.includes("measure() cannot be used with Jest")) return; + originalLog(data); + }; + } +} + +const slideWidth = 300; +const slideHeight = 200; +const gestureTestId = "rnrc-gesture-handler"; +const realPan = Gesture.Pan(); + +jest.spyOn(Gesture, "Pan").mockImplementation(() => realPan.withTestId(gestureTestId)); + +describe("issue #899 parallax reverse loop regression", () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + act(() => { + jest.runOnlyPendingTimers(); + }); + jest.useRealTimers(); + jest.clearAllTimers(); + }); + + it("preserves renderItem styles and props when looping backward from index 0 in parallax mode", async () => { + const progress = { current: 0 }; + const data = Array.from({ length: 30 }, (_, index) => ({ + id: `item-${index}`, + color: index === 29 ? "#111111" : `rgb(${index}, ${index}, ${index})`, + })); + + const Wrapper = () => { + const progressAnimVal = useSharedValue(progress.current); + + useDerivedValue(() => { + progress.current = progressAnimVal.value; + }, [progressAnimVal]); + + return ( + { + progressAnimVal.value = absoluteProgress; + }} + renderItem={({ item, index }) => ( + + )} + /> + ); + }; + + const { getByTestId, queryByTestId } = render(); + + await waitFor(() => { + expect(getByTestId("issue-899-item-0")).toBeTruthy(); + }); + + const prerenderedWrappedItem = getByTestId("issue-899-item-29"); + const prerenderedWrappedStyle = StyleSheet.flatten(prerenderedWrappedItem.props.style); + + expect(prerenderedWrappedItem.props.accessibilityLabel).toBe("issue-899-label-29"); + expect(prerenderedWrappedStyle.backgroundColor).toBe("#111111"); + + fireGestureHandler(getByGestureTestId(gestureTestId), [ + { state: State.BEGAN, translationX: 0, velocityX: slideWidth }, + ]); + + fireGestureHandler(getByGestureTestId(gestureTestId), [ + { state: State.ACTIVE, translationX: slideWidth * 0.7, velocityX: slideWidth }, + ]); + + expect(queryByTestId("issue-899-item-29")).toBeTruthy(); + + await waitFor(() => { + expect(queryByTestId("issue-899-item-29")).toBeTruthy(); + }); + + const activeWrappedItem = getByTestId("issue-899-item-29"); + const activeWrappedStyle = StyleSheet.flatten(activeWrappedItem.props.style); + + expect(activeWrappedItem.props.accessibilityLabel).toBe("issue-899-label-29"); + expect(activeWrappedStyle.backgroundColor).toBe("#111111"); + + fireGestureHandler(getByGestureTestId(gestureTestId), [ + { state: State.END, translationX: slideWidth, velocityX: slideWidth }, + ]); + + await waitFor(() => { + expect(progress.current).toBe(29); + }); + + const wrappedItem = getByTestId("issue-899-item-29"); + const flattenedStyle = StyleSheet.flatten(wrappedItem.props.style); + + expect(wrappedItem.props.accessibilityLabel).toBe("issue-899-label-29"); + expect(flattenedStyle.backgroundColor).toBe("#111111"); + }); +}); diff --git a/src/hooks/useVisibleRanges.tsx b/src/hooks/useVisibleRanges.tsx index 11902c81..2cfe04dd 100644 --- a/src/hooks/useVisibleRanges.tsx +++ b/src/hooks/useVisibleRanges.tsx @@ -11,6 +11,72 @@ export interface VisibleRanges { export type IVisibleRanges = SharedValue; +function normalizeWindowSize(total: number, windowSize?: number) { + return typeof windowSize === "number" && Number.isFinite(windowSize) && windowSize > 0 + ? windowSize + : total; +} + +function normalizeLoopIndex(currentIndex: number, total: number) { + return currentIndex < 0 ? (currentIndex % total) + total : currentIndex; +} + +export function computeVisibleRanges(params: { + total: number; + windowSize?: number; + currentIndex: number; + loop?: boolean; +}): VisibleRanges { + "worklet"; + + const { total = 0, loop } = params; + const windowSize = normalizeWindowSize(total, params.windowSize); + + if (total <= 0) { + return { + negativeRange: [0, 0], + positiveRange: [0, -1], + }; + } + + const positiveCount = Math.round(windowSize / 2); + const negativeCount = windowSize - positiveCount; + + let currentIndex = params.currentIndex; + if (!Number.isFinite(currentIndex)) currentIndex = 0; + + if (!loop) { + currentIndex = Math.max(0, Math.min(total - 1, currentIndex)); + return { + negativeRange: [Math.max(0, currentIndex - (windowSize - 1)), currentIndex], + positiveRange: [currentIndex, Math.min(total - 1, currentIndex + (windowSize - 1))], + }; + } + + currentIndex = normalizeLoopIndex(currentIndex, total); + + const negativeRange: Range = [ + (currentIndex - negativeCount + total) % total, + (currentIndex - 1 + total) % total, + ]; + + const positiveRange: Range = [ + (currentIndex + total) % total, + (currentIndex + positiveCount + total) % total, + ]; + + if (negativeRange[0] < total && negativeRange[0] > negativeRange[1]) { + negativeRange[1] = total - 1; + positiveRange[0] = 0; + } + if (positiveRange[0] > positiveRange[1]) { + negativeRange[1] = total - 1; + positiveRange[0] = 0; + } + + return { negativeRange, positiveRange }; +} + export function useVisibleRanges(options: { total: number; viewSize: number; @@ -20,18 +86,12 @@ export function useVisibleRanges(options: { }): IVisibleRanges { const { total = 0, viewSize, translation, windowSize: _windowSize, loop } = options; - const windowSize = - typeof _windowSize === "number" && Number.isFinite(_windowSize) && _windowSize > 0 - ? _windowSize - : total; + const windowSize = normalizeWindowSize(total, _windowSize); const cachedRanges = useRef(null); const ranges = useDerivedValue(() => { if (total <= 0) { - return { - negativeRange: [0, 0] as Range, - positiveRange: [0, -1] as Range, - }; + return computeVisibleRanges({ total, currentIndex: 0, windowSize, loop }); } // Prevent division by zero when viewSize is not yet measured @@ -42,50 +102,10 @@ export function useVisibleRanges(options: { }; } - const positiveCount = Math.round(windowSize / 2); - const negativeCount = windowSize - positiveCount; - let currentIndex = Math.round(-translation.value / viewSize); if (!Number.isFinite(currentIndex)) currentIndex = 0; - let newRanges: VisibleRanges; - - if (!loop) { - // Clamp currentIndex to valid range [0, total-1] for non-loop mode - // When overdragging right, translation.value becomes positive, making currentIndex negative - currentIndex = Math.max(0, Math.min(total - 1, currentIndex)); - - // Adjusting negative range if the carousel is not loopable. - // So, It will be only displayed the positive items. - newRanges = { - negativeRange: [Math.max(0, currentIndex - (windowSize - 1)), currentIndex], - positiveRange: [currentIndex, Math.min(total - 1, currentIndex + (windowSize - 1))], - }; - } else { - currentIndex = currentIndex < 0 ? (currentIndex % total) + total : currentIndex; - - const negativeRange: Range = [ - (currentIndex - negativeCount + total) % total, - (currentIndex - 1 + total) % total, - ]; - - const positiveRange: Range = [ - (currentIndex + total) % total, - (currentIndex + positiveCount + total) % total, - ]; - - if (negativeRange[0] < total && negativeRange[0] > negativeRange[1]) { - negativeRange[1] = total - 1; - positiveRange[0] = 0; - } - if (positiveRange[0] > positiveRange[1]) { - negativeRange[1] = total - 1; - positiveRange[0] = 0; - } - - // console.log({ negativeRange, positiveRange ,total,windowSize,a:total <= _windowSize}) - newRanges = { negativeRange, positiveRange }; - } + const newRanges = computeVisibleRanges({ total, windowSize, currentIndex, loop }); if ( cachedRanges.current &&