Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/components/CarouselLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,7 @@ export const CarouselLayout = React.forwardRef<ICarouselInstance>((_props, ref)
loop={loop}
size={size}
windowSize={windowSize}
defaultIndex={defaultIndex}
autoFillData={autoFillData}
offsetX={offsetX}
handlerOffset={handlerOffset}
Expand Down
17 changes: 11 additions & 6 deletions src/components/ItemRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -20,6 +20,7 @@ interface Props {
loop: boolean;
size: number;
windowSize?: number;
defaultIndex: number;
autoFillData: boolean;
offsetX: SharedValue<number>;
handlerOffset: SharedValue<number>;
Expand All @@ -33,6 +34,7 @@ export const ItemRenderer: FC<Props> = (props) => {
data,
size,
windowSize,
defaultIndex,
handlerOffset,
offsetX,
dataLength,
Expand All @@ -54,11 +56,14 @@ export const ItemRenderer: FC<Props> = (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<VisibleRanges>(initialRanges);
Expand Down
131 changes: 131 additions & 0 deletions src/components/issue-899-parallax-reverse-loop.test.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Carousel
data={data}
defaultIndex={0}
loop
mode="parallax"
windowSize={5}
style={{ width: slideWidth, height: slideHeight }}
onProgressChange={(_, absoluteProgress) => {
progressAnimVal.value = absoluteProgress;
}}
renderItem={({ item, index }) => (
<Animated.View
testID={`issue-899-item-${index}`}
accessibilityLabel={`issue-899-label-${index}`}
style={{
width: slideWidth,
height: slideHeight,
backgroundColor: item.color,
}}
/>
)}
/>
);
};

const { getByTestId, queryByTestId } = render(<Wrapper />);

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<PanGesture>(getByGestureTestId(gestureTestId), [
{ state: State.BEGAN, translationX: 0, velocityX: slideWidth },
]);

fireGestureHandler<PanGesture>(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<PanGesture>(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");
});
});
118 changes: 69 additions & 49 deletions src/hooks/useVisibleRanges.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,72 @@ export interface VisibleRanges {

export type IVisibleRanges = SharedValue<VisibleRanges>;

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;
Expand All @@ -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<VisibleRanges | null>(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
Expand All @@ -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 &&
Expand Down
Loading