[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> M
Michał Bert committed
f31b8a6f8e7c8c786f80cef0d7db60926838ca81
Parent: 7c71e64
Committed by GitHub <noreply@github.com>
on 8/20/2026, 5:53:06 AM