diff --git a/src/components/Carousel.test.tsx b/src/components/Carousel.test.tsx index e6df8a7a..d142a748 100644 --- a/src/components/Carousel.test.tsx +++ b/src/components/Carousel.test.tsx @@ -10,7 +10,7 @@ import { fireGestureHandler, getByGestureTestId } from "react-native-gesture-han import Carousel from "./Carousel"; -import type { TCarouselProps } from "../types"; +import type { TCarouselActionOptions, TCarouselProps } from "../types"; jest.setTimeout(1000 * 12); @@ -563,4 +563,121 @@ describe("Test the real swipe behavior of Carousel to ensure it's working as exp expect(progress.current).toBe(2); }); }); + + it("should scroll to correct page when calling next() or scrollTo() after left overscroll at first page with loop=false and overscrollEnabled=true", async () => { + const handlerOffset = { current: 0 }; + let nextSlide: (() => void) | undefined; + let scrollToIndex: ((opts?: TCarouselActionOptions) => void) | undefined; + const Wrapper: FC>> = React.forwardRef((customProps, ref) => { + const progressAnimVal = useSharedValue(0); + const mockHandlerOffset = useSharedValue(handlerOffset.current); + const defaultRenderItem = ({ + item, + index, + }: { + item: string; + index: number; + }) => ( + + {item} + + ); + const { renderItem = defaultRenderItem, ...defaultProps } = createDefaultProps( + progressAnimVal, + customProps + ); + + useDerivedValue(() => { + handlerOffset.current = mockHandlerOffset.value; + }, [mockHandlerOffset]); + + return ( + + ); + }); + + const { getByTestId } = render( + { + if (ref) { + nextSlide = ref.next; + scrollToIndex = ref.scrollTo; + } + }} + loop={false} + overscrollEnabled + /> + ); + await verifyInitialRender(getByTestId); + + // Simulate left overscroll at 1st page (index 0) + fireGestureHandler(getByGestureTestId(gestureTestId), [ + { state: State.BEGAN, translationX: 0, velocityX: 0 }, + { + state: State.ACTIVE, + translationX: slideWidth / 4, + velocityX: slideWidth, + }, + { + state: State.ACTIVE, + translationX: 0.00003996, + velocityX: slideWidth, + }, + { + state: State.END, + translationX: 0.00003996, + velocityX: slideWidth, + }, + ]); + + /** + * Call next() after overscroll - should move to next page correctly + */ + nextSlide?.(); + await waitFor(() => { + expect(handlerOffset.current).toBe(-1 * slideWidth); // Should move to page 1 + }); + + // Simulate left overscroll at 1st page (index 0) + fireGestureHandler(getByGestureTestId(gestureTestId), [ + { + state: State.BEGAN, + translationX: 0, + velocityX: -slideWidth, + }, + { + state: State.ACTIVE, + translationX: slideWidth, + velocityX: slideWidth, + }, + { + state: State.ACTIVE, + translationX: slideWidth + 0.00003996, + velocityX: slideWidth, + }, + { + state: State.END, + translationX: slideWidth + 0.00003996, + velocityX: slideWidth, + }, + ]); + + /** + * Go to 1st slide. After left overscroll, execute ref.scrollTo({}) + */ + scrollToIndex?.({ + index: 1, + }); + await waitFor(() => { + expect(handlerOffset.current).toBe(-1 * slideWidth); // Should move to page 1 + }); + }); }); diff --git a/src/components/ScrollViewGesture.tsx b/src/components/ScrollViewGesture.tsx index c935c4b7..4ccc6304 100644 --- a/src/components/ScrollViewGesture.tsx +++ b/src/components/ScrollViewGesture.tsx @@ -129,7 +129,10 @@ const IScrollViewGesture: React.FC> = (props) => { const origin = translation.value; const velocity = scrollEndVelocityValue; // Default to scroll in the direction of the slide (with deceleration) - let finalTranslation: number = withDecay({ velocity, deceleration: 0.999 }); + let finalTranslation: number = withDecay({ + velocity, + deceleration: 0.999, + }); // If the distance of the swipe exceeds the max scroll distance, keep the view at the current position if ( diff --git a/src/hooks/useCarouselController.test.tsx b/src/hooks/useCarouselController.test.tsx index f87267a1..fe98ac27 100644 --- a/src/hooks/useCarouselController.test.tsx +++ b/src/hooks/useCarouselController.test.tsx @@ -142,7 +142,9 @@ describe("useCarouselController", () => { }); it("should move to next slide", () => { - const { result } = renderHook(() => useCarouselController(defaultProps), { wrapper }); + const { result } = renderHook(() => useCarouselController(defaultProps), { + wrapper, + }); act(() => { result.current.next(); @@ -152,7 +154,9 @@ describe("useCarouselController", () => { }); it("should move to previous slide", () => { - const { result } = renderHook(() => useCarouselController(defaultProps), { wrapper }); + const { result } = renderHook(() => useCarouselController(defaultProps), { + wrapper, + }); act(() => { result.current.prev(); @@ -213,7 +217,9 @@ describe("useCarouselController", () => { }); it("should scroll to specific index", () => { - const { result } = renderHook(() => useCarouselController(defaultProps), { wrapper }); + const { result } = renderHook(() => useCarouselController(defaultProps), { + wrapper, + }); act(() => { result.current.scrollTo({ index: 3 }); @@ -224,7 +230,9 @@ describe("useCarouselController", () => { it("should handle animation callbacks", () => { const onFinished = jest.fn(); - const { result } = renderHook(() => useCarouselController(defaultProps), { wrapper }); + const { result } = renderHook(() => useCarouselController(defaultProps), { + wrapper, + }); act(() => { result.current.next({ @@ -258,7 +266,9 @@ describe("useCarouselController", () => { }); it("should handle non-animated transitions", () => { - const { result } = renderHook(() => useCarouselController(defaultProps), { wrapper }); + const { result } = renderHook(() => useCarouselController(defaultProps), { + wrapper, + }); act(() => { result.current.scrollTo({ index: 2, animated: false }); @@ -268,7 +278,9 @@ describe("useCarouselController", () => { }); it("should handle multiple slide movements", () => { - const { result } = renderHook(() => useCarouselController(defaultProps), { wrapper }); + const { result } = renderHook(() => useCarouselController(defaultProps), { + wrapper, + }); act(() => { result.current.next({ count: 2 }); @@ -304,7 +316,9 @@ describe("useCarouselController", () => { }); it("should handle runOnJS correctly", () => { - const { result } = renderHook(() => useCarouselController(defaultProps), { wrapper }); + const { result } = renderHook(() => useCarouselController(defaultProps), { + wrapper, + }); act(() => { result.current.next(); @@ -353,7 +367,9 @@ describe("useCarouselController imperative handle", () => { // }); it("should maintain correct index through imperative calls", () => { - const { result } = renderHook(() => useCarouselController(defaultProps), { wrapper }); + const { result } = renderHook(() => useCarouselController(defaultProps), { + wrapper, + }); // Get handle methods const createHandle = (useImperativeHandle as jest.Mock).mock.calls[0][1]; @@ -378,7 +394,9 @@ describe("useCarouselController imperative handle", () => { }); it("should handle animation callbacks through imperative calls", () => { - const { result } = renderHook(() => useCarouselController(defaultProps), { wrapper }); + const { result } = renderHook(() => useCarouselController(defaultProps), { + wrapper, + }); const onFinished = jest.fn(); // Get handle methods @@ -422,7 +440,9 @@ describe("useCarouselController imperative handle", () => { }); it("should handle multiple slide movements through imperative calls", () => { - const { result } = renderHook(() => useCarouselController(defaultProps), { wrapper }); + const { result } = renderHook(() => useCarouselController(defaultProps), { + wrapper, + }); // Get handle methods const createHandle = (useImperativeHandle as jest.Mock).mock.calls[0][1]; @@ -459,7 +479,9 @@ describe("useCarouselController edge cases and uncovered lines", () => { }); it("should handle next() without animation - uncovered line 213-214", () => { - const { result } = renderHook(() => useCarouselController(defaultProps), { wrapper }); + const { result } = renderHook(() => useCarouselController(defaultProps), { + wrapper, + }); const onFinished = jest.fn(); act(() => { @@ -497,7 +519,9 @@ describe("useCarouselController edge cases and uncovered lines", () => { }); it("should handle scrollTo() without animation when target equals current index - uncovered line 265", () => { - const { result } = renderHook(() => useCarouselController(defaultProps), { wrapper }); + const { result } = renderHook(() => useCarouselController(defaultProps), { + wrapper, + }); // Set index to 1 act(() => { @@ -536,7 +560,9 @@ describe("useCarouselController edge cases and uncovered lines", () => { }); it("should handle scrollTo() with count parameter - uncovered line 321-326", () => { - const { result } = renderHook(() => useCarouselController(defaultProps), { wrapper }); + const { result } = renderHook(() => useCarouselController(defaultProps), { + wrapper, + }); // Test negative count act(() => { @@ -554,7 +580,9 @@ describe("useCarouselController edge cases and uncovered lines", () => { }); it("should handle scrollTo() with invalid count (should return early)", () => { - const { result } = renderHook(() => useCarouselController(defaultProps), { wrapper }); + const { result } = renderHook(() => useCarouselController(defaultProps), { + wrapper, + }); act(() => { result.current.scrollTo({ count: 0 }); // Should return early @@ -703,7 +731,9 @@ describe("useCarouselController edge cases and uncovered lines", () => { }); it("should get shared index correctly", () => { - const { result } = renderHook(() => useCarouselController(defaultProps), { wrapper }); + const { result } = renderHook(() => useCarouselController(defaultProps), { + wrapper, + }); const sharedIndex = result.current.getSharedIndex(); expect(typeof sharedIndex).toBe("number"); @@ -728,4 +758,37 @@ describe("useCarouselController edge cases and uncovered lines", () => { expect(typeof mockHandlerOffset.value).toBe("number"); }); + + it("should handle scrollTo() and next() correctly after left overscroll at first page in non-loop mode", () => { + const { result } = renderHook( + () => + useCarouselController({ + ...defaultProps, + loop: false, + }), + { wrapper } + ); + + // This small positive value (0.00003996) simulates the overscroll scenario + // where user scrolls right at index 3, creating a slight positive offset + mockHandlerOffset.value = 0.00003996; + + act(() => { + result.current.scrollTo({ + index: 3, + }); + }); + + expect(mockHandlerOffset.value).toBe(-3 * 300); + + // This small positive value (0.00003996) simulates the overscroll scenario + // where user scrolls right at index 0, creating a slight positive offset + mockHandlerOffset.value = 0.00003996; + + act(() => { + result.current.next(); + }); + + expect(mockHandlerOffset.value).toBe(-1 * 300); + }); }); diff --git a/src/hooks/useCarouselController.tsx b/src/hooks/useCarouselController.tsx index 38af010d..a37ac364 100644 --- a/src/hooks/useCarouselController.tsx +++ b/src/hooks/useCarouselController.tsx @@ -79,7 +79,17 @@ export function useCarouselController(options: IOpts): ICarouselController { const currentFixedPage = React.useCallback(() => { if (loop) return -Math.round(handlerOffset.value / size); - const fixed = (handlerOffset.value / size) % dataInfo.length; + /* FIX: Handle overscroll edge case when loop=false + * Without this fix, when overscrolling to the left at index 0: + * - handlerOffset.value becomes slightly positive during overscroll + * - fixed calculation results in a small positive value + * - Returned index becomes dataInfo.length - fixed ≈ dataInfo.length (incorrect) + * This causes unwanted next() API calls during left overscroll + * + * The fix ensures Math.round(handlerOffset.value / size) returns 0 during + * left overscroll at index 0, maintaining correct page index + */ + const fixed = Math.round(handlerOffset.value / size) % dataInfo.length; return Math.round( handlerOffset.value <= 0 ? Math.abs(fixed) : Math.abs(fixed > 0 ? dataInfo.length - fixed : 0) ); @@ -268,7 +278,7 @@ export function useCarouselController(options: IOpts): ICarouselController { onScrollStart?.(); // direction -> 1 | -1 - const direction = handlerOffsetDirection(handlerOffset, fixedDirection); + const direction = handlerOffsetDirection(handlerOffset, fixedDirection, loop); // target offset const offset = i * size * direction; diff --git a/src/hooks/useCommonVariables.ts b/src/hooks/useCommonVariables.ts index c3d83bf8..130c446d 100644 --- a/src/hooks/useCommonVariables.ts +++ b/src/hooks/useCommonVariables.ts @@ -44,7 +44,7 @@ export function useCommonVariables(props: TInitializeCarouselProps): ICommo ({ shouldComputed, previousLength, currentLength }) => { if (shouldComputed) { // direction -> 1 | -1 - const direction = handlerOffsetDirection(handlerOffset); + const direction = handlerOffsetDirection(handlerOffset, undefined, loop); handlerOffset.value = computeOffsetIfDataChanged({ direction, diff --git a/src/utils/handleroffset-direction.ts b/src/utils/handleroffset-direction.ts index 716ae892..d4a92bc3 100644 --- a/src/utils/handleroffset-direction.ts +++ b/src/utils/handleroffset-direction.ts @@ -4,7 +4,8 @@ import type { TCarouselProps } from "../types"; export function handlerOffsetDirection( handlerOffset: SharedValue, - fixedDirection?: TCarouselProps["fixedDirection"] + fixedDirection?: TCarouselProps["fixedDirection"], + loop?: boolean ): -1 | 1 { "worklet"; @@ -14,5 +15,10 @@ export function handlerOffsetDirection( if (handlerOffset.value === 0) return -1; + // Handle overscroll case when loop is disabled + // When loop=false and small positive offset occurs (overscroll at index 0), + // return -1 to maintain consistent direction handling + if (loop === false) return -1; + return Math.sign(handlerOffset.value) as -1 | 1; }