Skip to content
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
15 changes: 15 additions & 0 deletions .changeset/one-swipe-one-page.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
'react-spring-lightbox': patch
---

One trackpad swipe now pages one image, instead of paging through many as its scroll slows down.

- A trackpad sends wheel events for about a second after the fingers lift, as its scroll slows down. With 1.9.0, each of those events faster than 1.1px/ms paged. In jsdom, 10 recorded trackpad swipes from Safari and Chrome on macOS and Chrome on Windows each called `onNext` or `onPrev` 22 to 63 times.
- Now each wheel gesture pages once. A wheel gesture ends 140ms after its last wheel event. Fingers landing on the trackpad for another swipe stop the scroll, so once the scroll is under 0.2px/ms, the next swipe pages too, even before the first swipe's scroll ends.
- A mouse wheel spun quickly also pages once per spin. With 1.9.0, 6 recorded mouse wheel scrolls from Chrome, Edge, Firefox, and Safari paged 3 to 19 times each.
- Scrolling still pages only above 1.1px/ms, the first wheel event of a gesture still only measures its speed, and neither ctrl + wheel nor a zoomed-in image pages.
- Tested by replaying 20 recorded wheel streams from the [wheel-gestures](https://github.com/xiel/wheel-gestures) project with their timing, in jsdom and in Chromium. Each single swipe and mouse wheel scroll paged once, and a recorded double swipe paged twice. The Playwright wheel tests also pass in Firefox. A Mac trackpad was also tried by hand in the repository's demo app. A physical mouse and a Windows touchpad weren't tried by hand.

Upgrade notes:

- If a test sends several quick wheel events and expects the lightbox to page more than once, it now pages once. To page again, wait more than 140ms after the last wheel event, or slow the scroll to under 0.2px/ms first.
51 changes: 49 additions & 2 deletions e2e/paging.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -281,7 +281,7 @@ test.describe('scrolling with a mouse wheel or trackpad', () => {
expect(await shownImage(page)).toBe('Image 3');
});

test('one trackpad swipe moves one image (#52)', async ({ page }) => {
test('one trackpad swipe moves one image', async ({ page }) => {
await openHarness(page, 'index=0');

await page.mouse.move(640, 300);
Expand All @@ -292,11 +292,58 @@ test.describe('scrolling with a mouse wheel or trackpad', () => {
await settle(page);
const { onNext } = await calls(page);

test.fail();
expect(onNext).toBe(1);
});
});

test.describe('swiping on a trackpad in Chromium', () => {
test.skip(
({ browserName, isMobile }) => isMobile || browserName !== 'chromium',
'Wheel events with timestamps through the DevTools protocol'
);

const fingers = (peak: number) => [0.1, 0.3, 0.6, 1].map((share) => Math.round(peak * share));

const momentum = (from: number) => {
const deltas: number[] = [];
for (let delta = from * 0.94; Math.abs(delta) >= 1; delta *= 0.94) {
deltas.push(Math.round(delta));
}
return deltas;
};

const swipe = async (wheel: (point: Point, event: { deltaX: number }) => Promise<unknown>, deltasX: number[]) => {
for (const deltaX of deltasX) {
await wheel([640, 300], { deltaX });
}
};

test('one swipe moves one image, as it keeps scrolling after the fingers lift', async ({ page }) => {
await openHarness(page, 'index=0');
const { wheel } = await chromiumInput(page);

await swipe(wheel, [...fingers(-50), ...momentum(-50)]);
await settle(page);

expect((await calls(page)).onNext).toBe(1);
expect(await shownImage(page)).toBe('Image 2');
});

test('a second swipe moves another image, even before the first one stops scrolling', async ({ page }) => {
await openHarness(page, 'index=0');
const { wheel } = await chromiumInput(page);

await swipe(wheel, [...fingers(-50), ...momentum(-50).slice(0, 8)]);
// Fingers landing on the trackpad stop the scroll. The next wheel event moves 2px in 40ms.
await wheel([640, 300], { deltaX: -2, ms: 40 });
await swipe(wheel, [...fingers(-50), ...momentum(-50)]);
await settle(page);

expect((await calls(page)).onNext).toBe(2);
expect(await shownImage(page)).toBe('Image 3');
});
});

test.describe('the keyboard', () => {
test.skip(({ isMobile }) => isMobile, 'Keyboard input');

Expand Down
6 changes: 6 additions & 0 deletions src/__tests__/devices.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,12 @@ export const trackpad = {
await advance(FRAME);
}
},
swipe: async (target: Element, deltasX: number[]) => {
for (const deltaX of deltasX) {
wheel(target, { deltaX });
await advance(FRAME);
}
},
};

type Finger = { id: number; point: Point };
Expand Down
75 changes: 74 additions & 1 deletion src/__tests__/paging.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,16 @@ const SHORT_LEFT: Point[] = [
[560, 300],
];

const fingers = (peak: number) => [0.1, 0.3, 0.6, 1].map((share) => Math.round(peak * share));

const momentum = (from: number) => {
const deltas: number[] = [];
for (let delta = from * 0.94; Math.abs(delta) >= 1; delta *= 0.94) {
deltas.push(Math.round(delta));
}
return deltas;
};

let layout: ReturnType<typeof mockLayout>;

beforeEach(() => {
Expand Down Expand Up @@ -372,14 +382,77 @@ describe('scrolling with a mouse wheel or trackpad', () => {
expect(shownImage()).toBe('Image 3');
});

test.fails('one trackpad swipe pages one image (#52)', async () => {
test('one trackpad swipe pages one image', async () => {
const props = lightboxProps({ currentIndex: START });
render(<Lightbox {...props} />);

await trackpad.scroll(pageOf(3), { deltaX: -600, ticks: 6 });

expect(props.onNext).toHaveBeenCalledTimes(1);
});

test('one trackpad swipe pages one image, as it keeps scrolling after the fingers lift', async () => {
const props = lightboxProps({ currentIndex: START });
render(<Lightbox {...props} />);

await trackpad.swipe(pageOf(3), [...fingers(-50), ...momentum(-50)]);
await settle();

expect(props.onNext).toHaveBeenCalledOnce();
});

test('a swipe on a Windows touchpad pages once, though Chrome sends an empty wheel event as the fingers lift', async () => {
const props = lightboxProps({ currentIndex: START });
render(<Lightbox {...props} />);

await trackpad.swipe(pageOf(3), [...fingers(-50), 0, -50, ...momentum(-50)]);
await settle();

expect(props.onNext).toHaveBeenCalledOnce();
});

test('a swipe that slows down and speeds up again, without stopping, pages once', async () => {
const props = lightboxProps({ currentIndex: START });
render(<Lightbox {...props} />);

await trackpad.swipe(pageOf(3), [...fingers(-50), -20, -8, -8, -20, -35, -50, ...momentum(-50)]);
await settle();

expect(props.onNext).toHaveBeenCalledOnce();
});

test.each([
['the same way', -50, 2, 0],
['back', 50, 1, 1],
])(
'a second swipe %s pages again, even before the first one stops scrolling',
async (_, secondPeak, nextCalls, prevCalls) => {
const props = lightboxProps({ currentIndex: START });
render(<Lightbox {...props} />);

await trackpad.swipe(pageOf(3), [...fingers(-50), ...momentum(-50).slice(0, 8)]);
// Fingers landing on the trackpad stop the scroll. The next wheel event moves 2px in 40ms.
await advance(40 - FRAME);
await trackpad.swipe(pageOf(3), [-2, ...fingers(secondPeak), ...momentum(secondPeak)]);
await settle();

expect(props.onNext).toHaveBeenCalledTimes(nextCalls);
expect(props.onPrev).toHaveBeenCalledTimes(prevCalls);
}
);

test('a mouse wheel spun quickly pages once', async () => {
const props = lightboxProps({ currentIndex: START });
render(<Lightbox {...props} />);

for (let notch = 0; notch < 5; notch += 1) {
fireEvent.wheel(pageOf(3), { deltaY: 100 });
await advance(50);
}
await settle();

expect(props.onPrev).toHaveBeenCalledOnce();
});
});

describe('the keyboard', () => {
Expand Down
17 changes: 16 additions & 1 deletion src/components/ImageStage/components/ImagePager/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,13 @@ const ImagePager = ({
// Add small timeout buffer to prevent event handlers from firing in child Images
setTimeout(() => setIsDragging(false), 100);
},
onWheel: ({ ctrlKey, delta: [xDelta, yDelta], last, velocity: [xVelocity, yVelocity] }) => {
onWheel: ({
ctrlKey,
delta: [xDelta, yDelta],
last,
memo: canPage = true,
velocity: [xVelocity, yVelocity],
}) => {
// react-use-gesture 9 reported one speed for both axes, and 0 for the
// last wheel event, where @use-gesture repeats the one before it
const velocity = last ? 0 : Math.hypot(xVelocity, yVelocity);
Expand All @@ -176,6 +182,13 @@ const ImagePager = ({
setIsDragging(true);
}

// A trackpad sends wheel events for about a second after the fingers lift,
// as its scroll slows down, so a swipe pages once. Fingers landing for another
// swipe stop the scroll, so the next swipe can page once the scroll nearly stops.
if (!canPage) {
return velocity < 0.2;
}

const draggedFastEnough = velocity > 1.1;

// Handle next/prev image from valid drag
Expand All @@ -188,6 +201,8 @@ const ImagePager = ({
onPrev();
}
}

return !draggedFastEnough;
},
onWheelEnd: () => {
springsApi.start((i) => getPagePositions(i));
Expand Down
Loading