SIGN IN SIGN UP

[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