Skip to content

Commit f31b8a6

Browse files
authored
[Web] Activate ScrollView's native gesture on real scroll instead of pointer distance (#4420)
## Description On web, `NativeViewGestureHandler` activated after ~`15px` of pointer movement in any direction, even though the browser does the scrolling itself. A vertical `ScrollView` would claim horizontal drags, and once active, `InteractionManager` failed any `Pan` whose activation criteria (`minDistance`, `activeOffsetX`, ...) delayed activation past the slop - such pans could never activate inside a `ScrollView` or `FlatList`. This PR adds `ScrollEventManager` which delivers the view's `scroll` events to handlers via a new `onScroll` hook. Handlers with the `ScrollView` role now activate only when the view actually scrolls, with a `2px` pointer travel requirement that ignores momentum-scroll ticks after a touch meant to stop a fling. > [!IMPORTANT] > This covers only new, hook based API ## Test plan - Unit tests for scroll-driven activation, the momentum guard and the unchanged legacy path - Pan activation criteria screen: all boxes activate per their criteria inside the ScrollView, negatives stay inactive, scrolling works - Buttons in FlatList screen: scrolling from a button doesn't fire a press, taps do; tap-to-stop-momentum fires nothing and the next tap works <details> <summary>Tested on the following code:</summary> ```tsx import React, { useRef, useState } from 'react'; import { Button, StyleSheet, Text, View } from 'react-native'; import { GestureDetector, ScrollView, usePanGesture, } from 'react-native-gesture-handler'; import Animated, { interpolateColor, useAnimatedStyle, useSharedValue, withTiming, } from 'react-native-reanimated'; import type { FeedbackHandle } from '../../../common'; import { COLORS, commonStyles, Feedback } from '../../../common'; type PanConfig = Parameters<typeof usePanGesture>[0]; type DraggableBoxProps = { label: string; comment: string; config?: PanConfig; onActivated: (label: string) => void; }; function DraggableBox({ label, comment, config, onActivated }: DraggableBoxProps) { const translateX = useSharedValue(0); const translateY = useSharedValue(0); const colorProgress = useSharedValue(0); const panGesture = usePanGesture({ minDistance: config?.minDistance, minVelocity: config?.minVelocity, activeOffsetX: config?.activeOffsetX, maxPointers: config?.maxPointers, runOnJS: true, onActivate: () => { colorProgress.value = withTiming(1, { duration: 100 }); onActivated(label); }, onUpdate: (event) => { translateX.value = event.translationX; translateY.value = event.translationY; }, onFinalize: () => { colorProgress.value = withTiming(0, { duration: 100 }); translateX.value = withTiming(0); translateY.value = withTiming(0); }, }); const animatedStyle = useAnimatedStyle(() => ({ transform: [ { translateX: translateX.value }, { translateY: translateY.value }, ], backgroundColor: interpolateColor( colorProgress.value, [0, 1], [COLORS.NAVY, COLORS.GREEN] ), })); return ( <View style={[commonStyles.subcontainer, styles.entry]}> <GestureDetector gesture={panGesture}> <Animated.View style={[styles.box, animatedStyle]}> <Text style={styles.label}>{label}</Text> </Animated.View> </GestureDetector> <Text style={commonStyles.instructions}>{comment}</Text> </View> ); } export default function PanActivationCriteriaExample() { const [maxPointers, setMaxPointers] = useState(1); const feedbackRef = useRef<FeedbackHandle>(null); const onActivated = (label: string) => feedbackRef.current?.showMessage(`Activated: ${label}`); return ( <View style={styles.container}> <ScrollView style={styles.scroll}> <Text style={commonStyles.instructions}> Each box turns green the moment its pan activates. Drag each one and verify the activation criteria are respected. </Text> <DraggableBox label="minDistance: 100" comment="Should activate only after the finger travels 100pt in any direction." config={{ minDistance: 100 }} onActivated={onActivated} /> <DraggableBox label="minVelocity: 800" comment="Should activate only on a fast drag (over 800pt/s), regardless of direction. Slow drags must never activate." config={{ minVelocity: 800 }} onActivated={onActivated} /> <DraggableBox label="activeOffsetX: ±60" comment="Should activate only after moving 60pt horizontally. Vertical drags must not activate." config={{ activeOffsetX: [-60, 60] }} onActivated={onActivated} /> <View style={styles.updateSection}> <DraggableBox label={`minDistance: 120\nmaxPointers: ${maxPointers}`} comment="Explicit minDistance combined with another prop updated at runtime. After pressing the button below, activation must still require 120pt of travel — partial config updates must not reset minDistance." config={{ minDistance: 120, maxPointers }} onActivated={onActivated} /> <Button title="Update unrelated prop (maxPointers)" onPress={() => setMaxPointers((prev) => (prev === 1 ? 2 : 1))} /> </View> </ScrollView> <View style={styles.feedbackOverlay} pointerEvents="none"> <Feedback ref={feedbackRef} duration={2000} /> </View> </View> ); } const styles = StyleSheet.create({ container: { flex: 1, }, scroll: { paddingVertical: 24, }, feedbackOverlay: { position: 'absolute', bottom: 20, alignSelf: 'center', }, entry: { paddingVertical: 24, gap: 12, }, box: { width: 150, height: 150, borderRadius: 20, justifyContent: 'center', alignItems: 'center', }, label: { color: 'white', fontWeight: '600', textAlign: 'center', }, updateSection: { paddingBottom: 32, }, }); ``` </details>
1 parent 7c71e64 commit f31b8a6

6 files changed

Lines changed: 309 additions & 6 deletions

File tree

Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
import { ActionType } from '../ActionType';
2+
import { PointerType } from '../PointerType';
3+
import { State } from '../State';
4+
import { NATIVE_GESTURE_ROLE_ATTRIBUTE } from '../web/constants';
5+
import type IGestureHandler from '../web/handlers/IGestureHandler';
6+
import NativeViewGestureHandler from '../web/handlers/NativeViewGestureHandler';
7+
import type { AdaptedEvent } from '../web/interfaces';
8+
import { EventTypes, NativeGestureRole } from '../web/interfaces';
9+
import type { GestureHandlerDelegate } from '../web/tools/GestureHandlerDelegate';
10+
import GestureHandlerOrchestrator from '../web/tools/GestureHandlerOrchestrator';
11+
import ScrollEventManager from '../web/tools/ScrollEventManager';
12+
13+
// The Jest environment is node — provide the minimal DOM surface the handler
14+
// touches (canUseDOM, instanceof HTMLElement).
15+
class FakeHTMLElement {
16+
public style: Record<string, string> = {};
17+
public scrollLeft = 0;
18+
public scrollTop = 0;
19+
private attributes = new Map<string, string>();
20+
private listeners = new Map<string, Set<(event: unknown) => void>>();
21+
22+
public setAttribute(name: string, value: string): void {
23+
this.attributes.set(name, value);
24+
}
25+
public getAttribute(name: string): string | null {
26+
return this.attributes.get(name) ?? null;
27+
}
28+
public hasAttribute(name: string): boolean {
29+
return this.attributes.has(name);
30+
}
31+
public addEventListener(type: string, listener: (event: unknown) => void) {
32+
const listeners = this.listeners.get(type) ?? new Set();
33+
listeners.add(listener);
34+
this.listeners.set(type, listeners);
35+
}
36+
public removeEventListener(type: string, listener: (event: unknown) => void) {
37+
this.listeners.get(type)?.delete(listener);
38+
}
39+
public dispatchEvent(type: string): void {
40+
this.listeners.get(type)?.forEach((listener) => listener({ type }));
41+
}
42+
}
43+
44+
beforeAll(() => {
45+
const globals = globalThis as Record<string, unknown>;
46+
globals.HTMLElement = FakeHTMLElement;
47+
globals.SVGElement = FakeHTMLElement;
48+
globals.window = { document: { createElement: () => new FakeHTMLElement() } };
49+
});
50+
51+
afterAll(() => {
52+
const globals = globalThis as Record<string, unknown>;
53+
delete globals.HTMLElement;
54+
delete globals.SVGElement;
55+
delete globals.window;
56+
});
57+
58+
class TestNativeViewGestureHandler extends NativeViewGestureHandler {
59+
public pointerDown(event: AdaptedEvent): void {
60+
this.onPointerDown(event);
61+
}
62+
63+
public pointerMove(event: AdaptedEvent): void {
64+
this.onPointerMove(event);
65+
}
66+
}
67+
68+
function touchEvent(x: number, y: number, eventType: EventTypes): AdaptedEvent {
69+
return {
70+
x,
71+
y,
72+
offsetX: x,
73+
offsetY: y,
74+
pointerId: 0,
75+
eventType,
76+
pointerType: PointerType.TOUCH,
77+
time: 0,
78+
};
79+
}
80+
81+
function createHandler(view: FakeHTMLElement) {
82+
const delegate = {
83+
view,
84+
init: jest.fn(),
85+
detach: jest.fn(),
86+
reset: jest.fn(),
87+
onActivate: jest.fn(),
88+
onFail: jest.fn(),
89+
onCancel: jest.fn(),
90+
onEnd: jest.fn(),
91+
onEnabledChange: jest.fn(),
92+
updateDOM: jest.fn(),
93+
} as unknown as GestureHandlerDelegate<unknown, IGestureHandler>;
94+
95+
const handler = new TestNativeViewGestureHandler(delegate);
96+
handler.setGestureConfig({ enabled: true });
97+
handler.init(1, { current: {} } as never, ActionType.NATIVE_DETECTOR);
98+
99+
// Route scroll events the same way the real delegate does.
100+
handler.attachEventManager(
101+
new ScrollEventManager(view as unknown as HTMLElement)
102+
);
103+
104+
// The full event pipeline is not under test — silence event emission.
105+
handler.sendEvent = jest.fn();
106+
107+
return handler;
108+
}
109+
110+
describe('NativeViewGestureHandler activation', () => {
111+
afterEach(() => {
112+
// The orchestrator is a singleton — drop handlers recorded by the test.
113+
(
114+
GestureHandlerOrchestrator.instance as unknown as {
115+
gestureHandlers: IGestureHandler[];
116+
}
117+
).gestureHandlers = [];
118+
});
119+
120+
test('scrollable view does not activate on pointer distance alone', () => {
121+
const view = new FakeHTMLElement();
122+
view.setAttribute(
123+
NATIVE_GESTURE_ROLE_ATTRIBUTE,
124+
NativeGestureRole.ScrollView
125+
);
126+
const handler = createHandler(view);
127+
128+
handler.pointerDown(touchEvent(100, 100, EventTypes.DOWN));
129+
expect(handler.state).toBe(State.BEGAN);
130+
131+
handler.pointerMove(touchEvent(100, 200, EventTypes.MOVE));
132+
expect(handler.state).toBe(State.BEGAN);
133+
});
134+
135+
test('scrollable view activates when it really scrolls during a drag', () => {
136+
const view = new FakeHTMLElement();
137+
view.setAttribute(
138+
NATIVE_GESTURE_ROLE_ATTRIBUTE,
139+
NativeGestureRole.ScrollView
140+
);
141+
const handler = createHandler(view);
142+
143+
handler.pointerDown(touchEvent(100, 100, EventTypes.DOWN));
144+
handler.pointerMove(touchEvent(100, 130, EventTypes.MOVE));
145+
146+
view.dispatchEvent('scroll');
147+
expect(handler.state).toBe(State.ACTIVE);
148+
});
149+
150+
test('scroll under a resting pointer does not activate (momentum stop)', () => {
151+
const view = new FakeHTMLElement();
152+
view.setAttribute(
153+
NATIVE_GESTURE_ROLE_ATTRIBUTE,
154+
NativeGestureRole.ScrollView
155+
);
156+
const handler = createHandler(view);
157+
158+
handler.pointerDown(touchEvent(100, 100, EventTypes.DOWN));
159+
view.dispatchEvent('scroll');
160+
expect(handler.state).toBe(State.BEGAN);
161+
162+
// Once the pointer really moves, the earlier scroll counts.
163+
handler.pointerMove(touchEvent(100, 110, EventTypes.MOVE));
164+
expect(handler.state).toBe(State.ACTIVE);
165+
});
166+
167+
test('scroll with no tracked pointers is ignored', () => {
168+
const view = new FakeHTMLElement();
169+
view.setAttribute(
170+
NATIVE_GESTURE_ROLE_ATTRIBUTE,
171+
NativeGestureRole.ScrollView
172+
);
173+
const handler = createHandler(view);
174+
175+
view.dispatchEvent('scroll');
176+
expect(handler.state).toBe(State.UNDETERMINED);
177+
});
178+
179+
test('non-scrollable view keeps distance-based activation', () => {
180+
const view = new FakeHTMLElement();
181+
const handler = createHandler(view);
182+
183+
handler.pointerDown(touchEvent(100, 100, EventTypes.DOWN));
184+
expect(handler.state).toBe(State.BEGAN);
185+
186+
handler.pointerMove(touchEvent(100, 130, EventTypes.MOVE));
187+
expect(handler.state).toBe(State.ACTIVE);
188+
});
189+
190+
test('role-less view keeps distance-based activation (scroll-driven mode is v3-only)', () => {
191+
const view = new FakeHTMLElement();
192+
view.style.overflowY = 'scroll';
193+
const handler = createHandler(view);
194+
195+
handler.pointerDown(touchEvent(100, 100, EventTypes.DOWN));
196+
handler.pointerMove(touchEvent(100, 130, EventTypes.MOVE));
197+
expect(handler.state).toBe(State.ACTIVE);
198+
});
199+
200+
test('scroll on a role-less view does not add an activation path', () => {
201+
const view = new FakeHTMLElement();
202+
view.style.overflowY = 'scroll';
203+
const handler = createHandler(view);
204+
205+
// Below DEFAULT_TOUCH_SLOP, a scroll of the view itself must not activate
206+
// a handler that is not scroll-driven (e.g. legacy ScrollView, TextInput).
207+
handler.pointerDown(touchEvent(100, 100, EventTypes.DOWN));
208+
handler.pointerMove(touchEvent(100, 110, EventTypes.MOVE));
209+
view.dispatchEvent('scroll');
210+
expect(handler.state).toBe(State.BEGAN);
211+
212+
handler.pointerMove(touchEvent(100, 130, EventTypes.MOVE));
213+
expect(handler.state).toBe(State.ACTIVE);
214+
});
215+
});

packages/react-native-gesture-handler/src/web/handlers/GestureHandler.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,7 @@ export default abstract class GestureHandler implements IGestureHandler {
143143
manager.setOnPointerMoveOver(this.onPointerMoveOver.bind(this));
144144
manager.setOnPointerMoveOut(this.onPointerMoveOut.bind(this));
145145
manager.setOnWheel(this.onWheel.bind(this));
146+
manager.setOnScroll(this.onScroll.bind(this));
146147

147148
// The initial config is applied before the handler is attached. Honor an
148149
// initially disabled handler here because the delegate's enabled-change
@@ -398,6 +399,9 @@ export default abstract class GestureHandler implements IGestureHandler {
398399
protected onWheel(_event: AdaptedEvent): void {
399400
// Used only by pan gesture handler
400401
}
402+
protected onScroll(_event: AdaptedEvent): void {
403+
// Used only by native view gesture handler
404+
}
401405
protected tryToSendMoveEvent(out: boolean, event: AdaptedEvent): void {
402406
if ((out && this.shouldCancelWhenOutside) || !this.enabled) {
403407
return;

packages/react-native-gesture-handler/src/web/handlers/NativeViewGestureHandler.ts

Lines changed: 48 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,10 @@ export default class NativeViewGestureHandler extends GestureHandler {
4141
private startY = 0;
4242
private minDistSq = DEFAULT_TOUCH_SLOP * DEFAULT_TOUCH_SLOP;
4343

44+
private readonly scrollActivationThresholdSq = 2 * 2;
45+
private isScrollDriven = false;
46+
private scrollDetected = false;
47+
4448
private lastActiveHandlerData: HandlerData<NativeHandlerData> | null = null;
4549

4650
private hasLongPressHandler = false;
@@ -65,6 +69,7 @@ export default class NativeViewGestureHandler extends GestureHandler {
6569
super.init(ref, propsRef, actionType, hostDetector);
6670

6771
this.shouldCancelWhenOutside = true;
72+
this.isScrollDriven = false;
6873

6974
const view = this.delegate.view;
7075

@@ -86,6 +91,8 @@ export default class NativeViewGestureHandler extends GestureHandler {
8691
this.role = NativeGestureRole.Switch;
8792
}
8893
}
94+
95+
this.isScrollDriven = this.role === NativeGestureRole.ScrollView;
8996
}
9097

9198
public override updateGestureConfig(config: Config): void {
@@ -144,6 +151,8 @@ export default class NativeViewGestureHandler extends GestureHandler {
144151
return;
145152
}
146153

154+
this.scrollDetected = false;
155+
147156
this.begin();
148157

149158
dispatchGestureLifecycleEvent(
@@ -166,19 +175,51 @@ export default class NativeViewGestureHandler extends GestureHandler {
166175
protected override onPointerMove(event: AdaptedEvent): void {
167176
this.tracker.track(event);
168177

169-
const lastCoords = this.tracker.getAbsoluteCoordsAverage();
170-
const dx = this.startX - lastCoords.x;
171-
const dy = this.startY - lastCoords.y;
172-
const distSq = dx * dx + dy * dy;
173-
174178
if (
175179
this.role === NativeGestureRole.Switch ||
176180
this.role === NativeGestureRole.Button
177181
) {
178182
return;
179183
}
180184

181-
if (distSq >= this.minDistSq && this.state === State.BEGAN) {
185+
if (this.isScrollDriven) {
186+
this.tryScrollDrivenActivation();
187+
return;
188+
}
189+
190+
if (
191+
this.pointerTravelSq() >= this.minDistSq &&
192+
this.state === State.BEGAN
193+
) {
194+
this.activate();
195+
}
196+
}
197+
198+
private pointerTravelSq(): number {
199+
const lastCoords = this.tracker.getAbsoluteCoordsAverage();
200+
const dx = this.startX - lastCoords.x;
201+
const dy = this.startY - lastCoords.y;
202+
return dx * dx + dy * dy;
203+
}
204+
205+
protected override onScroll(_event: AdaptedEvent): void {
206+
if (!this.isScrollDriven || this.tracker.trackedPointersCount === 0) {
207+
return;
208+
}
209+
210+
this.scrollDetected = true;
211+
this.tryScrollDrivenActivation();
212+
}
213+
214+
private tryScrollDrivenActivation(): void {
215+
if (!this.scrollDetected || this.state !== State.BEGAN) {
216+
return;
217+
}
218+
219+
// Require some pointer travel on top of the scroll event — momentum
220+
// scrolling keeps emitting `scroll` events after a touch that was only
221+
// meant to stop it, and that touch must not activate the handler.
222+
if (this.pointerTravelSq() >= this.scrollActivationThresholdSq) {
182223
this.activate();
183224
}
184225
}
@@ -427,6 +468,7 @@ export default class NativeViewGestureHandler extends GestureHandler {
427468
this.lastActiveHandlerData = null;
428469
this.lastEventWasInside = false;
429470
this.longPressDetected = false;
471+
this.scrollDetected = false;
430472
}
431473

432474
public override onDestroy(): void {

packages/react-native-gesture-handler/src/web/tools/EventManager.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ export default abstract class EventManager<T> {
3838
protected onPointerMoveOver(_event: AdaptedEvent): void {}
3939
protected onPointerMoveOut(_event: AdaptedEvent): void {}
4040
protected onWheel(_event: AdaptedEvent): void {}
41+
protected onScroll(_event: AdaptedEvent): void {}
4142

4243
public setOnPointerDown(callback: PointerEventCallback): void {
4344
this.onPointerDown = callback;
@@ -75,6 +76,9 @@ export default abstract class EventManager<T> {
7576
public setOnWheel(callback: PointerEventCallback): void {
7677
this.onWheel = callback;
7778
}
79+
public setOnScroll(callback: PointerEventCallback): void {
80+
this.onScroll = callback;
81+
}
7882

7983
protected markAsInBounds(pointerId: number): void {
8084
if (this.pointersInBounds.indexOf(pointerId) >= 0) {

packages/react-native-gesture-handler/src/web/tools/GestureHandlerWebDelegate.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import type {
1919
} from './GestureHandlerDelegate';
2020
import KeyboardEventManager from './KeyboardEventManager';
2121
import PointerEventManager from './PointerEventManager';
22+
import ScrollEventManager from './ScrollEventManager';
2223
import WheelEventManager from './WheelEventManager';
2324

2425
interface DefaultViewStyles {
@@ -69,6 +70,7 @@ export class GestureHandlerWebDelegate
6970
);
7071
this.eventManagers.push(new KeyboardEventManager(this.view));
7172
this.eventManagers.push(new WheelEventManager(this.view));
73+
this.eventManagers.push(new ScrollEventManager(this.view));
7274

7375
this.eventManagers.forEach((manager) =>
7476
this.gestureHandler.attachEventManager(manager)

0 commit comments

Comments
 (0)