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
119 changes: 118 additions & 1 deletion src/components/Carousel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -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<Partial<TCarouselProps<string>>> = React.forwardRef((customProps, ref) => {
const progressAnimVal = useSharedValue(0);
const mockHandlerOffset = useSharedValue(handlerOffset.current);
const defaultRenderItem = ({
item,
index,
}: {
item: string;
index: number;
}) => (
<Animated.View
testID={`carousel-item-${index}`}
style={{ width: slideWidth, height: slideHeight, flex: 1 }}
>
{item}
</Animated.View>
);
const { renderItem = defaultRenderItem, ...defaultProps } = createDefaultProps(
progressAnimVal,
customProps
);

useDerivedValue(() => {
handlerOffset.current = mockHandlerOffset.value;
}, [mockHandlerOffset]);

return (
<Carousel
{...defaultProps}
defaultScrollOffsetValue={mockHandlerOffset}
renderItem={renderItem}
ref={ref}
/>
);
});

const { getByTestId } = render(
<Wrapper
ref={(ref) => {
if (ref) {
nextSlide = ref.next;
scrollToIndex = ref.scrollTo;
}
}}
loop={false}
overscrollEnabled
/>
);
await verifyInitialRender(getByTestId);

// Simulate left overscroll at 1st page (index 0)
fireGestureHandler<PanGesture>(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<PanGesture>(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
});
});
});
5 changes: 4 additions & 1 deletion src/components/ScrollViewGesture.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,10 @@ const IScrollViewGesture: React.FC<PropsWithChildren<Props>> = (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 (
Expand Down
93 changes: 78 additions & 15 deletions src/hooks/useCarouselController.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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();
Expand Down Expand Up @@ -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 });
Expand All @@ -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({
Expand Down Expand Up @@ -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 });
Expand All @@ -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 });
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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];
Expand All @@ -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
Expand Down Expand Up @@ -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];
Expand Down Expand Up @@ -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(() => {
Expand Down Expand Up @@ -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(() => {
Expand Down Expand Up @@ -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(() => {
Expand All @@ -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
Expand Down Expand Up @@ -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");
Expand All @@ -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);
});
});
14 changes: 12 additions & 2 deletions src/hooks/useCarouselController.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)
);
Expand Down Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion src/hooks/useCommonVariables.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ export function useCommonVariables(props: TInitializeCarouselProps<any>): ICommo
({ shouldComputed, previousLength, currentLength }) => {
if (shouldComputed) {
// direction -> 1 | -1
const direction = handlerOffsetDirection(handlerOffset);
const direction = handlerOffsetDirection(handlerOffset, undefined, loop);

handlerOffset.value = computeOffsetIfDataChanged({
direction,
Expand Down
Loading