Skip to content

Commit bc7daaf

Browse files
authored
Fix non-loop overscroll after left edge (credit PR 839) (#871)
fix: correct non-loop overscroll (credits pr839)
1 parent 566bf52 commit bc7daaf

File tree

3 files changed

+104
-5
lines changed

3 files changed

+104
-5
lines changed
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"react-native-reanimated-carousel": patch
3+
---
4+
5+
Fix non-loop overscroll direction so tiny positive offsets at the first page no longer wrap to the last page when calling next()/scrollTo(), and add integration coverage for the scenario. Thanks to @hennessyevan for the original report and PR 839 inspiration.

src/components/Carousel.test.tsx

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -806,4 +806,87 @@ describe("Test the real swipe behavior of Carousel to ensure it's working as exp
806806
TICK();
807807
pushExpect(1);
808808
});
809+
810+
it("should keep correct page after left overscroll at first page when calling next() or scrollTo()", async () => {
811+
const handlerOffset = { current: 0 };
812+
let nextSlide: ((opts?: { animated?: boolean }) => void) | undefined;
813+
let scrollToIndex: ((opts?: { index: number; animated?: boolean }) => void) | undefined;
814+
815+
const Wrapper: FC<Partial<TCarouselProps<string>>> = React.forwardRef((customProps, ref) => {
816+
const progressAnimVal = useSharedValue(0);
817+
const mockHandlerOffset = useSharedValue(handlerOffset.current);
818+
const defaultRenderItem = ({
819+
item,
820+
index,
821+
}: {
822+
item: string;
823+
index: number;
824+
}) => (
825+
<Animated.View
826+
testID={`carousel-item-${index}`}
827+
style={{ width: slideWidth, height: slideHeight }}
828+
>
829+
{item}
830+
</Animated.View>
831+
);
832+
const { renderItem = defaultRenderItem, ...defaultProps } = createDefaultProps(
833+
progressAnimVal,
834+
customProps
835+
);
836+
837+
useDerivedValue(() => {
838+
handlerOffset.current = mockHandlerOffset.value;
839+
}, [mockHandlerOffset]);
840+
841+
return (
842+
<Carousel
843+
{...defaultProps}
844+
defaultScrollOffsetValue={mockHandlerOffset}
845+
renderItem={renderItem}
846+
ref={ref}
847+
/>
848+
);
849+
});
850+
851+
const { getByTestId } = render(
852+
<Wrapper
853+
ref={(ref) => {
854+
if (ref) {
855+
nextSlide = ref.next;
856+
scrollToIndex = ref.scrollTo;
857+
}
858+
}}
859+
loop={false}
860+
overscrollEnabled
861+
style={{ width: slideWidth, height: slideHeight }}
862+
/>
863+
);
864+
await verifyInitialRender(getByTestId);
865+
866+
// Simulate left overscroll at first page
867+
fireGestureHandler<PanGesture>(getByGestureTestId(gestureTestId), [
868+
{ state: State.BEGAN, translationX: 0, velocityX: 0 },
869+
{ state: State.ACTIVE, translationX: slideWidth / 4, velocityX: slideWidth },
870+
{ state: State.ACTIVE, translationX: 0.00003996, velocityX: slideWidth },
871+
{ state: State.END, translationX: 0.00003996, velocityX: slideWidth },
872+
]);
873+
874+
nextSlide?.({ animated: false });
875+
await waitFor(() => {
876+
expect(handlerOffset.current).toBe(-slideWidth);
877+
});
878+
879+
// Overscroll again, then call scrollTo()
880+
fireGestureHandler<PanGesture>(getByGestureTestId(gestureTestId), [
881+
{ state: State.BEGAN, translationX: 0, velocityX: -slideWidth },
882+
{ state: State.ACTIVE, translationX: slideWidth, velocityX: slideWidth },
883+
{ state: State.ACTIVE, translationX: slideWidth + 0.00003996, velocityX: slideWidth },
884+
{ state: State.END, translationX: slideWidth + 0.00003996, velocityX: slideWidth },
885+
]);
886+
887+
scrollToIndex?.({ index: 1, animated: false });
888+
await waitFor(() => {
889+
expect(handlerOffset.current).toBe(-slideWidth);
890+
});
891+
});
809892
});

src/hooks/useCarouselController.tsx

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -83,10 +83,13 @@ export function useCarouselController(options: IOpts): ICarouselController {
8383
if (size <= 0) return 0;
8484
if (loop) return -Math.round(handlerOffset.value / size);
8585

86-
const fixed = (handlerOffset.value / size) % dataInfo.length;
87-
return Math.round(
88-
handlerOffset.value <= 0 ? Math.abs(fixed) : Math.abs(fixed > 0 ? dataInfo.length - fixed : 0)
89-
);
86+
// In non-loop mode, treat the offset as negative when moving forward.
87+
// Clamp within valid range to avoid wrapping when a tiny positive offset
88+
// appears after overscroll at index 0.
89+
const rawPage = -handlerOffset.value / size;
90+
const rounded = Math.round(rawPage);
91+
const clamped = Math.max(0, Math.min(dataInfo.length - 1, rounded));
92+
return clamped;
9093
}, [handlerOffset, dataInfo, size, loop]);
9194

9295
function setSharedIndex(newSharedIndex: number) {
@@ -350,7 +353,15 @@ export function useCarouselController(options: IOpts): ICarouselController {
350353

351354
onScrollStart?.();
352355
// direction -> 1 | -1
353-
const direction = handlerOffsetDirection(handlerOffset, fixedDirection);
356+
let direction: -1 | 1;
357+
if (fixedDirection === "positive") direction = 1;
358+
else if (fixedDirection === "negative") direction = -1;
359+
else if (!loop) {
360+
const currentPage = currentFixedPage();
361+
direction = i >= currentPage ? -1 : 1;
362+
} else {
363+
direction = handlerOffsetDirection(handlerOffset);
364+
}
354365

355366
// target offset
356367
const offset = i * size * direction;

0 commit comments

Comments
 (0)