diff --git a/.changeset/wise-drinks-explode.md b/.changeset/wise-drinks-explode.md new file mode 100644 index 00000000..b5b23a61 --- /dev/null +++ b/.changeset/wise-drinks-explode.md @@ -0,0 +1,5 @@ +--- +"react-native-reanimated-carousel": patch +--- + +improve "slow pan" behavior: if it seems that the user intent is to stay on the current page (because they didn't pan very far; maybe they started panning one direction then reversed direction, etc.), _don't_ actually change page upon gesture completion diff --git a/biome.json b/biome.json index 99a2e0c5..8e9c1ba2 100644 --- a/biome.json +++ b/biome.json @@ -53,6 +53,8 @@ "example/app/node_modules/**/*", "example/app/metro.config.js", "example/app/babel.config.js", + "example/app/ios/**/*", + "example/app/android/**/*", "scripts/**/*", "package.json" ] diff --git a/example/app/app/demos/basic-layouts/left-align/index.tsx b/example/app/app/demos/basic-layouts/left-align/index.tsx index f46a7a4a..be59d7f5 100644 --- a/example/app/app/demos/basic-layouts/left-align/index.tsx +++ b/example/app/app/demos/basic-layouts/left-align/index.tsx @@ -1,5 +1,6 @@ import { CarouselAdvancedSettingsPanel } from "@/components/CarouselAdvancedSettingsPanel"; import { defaultDataWith6Colors } from "@/components/CarouselBasicSettingsPanel"; +import { window } from "@/constants/sizes"; import { useAdvancedSettings } from "@/hooks/useSettings"; import { CaptureWrapper } from "@/store/CaptureProvider"; import { renderItem } from "@/utils/render-item"; @@ -22,7 +23,7 @@ function Index() { pagingEnabled: true, snapEnabled: true, vertical: false, - width: 430, + width: window.width, }, }); diff --git a/example/app/app/demos/basic-layouts/normal/index.tsx b/example/app/app/demos/basic-layouts/normal/index.tsx index a4190e50..66c37054 100644 --- a/example/app/app/demos/basic-layouts/normal/index.tsx +++ b/example/app/app/demos/basic-layouts/normal/index.tsx @@ -1,5 +1,6 @@ import { CarouselAdvancedSettingsPanel } from "@/components/CarouselAdvancedSettingsPanel"; import { defaultDataWith6Colors } from "@/components/CarouselBasicSettingsPanel"; +import { MAX_WIDTH, window } from "@/constants/sizes"; import { useAdvancedSettings } from "@/hooks/useSettings"; import { CaptureWrapper } from "@/store/CaptureProvider"; import { renderItem } from "@/utils/render-item"; @@ -24,7 +25,7 @@ function Index() { pagingEnabled: true, snapEnabled: true, vertical: false, - width: 430, + width: window.width, }, }); diff --git a/example/app/app/demos/basic-layouts/parallax/demo.tsx b/example/app/app/demos/basic-layouts/parallax/demo.tsx index 94c02cc9..c443c33d 100644 --- a/example/app/app/demos/basic-layouts/parallax/demo.tsx +++ b/example/app/app/demos/basic-layouts/parallax/demo.tsx @@ -1,3 +1,4 @@ +import { window } from "@/constants/sizes"; import { renderItem } from "@/utils/render-item"; import * as React from "react"; import { View } from "react-native"; @@ -18,9 +19,9 @@ function Index() { loop={true} pagingEnabled={true} snapEnabled={true} - width={430} + width={window.width} style={{ - width: 430, + width: window.width, }} mode="parallax" modeConfig={{ diff --git a/example/app/app/demos/basic-layouts/parallax/index.tsx b/example/app/app/demos/basic-layouts/parallax/index.tsx index a6cc26e1..596be8cd 100644 --- a/example/app/app/demos/basic-layouts/parallax/index.tsx +++ b/example/app/app/demos/basic-layouts/parallax/index.tsx @@ -28,7 +28,7 @@ function Index() { pagingEnabled: true, snapEnabled: true, vertical: false, - width: 430, + width: PAGE_WIDTH, }, }); diff --git a/example/app/app/demos/basic-layouts/stack/index.tsx b/example/app/app/demos/basic-layouts/stack/index.tsx index 4615cfca..4fee1793 100644 --- a/example/app/app/demos/basic-layouts/stack/index.tsx +++ b/example/app/app/demos/basic-layouts/stack/index.tsx @@ -5,6 +5,7 @@ import Carousel, { ICarouselInstance } from "react-native-reanimated-carousel"; import { CustomSelectActionItem } from "@/components/ActionItems"; import { CarouselAdvancedSettingsPanel } from "@/components/CarouselAdvancedSettingsPanel"; import { defaultDataWith6Colors } from "@/components/CarouselBasicSettingsPanel"; +import { window } from "@/constants/sizes"; import { useAdvancedSettings } from "@/hooks/useSettings"; import { CaptureWrapper } from "@/store/CaptureProvider"; import { renderItem } from "@/utils/render-item"; @@ -26,7 +27,7 @@ function Index() { pagingEnabled: true, snapEnabled: true, vertical: false, - width: 430, + width: window.width, }, }); diff --git a/src/components/Carousel.test.tsx b/src/components/Carousel.test.tsx index 635dad59..2199adef 100644 --- a/src/components/Carousel.test.tsx +++ b/src/components/Carousel.test.tsx @@ -92,18 +92,17 @@ describe("Test the real swipe behavior of Carousel to ensure it's working as exp // Helper function to simulate swipe const swipeToLeftOnce = ( options: { - itemWidth: number; - } = { - itemWidth: slideWidth, - } + itemWidth?: number; + velocityX?: number; + } = {} ) => { - const { itemWidth } = options; + const { itemWidth = slideWidth, velocityX = -slideWidth } = options; fireGestureHandler(getByGestureTestId(gestureTestId), [ - { state: State.BEGAN, translationX: 0 }, - { state: State.ACTIVE, translationX: -itemWidth * 0.25 }, - { state: State.ACTIVE, translationX: -itemWidth * 0.5 }, - { state: State.ACTIVE, translationX: -itemWidth * 0.75 }, - { state: State.END, translationX: -itemWidth }, + { state: State.BEGAN, translationX: 0, velocityX }, + { state: State.ACTIVE, translationX: -itemWidth * 0.25, velocityX }, + { state: State.ACTIVE, translationX: -itemWidth * 0.5, velocityX }, + { state: State.ACTIVE, translationX: -itemWidth * 0.75, velocityX }, + { state: State.END, translationX: -itemWidth, velocityX }, ]); }; @@ -301,9 +300,9 @@ describe("Test the real swipe behavior of Carousel to ensure it's working as exp await verifyInitialRender(getByTestId); fireGestureHandler(getByGestureTestId(gestureTestId), [ - { state: State.BEGAN, translationX: 0 }, - { state: State.ACTIVE, translationX: -slideWidth * 0.15, velocityX: 0 }, - { state: State.END, translationX: -slideWidth * 0.25, velocityX: 0 }, + { state: State.BEGAN, translationX: 0, velocityX: -5 }, + { state: State.ACTIVE, translationX: -slideWidth * 0.15, velocityX: -5 }, + { state: State.END, translationX: -slideWidth * 0.25, velocityX: -5 }, ]); await waitFor(() => expect(progress.current).toBe(0)); @@ -314,9 +313,9 @@ describe("Test the real swipe behavior of Carousel to ensure it's working as exp await verifyInitialRender(getByTestId); fireGestureHandler(getByGestureTestId(gestureTestId), [ - { state: State.BEGAN, translationX: 0 }, - { state: State.ACTIVE, translationX: -slideWidth * 0.15, velocityX: 0 }, - { state: State.END, translationX: -slideWidth * 0.25, velocityX: 0 }, + { state: State.BEGAN, translationX: 0, velocityX: -1000 }, + { state: State.ACTIVE, translationX: -slideWidth * 0.15, velocityX: -1000 }, + { state: State.END, translationX: -slideWidth * 0.25, velocityX: -1000 }, ]); await waitFor(() => expect(progress.current).toBe(1)); @@ -354,9 +353,9 @@ describe("Test the real swipe behavior of Carousel to ensure it's working as exp await verifyInitialRender(getByTestId); fireGestureHandler(getByGestureTestId(gestureTestId), [ - { state: State.BEGAN, translationX: 0 }, - { state: State.ACTIVE, translationX: slideWidth / 2 }, - { state: State.END, translationX: slideWidth }, + { state: State.BEGAN, translationX: 0, velocityX: 1000 }, + { state: State.ACTIVE, translationX: slideWidth / 2, velocityX: 1000 }, + { state: State.END, translationX: slideWidth, velocityX: 1000 }, ]); await waitFor(() => { @@ -377,9 +376,9 @@ describe("Test the real swipe behavior of Carousel to ensure it's working as exp await verifyInitialRender(getByTestId); fireGestureHandler(getByGestureTestId(gestureTestId), [ - { state: State.BEGAN, translationX: 0 }, - { state: State.ACTIVE, translationX: slideWidth / 2 }, - { state: State.END, translationX: slideWidth }, + { state: State.BEGAN, translationX: 0, velocityX: 1000 }, + { state: State.ACTIVE, translationX: slideWidth / 2, velocityX: 1000 }, + { state: State.END, translationX: slideWidth, velocityX: 1000 }, ]); await waitFor(() => { @@ -407,9 +406,9 @@ describe("Test the real swipe behavior of Carousel to ensure it's working as exp }); fireGestureHandler(getByGestureTestId(gestureTestId), [ - { state: State.BEGAN, translationX: 0 }, - { state: State.ACTIVE, translationX: -slideWidth / 2 }, - { state: State.END, translationX: -slideWidth }, + { state: State.BEGAN, translationX: 0, velocityX: -1000 }, + { state: State.ACTIVE, translationX: -slideWidth / 2, velocityX: -1000 }, + { state: State.END, translationX: -slideWidth, velocityX: -1000 }, ]); await waitFor(() => { @@ -419,21 +418,25 @@ describe("Test the real swipe behavior of Carousel to ensure it's working as exp }); it("`fixedDirection` prop: should swipe to the correct direction when fixedDirection is positive", async () => { - const progress = { current: 0 }; - const Wrapper = createCarousel(progress); { + const progress = { current: 0 }; + const Wrapper = createCarousel(progress); const { getByTestId } = render(); await verifyInitialRender(getByTestId); - swipeToLeftOnce(); - await waitFor(() => expect(progress.current).toBe(3)); + swipeToLeftOnce({ velocityX: slideWidth }); + await waitFor(() => { + expect(progress.current).toBe(3); + }); } { + const progress = { current: 0 }; + const Wrapper = createCarousel(progress); const { getByTestId } = render(); await verifyInitialRender(getByTestId); - swipeToLeftOnce(); + swipeToLeftOnce({ velocityX: -slideWidth }); await waitFor(() => expect(progress.current).toBe(1)); } }); diff --git a/src/components/ScrollViewGesture.tsx b/src/components/ScrollViewGesture.tsx index 0a5933f7..c935c4b7 100644 --- a/src/components/ScrollViewGesture.tsx +++ b/src/components/ScrollViewGesture.tsx @@ -145,13 +145,27 @@ const IScrollViewGesture: React.FC> = (props) => { * * `page size` equals to `size` variable. * */ + + // calculate target "nextPage" based on the final pan position and the velocity of + // the pan gesture at termination; this allows for a quick "flick" to indicate a far + // off page change. + const nextPage = -Math.round((origin + velocity * 2) / size); + if (pagingEnabled) { + // we'll never go further than a single page away from the current page when paging + // is enabled. + // distance with direction const offset = -(scrollEndTranslationValue >= 0 ? 1 : -1); // 1 or -1 const computed = offset < 0 ? Math.ceil : Math.floor; const page = computed(-origin / size); - if (loop) { + const velocityDirection = -Math.sign(velocity); + if (page === nextPage || velocityDirection !== offset) { + // not going anywhere! Velocity was insufficient to overcome the distance to get to a + // further page. Let's reset gently to the current page. + finalTranslation = withSpring(withProcessTranslation(-page * size), onFinished); + } else if (loop) { const finalPage = page + offset; finalTranslation = withSpring(withProcessTranslation(-finalPage * size), onFinished); } else { @@ -162,8 +176,7 @@ const IScrollViewGesture: React.FC> = (props) => { if (!pagingEnabled && snapEnabled) { // scroll to the nearest item - const nextPage = Math.round((origin + velocity * 0.4) / size) * size; - finalTranslation = withSpring(withProcessTranslation(nextPage), onFinished); + finalTranslation = withSpring(withProcessTranslation(-nextPage * size), onFinished); } }