Skip to content

fix: better slow pan: if user intent is to stay on current page, _don't_ change page #731

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
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
5 changes: 5 additions & 0 deletions .changeset/wise-drinks-explode.md
Original file line number Diff line number Diff line change
@@ -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
2 changes: 2 additions & 0 deletions biome.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
]
Expand Down
3 changes: 2 additions & 1 deletion example/app/app/demos/basic-layouts/left-align/index.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -22,7 +23,7 @@ function Index() {
pagingEnabled: true,
snapEnabled: true,
vertical: false,
width: 430,
width: window.width,
},
});

Expand Down
3 changes: 2 additions & 1 deletion example/app/app/demos/basic-layouts/normal/index.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -24,7 +25,7 @@ function Index() {
pagingEnabled: true,
snapEnabled: true,
vertical: false,
width: 430,
width: window.width,
},
});

Expand Down
5 changes: 3 additions & 2 deletions example/app/app/demos/basic-layouts/parallax/demo.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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={{
Expand Down
2 changes: 1 addition & 1 deletion example/app/app/demos/basic-layouts/parallax/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ function Index() {
pagingEnabled: true,
snapEnabled: true,
vertical: false,
width: 430,
width: PAGE_WIDTH,
},
});

Expand Down
3 changes: 2 additions & 1 deletion example/app/app/demos/basic-layouts/stack/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -26,7 +27,7 @@ function Index() {
pagingEnabled: true,
snapEnabled: true,
vertical: false,
width: 430,
width: window.width,
},
});

Expand Down
63 changes: 33 additions & 30 deletions src/components/Carousel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<PanGesture>(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 },
]);
};

Expand Down Expand Up @@ -301,9 +300,9 @@ describe("Test the real swipe behavior of Carousel to ensure it's working as exp
await verifyInitialRender(getByTestId);

fireGestureHandler<PanGesture>(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));
Expand All @@ -314,9 +313,9 @@ describe("Test the real swipe behavior of Carousel to ensure it's working as exp
await verifyInitialRender(getByTestId);

fireGestureHandler<PanGesture>(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));
Expand Down Expand Up @@ -354,9 +353,9 @@ describe("Test the real swipe behavior of Carousel to ensure it's working as exp
await verifyInitialRender(getByTestId);

fireGestureHandler<PanGesture>(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(() => {
Expand All @@ -377,9 +376,9 @@ describe("Test the real swipe behavior of Carousel to ensure it's working as exp
await verifyInitialRender(getByTestId);

fireGestureHandler<PanGesture>(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(() => {
Expand Down Expand Up @@ -407,9 +406,9 @@ describe("Test the real swipe behavior of Carousel to ensure it's working as exp
});

fireGestureHandler<PanGesture>(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(() => {
Expand All @@ -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(<Wrapper fixedDirection="positive" />);
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(<Wrapper fixedDirection="negative" />);
await verifyInitialRender(getByTestId);

swipeToLeftOnce();
swipeToLeftOnce({ velocityX: -slideWidth });
await waitFor(() => expect(progress.current).toBe(1));
}
});
Expand Down
19 changes: 16 additions & 3 deletions src/components/ScrollViewGesture.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -145,13 +145,27 @@ const IScrollViewGesture: React.FC<PropsWithChildren<Props>> = (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 {
Expand All @@ -162,8 +176,7 @@ const IScrollViewGesture: React.FC<PropsWithChildren<Props>> = (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);
}
}

Expand Down
Loading