Skip to content

[Web] Activate ScrollView's native gesture on real scroll instead of pointer distance - #4420

Open
m-bert wants to merge 9 commits into
mainfrom
@mbert/pan-scroll-main
Open

[Web] Activate ScrollView's native gesture on real scroll instead of pointer distance#4420
m-bert wants to merge 9 commits into
mainfrom
@mbert/pan-scroll-main

Conversation

@m-bert

@m-bert m-bert commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator

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
Tested on the following code:
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,
  },
});

Copilot AI lite review requested due to automatic review settings August 14, 2026 07:35
@coderabbitai

coderabbitai Bot commented Aug 14, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 480eede6-0666-4d22-ba20-1b7a85ac09d7

📥 Commits

Reviewing files that changed from the base of the PR and between 648473f and 77fa382.

📒 Files selected for processing (1)
  • packages/react-native-gesture-handler/src/web/handlers/NativeViewGestureHandler.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/react-native-gesture-handler/src/web/handlers/NativeViewGestureHandler.ts

Included review availability: Your plan includes up to 8 reviews per rolling hour; 4 remain after this review.


📝 Walkthrough

Summary by CodeRabbit

  • New Features

    • Improved web native gesture handling for scrollable views.
    • Scroll gestures now activate based on actual scrolling and pointer movement.
    • Preserved distance-based activation for non-scrollable views.
  • Bug Fixes

    • Prevented pointer movement alone from incorrectly activating scroll-driven gestures.
    • Improved handling of momentum stops and untracked scrolling.
  • Tests

    • Added coverage for scroll activation, pointer movement, momentum behavior, and non-scrollable views.

Walkthrough

Web native view gestures now receive scroll events. Scrollable handlers activate only after scrolling and pointer travel. Non-scrollable and role-less handlers retain distance-based activation. Tests cover the new behavior with a fake DOM.

Changes

Web scroll activation

Layer / File(s) Summary
Scroll event pipeline
packages/react-native-gesture-handler/src/web/tools/EventManager.ts, packages/react-native-gesture-handler/src/web/tools/ScrollEventManager.ts, packages/react-native-gesture-handler/src/web/tools/GestureHandlerWebDelegate.ts, packages/react-native-gesture-handler/src/web/handlers/GestureHandler.ts
The web delegate registers passive scroll listeners. ScrollEventManager maps scroll offsets to adapted move events and forwards them through the gesture handler callback.
Native view scroll activation
packages/react-native-gesture-handler/src/web/handlers/NativeViewGestureHandler.ts
Native view handlers detect scroll roles, track scroll state, require pointer travel after scrolling, preserve distance activation for other views, and reset scroll state.
Activation behavior tests
packages/react-native-gesture-handler/src/__tests__/webNativeViewGestureHandler.test.ts
A fake DOM and test utilities validate scroll activation, momentum-stop behavior, untracked scroll events, and distance activation for non-scrollable and role-less views.

Sequence Diagram(s)

sequenceDiagram
  participant Pointer
  participant GestureHandlerWebDelegate
  participant ScrollEventManager
  participant NativeViewGestureHandler
  Pointer->>NativeViewGestureHandler: begin touch
  GestureHandlerWebDelegate->>ScrollEventManager: register scroll listener
  ScrollEventManager->>NativeViewGestureHandler: forward adapted scroll event
  Pointer->>NativeViewGestureHandler: move pointer
  NativeViewGestureHandler->>NativeViewGestureHandler: activate after scroll and travel threshold
Loading

Merge Risk: ⚪ Minimal · up to 77fa3

This PR changes web ScrollView gesture activation to follow actual scrolling, preventing scroll gestures from incorrectly claiming unrelated drags. No actionable merge-blocking risk remains after normal checks and review.

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes the main change: activating the web NativeViewGestureHandler on real scrolling instead of pointer distance.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR updates the web implementation of NativeViewGestureHandler (v3 / hook-based API path) so ScrollView-role handlers activate based on actual DOM scroll events rather than pointer-distance slop, preventing scroll views from incorrectly claiming cross-axis drags and blocking delayed-activation pans inside scrollable containers.

Changes:

  • Add ScrollEventManager and plumb a new onScroll callback through EventManagerGestureHandler.
  • Update NativeViewGestureHandler to use scroll-driven activation for the ScrollView role with a small pointer-travel momentum guard.
  • Add Jest unit tests validating scroll-driven activation behavior and ensuring legacy/distance-based activation remains unchanged.

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
packages/react-native-gesture-handler/src/web/tools/ScrollEventManager.ts New event manager that listens to DOM scroll and forwards it into the gesture pipeline.
packages/react-native-gesture-handler/src/web/tools/GestureHandlerWebDelegate.ts Registers ScrollEventManager alongside existing web event managers.
packages/react-native-gesture-handler/src/web/tools/EventManager.ts Adds onScroll callback plumbing to the shared event-manager abstraction.
packages/react-native-gesture-handler/src/web/handlers/GestureHandler.ts Wires onScroll into handler attachment and provides a default no-op implementation.
packages/react-native-gesture-handler/src/web/handlers/NativeViewGestureHandler.ts Switches ScrollView-role activation to be scroll-driven (v3-only role path).
packages/react-native-gesture-handler/src/tests/webNativeViewGestureHandler.test.ts New unit tests for scroll-driven activation, momentum guard behavior, and legacy path.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

@m-bert
m-bert requested a review from j-piasecki August 14, 2026 10:09
@m-bert
m-bert requested a review from j-piasecki August 19, 2026 10:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants