Skip to content

Commit bf06a80

Browse files
authored
fix(web): stabilize paging sync for autoplay, controller, and double-click zoom (#152)
* fix(web): stabilize paging sync for autoplay, controller, and double-click zoom * fix(web): stabilize paging and double-click zoom behavior * fix(web): stabilize paging state and double-click zoom behavior * fix(controller): reset transforms before programmatic page navigation
1 parent 0bb80ef commit bf06a80

10 files changed

Lines changed: 908 additions & 146 deletions

.changeset/shaggy-showers-judge.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
'react-native-gesture-image-viewer': patch
3+
---
4+
5+
fix(web): stabilize paging sync for autoplay, controller, and double-click zoom
6+
7+
Fix several web-specific paging and interaction issues in `GestureViewer`.
8+
9+
This improves loop and autoplay index synchronization on web, keeps controller-driven navigation in sync, restores double-click zoom, and refines web paging behavior so settled pages match the browser's final scroll position more naturally.

src/GestureViewer.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,9 @@ export function GestureViewer<ItemT, LC>({
5959
dismissGesture,
6060
zoomGesture,
6161
nativeScrollGesture,
62+
onWebDoubleClick,
6263
onMomentumScrollEnd,
64+
onScroll,
6365
onScrollBeginDrag,
6466
animatedStyle,
6567
backdropStyle,
@@ -136,7 +138,8 @@ export function GestureViewer<ItemT, LC>({
136138
horizontal: true,
137139
scrollEnabled: !isZoomed && !isRotated && !isPinching,
138140
showsHorizontalScrollIndicator: false,
139-
onMomentumScrollEnd: onMomentumScrollEnd,
141+
onMomentumScrollEnd,
142+
onScroll,
140143
onScrollBeginDrag,
141144
...(enableSnapMode
142145
? {
@@ -157,6 +160,7 @@ export function GestureViewer<ItemT, LC>({
157160
isRotated,
158161
isPinching,
159162
onMomentumScrollEnd,
163+
onScroll,
160164
onScrollBeginDrag,
161165
enableSnapMode,
162166
],
@@ -187,6 +191,7 @@ export function GestureViewer<ItemT, LC>({
187191
<Animated.View style={[styles.background, backdropStyleProps, backdropStyle]} />
188192
<Animated.View
189193
style={[styles.content, animatedStyle]}
194+
{...(Platform.OS === 'web' && { onClick: onWebDoubleClick })}
190195
{...(Platform.OS === 'web' &&
191196
isFlashListLike(Component) && { dataSet: { 'flash-list-paging-enabled-fix': true } })}
192197
>

src/GestureViewerManager.ts

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,10 @@ class GestureViewerManager {
2222
private rotation: SharedValue<number> | null = null;
2323
private translateX: SharedValue<number> | null = null;
2424
private translateY: SharedValue<number> | null = null;
25+
private resetTransformCallback: (() => void) | null = null;
2526

2627
private loopCallback: (() => void) | null = null;
28+
private programmaticScrollVersion = 0;
2729

2830
private listeners = new Set<(state: GestureViewerState) => void>();
2931
private eventListeners = new Map<GestureViewerEventType, Set<(data: any) => void>>();
@@ -91,6 +93,10 @@ class GestureViewerManager {
9193
};
9294
}
9395

96+
getProgrammaticScrollVersion() {
97+
return this.programmaticScrollVersion;
98+
}
99+
94100
setEnableLoop(enabled: boolean) {
95101
this.enableLoop = enabled;
96102
}
@@ -133,6 +139,10 @@ class GestureViewerManager {
133139
this.maxZoomScale = maxZoomScale;
134140
}
135141

142+
setResetTransformCallback(callback: (() => void) | null) {
143+
this.resetTransformCallback = callback;
144+
}
145+
136146
notifyStateChange() {
137147
this.notifyListeners();
138148
}
@@ -255,7 +265,7 @@ class GestureViewerManager {
255265
return;
256266
}
257267

258-
this.loopCallback = null;
268+
this.cancelPendingLoopTransition();
259269

260270
const { scrollTo } = createScrollAction(this.listRef, this.width);
261271

@@ -264,9 +274,11 @@ class GestureViewerManager {
264274
this.loopCallback = () => {
265275
scrollTo(this.dataLength, false);
266276
this.updateCurrentIndex(this.dataLength - 1);
267-
this.loopCallback = null;
277+
this.cancelPendingLoopTransition();
268278
};
269279

280+
this.resetTransformCallback?.();
281+
this.programmaticScrollVersion += 1;
270282
scrollTo(0, true);
271283
return;
272284
}
@@ -275,13 +287,17 @@ class GestureViewerManager {
275287
this.loopCallback = () => {
276288
scrollTo(1, false);
277289
this.updateCurrentIndex(0);
278-
this.loopCallback = null;
290+
this.cancelPendingLoopTransition();
279291
};
280292

293+
this.resetTransformCallback?.();
294+
this.programmaticScrollVersion += 1;
281295
scrollTo(this.dataLength + 1, true);
282296
return;
283297
}
284298

299+
this.resetTransformCallback?.();
300+
this.programmaticScrollVersion += 1;
285301
scrollTo(index + 1, true);
286302
this.updateCurrentIndex(index);
287303

@@ -292,6 +308,8 @@ class GestureViewerManager {
292308
return;
293309
}
294310

311+
this.resetTransformCallback?.();
312+
this.programmaticScrollVersion += 1;
295313
scrollTo(index, true);
296314
this.updateCurrentIndex(index);
297315
};
@@ -306,14 +324,18 @@ class GestureViewerManager {
306324
return true;
307325
}
308326

309-
this.loopCallback = null;
327+
this.cancelPendingLoopTransition();
310328
return true;
311329
};
312330

313-
handleScrollBeginDrag = () => {
331+
cancelPendingLoopTransition = () => {
314332
this.loopCallback = null;
315333
};
316334

335+
handleScrollBeginDrag = () => {
336+
this.cancelPendingLoopTransition();
337+
};
338+
317339
goToPrevious = () => {
318340
this.goToIndex(this.currentIndex - 1);
319341
};
@@ -323,7 +345,7 @@ class GestureViewerManager {
323345
};
324346

325347
cleanUp() {
326-
this.loopCallback = null;
348+
this.cancelPendingLoopTransition();
327349
this.listeners.clear();
328350
this.listRef = null;
329351
this.enableHorizontalSwipe = true;
@@ -334,6 +356,7 @@ class GestureViewerManager {
334356
this.translateX = null;
335357
this.translateY = null;
336358
this.rotation = null;
359+
this.resetTransformCallback = null;
337360
this.eventListeners.clear();
338361
}
339362

src/__tests__/webScroll.test.ts

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import { describe, expect, it } from '@jest/globals';
2+
3+
import { getWebAutoPlayTargetPhysicalIndex, resolveWebScrollFinalState } from '../utils/webScroll';
4+
5+
describe('web scroll settling', () => {
6+
it('wraps from the first page to the last page when loop is enabled', () => {
7+
expect(
8+
resolveWebScrollFinalState({
9+
dataLength: 5,
10+
enableLoop: true,
11+
lastSettledPhysicalIndex: 1,
12+
offsetX: 0,
13+
pageWidth: 100,
14+
}),
15+
).toEqual({
16+
logicalIndex: 4,
17+
rawPhysicalIndex: 0,
18+
settledPhysicalIndex: 5,
19+
});
20+
});
21+
22+
it('wraps from the last page to the first page when loop is enabled', () => {
23+
expect(
24+
resolveWebScrollFinalState({
25+
dataLength: 5,
26+
enableLoop: true,
27+
lastSettledPhysicalIndex: 5,
28+
offsetX: 600,
29+
pageWidth: 100,
30+
}),
31+
).toEqual({
32+
logicalIndex: 0,
33+
rawPhysicalIndex: 6,
34+
settledPhysicalIndex: 1,
35+
});
36+
});
37+
38+
it('treats a FlashList-style jump from the first page to the loop tail as a wrap to the last page', () => {
39+
expect(
40+
resolveWebScrollFinalState({
41+
dataLength: 5,
42+
enableLoop: true,
43+
lastSettledPhysicalIndex: 1,
44+
offsetX: 500,
45+
pageWidth: 100,
46+
}),
47+
).toEqual({
48+
logicalIndex: 4,
49+
rawPhysicalIndex: 5,
50+
settledPhysicalIndex: 5,
51+
});
52+
});
53+
54+
it('treats a FlashList-style jump from the last page to the loop head as a wrap to the first page', () => {
55+
expect(
56+
resolveWebScrollFinalState({
57+
dataLength: 5,
58+
enableLoop: true,
59+
lastSettledPhysicalIndex: 5,
60+
offsetX: 100,
61+
pageWidth: 100,
62+
}),
63+
).toEqual({
64+
logicalIndex: 0,
65+
rawPhysicalIndex: 1,
66+
settledPhysicalIndex: 1,
67+
});
68+
});
69+
it('does not settle on an intermediate raw page when the last observed raw page has already changed', () => {
70+
const firstPass = resolveWebScrollFinalState({
71+
dataLength: 5,
72+
enableLoop: false,
73+
lastSettledPhysicalIndex: 1,
74+
offsetX: 200,
75+
pageWidth: 100,
76+
});
77+
78+
expect(firstPass).toEqual({
79+
logicalIndex: 2,
80+
rawPhysicalIndex: 2,
81+
settledPhysicalIndex: 2,
82+
});
83+
});
84+
it('keeps non-loop web paging aligned to the browser-settled logical page', () => {
85+
expect(
86+
resolveWebScrollFinalState({
87+
dataLength: 5,
88+
enableLoop: false,
89+
lastSettledPhysicalIndex: 1,
90+
offsetX: 400,
91+
pageWidth: 100,
92+
}),
93+
).toEqual({
94+
logicalIndex: 4,
95+
rawPhysicalIndex: 4,
96+
settledPhysicalIndex: 4,
97+
});
98+
});
99+
100+
it('calculates the next autoplay target for looped web paging', () => {
101+
expect(
102+
getWebAutoPlayTargetPhysicalIndex({
103+
currentIndex: 3,
104+
dataLength: 5,
105+
enableLoop: true,
106+
}),
107+
).toBe(5);
108+
109+
expect(
110+
getWebAutoPlayTargetPhysicalIndex({
111+
currentIndex: 4,
112+
dataLength: 5,
113+
enableLoop: true,
114+
}),
115+
).toBe(6);
116+
});
117+
});

0 commit comments

Comments
 (0)