Skip to content

Commit 74879ed

Browse files
authored
fix: allow scrolling the ScrollView from draggable items on web (#574)
## Description Closes #545 On web, a `Sortable.Grid` / `Sortable.Flex` inside a scrollable container couldn't be scrolled when the gesture started over an item (or a `Sortable.Touchable`). Gesture Handler sets `touch-action: none` on web, so the browser never scrolls from that element. The only workaround was a drag handle + a plain `Pressable`. ## Fix Web-only, isolated in `SortableGestureDetector.web` (native is a plain passthrough; shared code is untouched): - Relax `touch-action` to the scroll axis (`pan-y` / `pan-x`) so a swipe starting over an item scrolls the surrounding ScrollView. - While an item is actively dragged, block native scroll (`touchmove` `preventDefault`) so the relaxed `touch-action` can't steal the drag. Net: swipe to scroll, hold to drag. No change on native. <details> <summary>Reproduction</summary> ```tsx import { Text, View } from 'react-native'; import Animated, { useAnimatedRef } from 'react-native-reanimated'; import Sortable from 'react-native-sortables'; const DATA = Array.from({ length: 30 }, (_, i) => `Item ${i + 1}`); export default function Example() { const scrollableRef = useAnimatedRef<Animated.ScrollView>(); return ( <Animated.ScrollView ref={scrollableRef}> <Sortable.Grid columns={3} data={DATA} rowGap={8} columnGap={8} scrollableRef={scrollableRef} renderItem={({ item }) => ( <View style={{ height: 90, alignItems: 'center', justifyContent: 'center' }}> <Text>{item}</Text> </View> )} /> </Animated.ScrollView> ); } ``` </details>
1 parent 6f68729 commit 74879ed

8 files changed

Lines changed: 116 additions & 9 deletions

File tree

packages/react-native-sortables/src/components/shared/CustomHandle.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import { type PropsWithChildren, useCallback, useEffect } from 'react';
22
import type { StyleProp, ViewStyle } from 'react-native';
33
import { View } from 'react-native';
4-
import { GestureDetector } from 'react-native-gesture-handler';
54
import { runOnUI, useAnimatedRef } from 'react-native-reanimated';
65

76
import {
@@ -10,6 +9,7 @@ import {
109
useItemContext
1110
} from '../../providers';
1211
import { error } from '../../utils';
12+
import SortableGestureDetector from './SortableGestureDetector';
1313

1414
/** Props for the Sortable Handle component */
1515
export type CustomHandleProps = PropsWithChildren<{
@@ -74,7 +74,7 @@ function CustomHandleComponent({
7474
}, [itemKey, isActive, updateActiveHandleMeasurements]);
7575

7676
return (
77-
<GestureDetector gesture={gesture.enabled(dragEnabled)} userSelect='none'>
77+
<SortableGestureDetector gesture={gesture.enabled(dragEnabled)}>
7878
<View
7979
collapsable={false}
8080
ref={handleRef}
@@ -84,6 +84,6 @@ function CustomHandleComponent({
8484
}}>
8585
{children}
8686
</View>
87-
</GestureDetector>
87+
</SortableGestureDetector>
8888
);
8989
}

packages/react-native-sortables/src/components/shared/DraggableView/DraggableView.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import { Fragment, memo, useCallback, useEffect, useState } from 'react';
22
import type { LayoutChangeEvent } from 'react-native';
3-
import { GestureDetector } from 'react-native-gesture-handler';
43
import {
54
LayoutAnimationConfig,
65
runOnUI,
@@ -22,6 +21,7 @@ import {
2221
useMeasurementsContext,
2322
usePortalContext
2423
} from '../../../providers';
24+
import SortableGestureDetector from '../SortableGestureDetector';
2525
import ActiveItemPortal from './ActiveItemPortal';
2626
import ItemCell from './ItemCell';
2727

@@ -100,9 +100,9 @@ function DraggableView({
100100
{customHandle ? (
101101
innerComponent
102102
) : (
103-
<GestureDetector gesture={gesture} userSelect='none'>
103+
<SortableGestureDetector gesture={gesture}>
104104
{innerComponent}
105-
</GestureDetector>
105+
</SortableGestureDetector>
106106
)}
107107
</ItemContextProvider>
108108
);
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import type { PropsWithChildren } from 'react';
2+
import type {
3+
ComposedGesture,
4+
GestureType
5+
} from 'react-native-gesture-handler';
6+
import { GestureDetector } from 'react-native-gesture-handler';
7+
8+
export type SortableGestureDetectorProps = PropsWithChildren<{
9+
gesture: ComposedGesture | GestureType;
10+
}>;
11+
12+
/**
13+
* Wrapper over gesture handler's `GestureDetector` used by all draggable item
14+
* parts. On native it is a passthrough; the web counterpart (`.web`) layers on
15+
* the browser-specific props needed to coexist with native scrolling.
16+
*/
17+
export default function SortableGestureDetector({
18+
children,
19+
gesture
20+
}: SortableGestureDetectorProps) {
21+
return <GestureDetector gesture={gesture}>{children}</GestureDetector>;
22+
}
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import { type PropsWithChildren, useCallback, useEffect, useRef } from 'react';
2+
import type {
3+
ComposedGesture,
4+
GestureType
5+
} from 'react-native-gesture-handler';
6+
import { GestureDetector } from 'react-native-gesture-handler';
7+
import { runOnJS, useAnimatedReaction } from 'react-native-reanimated';
8+
9+
import { useCommonValuesContext, useItemContext } from '../../providers';
10+
11+
// A single non-passive `touchmove` listener shared by all sortables, attached
12+
// only while at least one item is being dragged (ref counted).
13+
let activeDragCount = 0;
14+
const preventScroll = (event: TouchEvent) => event.preventDefault();
15+
16+
function blockNativeScroll() {
17+
if (activeDragCount++ === 0) {
18+
document.addEventListener('touchmove', preventScroll, { passive: false });
19+
}
20+
}
21+
22+
function releaseNativeScroll() {
23+
if (activeDragCount > 0 && --activeDragCount === 0) {
24+
document.removeEventListener('touchmove', preventScroll);
25+
}
26+
}
27+
28+
export type SortableGestureDetectorProps = PropsWithChildren<{
29+
gesture: ComposedGesture | GestureType;
30+
}>;
31+
32+
/**
33+
* Web `GestureDetector`: relaxes `touch-action` to the scroll axis (so items
34+
* don't block scrolling the ScrollView) and blocks native scroll while dragging.
35+
*/
36+
export default function SortableGestureDetector({
37+
children,
38+
gesture
39+
}: SortableGestureDetectorProps) {
40+
const { autoScrollDirection } = useCommonValuesContext();
41+
const { isActive } = useItemContext();
42+
const isBlockingRef = useRef(false);
43+
const touchAction = autoScrollDirection === 'horizontal' ? 'pan-x' : 'pan-y';
44+
45+
const setBlocking = useCallback((blocking: boolean) => {
46+
if (blocking === isBlockingRef.current) {
47+
return;
48+
}
49+
isBlockingRef.current = blocking;
50+
if (blocking) {
51+
blockNativeScroll();
52+
} else {
53+
releaseNativeScroll();
54+
}
55+
}, []);
56+
57+
useAnimatedReaction(
58+
() => isActive.value,
59+
(active, previous) => {
60+
if (active !== previous) {
61+
runOnJS(setBlocking)(active);
62+
}
63+
}
64+
);
65+
66+
// Release the lock if the item unmounts mid-drag
67+
useEffect(() => () => setBlocking(false), [setBlocking]);
68+
69+
return (
70+
<GestureDetector
71+
gesture={gesture}
72+
touchAction={touchAction}
73+
userSelect='none'>
74+
{children}
75+
</GestureDetector>
76+
);
77+
}

packages/react-native-sortables/src/components/shared/SortableTouchable.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,10 @@ import { type PropsWithChildren, useMemo } from 'react';
22
import type { ViewProps } from 'react-native';
33
import { View } from 'react-native';
44
import type { GestureType } from 'react-native-gesture-handler';
5-
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
5+
import { Gesture } from 'react-native-gesture-handler';
66

77
import { useItemContext } from '../../providers';
8+
import SortableGestureDetector from './SortableGestureDetector';
89

910
type SortableTouchableProps = PropsWithChildren<
1011
ViewProps & {
@@ -92,10 +93,10 @@ export default function SortableTouchable({
9293
]);
9394

9495
return (
95-
<GestureDetector gesture={gesture} userSelect='none'>
96+
<SortableGestureDetector gesture={gesture}>
9697
<View {...viewProps} collapsable={false}>
9798
{children}
9899
</View>
99-
</GestureDetector>
100+
</SortableGestureDetector>
100101
);
101102
}

packages/react-native-sortables/src/providers/shared/CommonValuesProvider.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
import type {
1212
ActiveItemDecorationSettings,
1313
ActiveItemSnapSettings,
14+
AutoScrollSettings,
1415
CommonValuesContextType,
1516
ControlledDimensions,
1617
Dimensions,
@@ -31,6 +32,7 @@ type CommonValuesProviderProps = PropsWithChildren<
3132
ActiveItemDecorationSettings &
3233
ActiveItemSnapSettings &
3334
Omit<ItemDragSettings, 'overDrag' | 'reorderTriggerOrigin'> & {
35+
autoScrollDirection: AutoScrollSettings['autoScrollDirection'];
3436
sortEnabled: Animatable<boolean>;
3537
customHandle: boolean;
3638
controlledContainerDimensions: ControlledDimensions;
@@ -49,6 +51,7 @@ const { CommonValuesContext, CommonValuesProvider, useCommonValuesContext } =
4951
activeItemOpacity: _activeItemOpacity,
5052
activeItemScale: _activeItemScale,
5153
activeItemShadowOpacity: _activeItemShadowOpacity,
54+
autoScrollDirection,
5255
controlledContainerDimensions,
5356
controlledItemDimensions,
5457
customHandle,
@@ -153,6 +156,7 @@ const { CommonValuesContext, CommonValuesProvider, useCommonValuesContext } =
153156
activeItemScale,
154157
activeItemShadowOpacity,
155158
animateLayoutOnReorderOnly,
159+
autoScrollDirection,
156160
containerHeight,
157161
containerId,
158162
containerRef,

packages/react-native-sortables/src/providers/shared/SharedProvider.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ export default function SharedProvider({
9191
__DEV__ && debug && <DebugProvider />,
9292
// Provider used for shared values between all providers below
9393
<CommonValuesProvider
94+
autoScrollDirection={autoScrollDirection}
9495
customHandle={customHandle}
9596
sortEnabled={sortEnabled}
9697
{...rest}

packages/react-native-sortables/src/types/providers/shared.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import type { Dimensions, ItemSizes, Vector } from '../layout/shared';
2222
import type {
2323
ActiveItemDecorationSettings,
2424
ActiveItemSnapSettings,
25+
AutoScrollSettings,
2526
ItemDragSettings,
2627
ReorderTriggerOrigin
2728
} from '../props/shared';
@@ -97,6 +98,7 @@ export type CommonValuesContextType =
9798
animateLayoutOnReorderOnly: SharedValue<boolean>;
9899
customHandle: boolean;
99100
isStackingOrderDesc: boolean;
101+
autoScrollDirection: AutoScrollSettings['autoScrollDirection'];
100102
};
101103

102104
// MEASUREMENTS

0 commit comments

Comments
 (0)