Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,10 +73,21 @@ yarn add react-native-sortables
This library is built with:

- [react-native-reanimated](https://docs.swmansion.com/react-native-reanimated/) (version 3.x, 4.x)
- [react-native-gesture-handler](https://docs.swmansion.com/react-native-gesture-handler/) (version 2.x)
- [react-native-gesture-handler](https://docs.swmansion.com/react-native-gesture-handler/) (version 2.x, 3.x)

Make sure to follow their installation instructions for your project.

> [!NOTE]
> On iOS + New Architecture, gesture-handler **2.x** has a known limitation: a
> sortable stops recognizing drags after its screen is detached and re-attached
> (for example bottom tabs with `detachInactiveScreens={false}`), which can make
> a dragged item get "stuck" - see
> [#349](https://github.com/MatiPl01/react-native-sortables/issues/349). This is
> not fixable on the v2 API and is resolved by upgrading to
> **gesture-handler 3** (which the library uses automatically when installed; v3
> requires the New Architecture). On the Old Architecture (Paper) the issue does
> not occur, so gesture-handler 2.x remains the right choice there.

## Quick Start

```tsx
Expand Down
12 changes: 9 additions & 3 deletions example/app/jest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,15 @@ const config: JestConfigWithTsJest = {
},
moduleDirectories: ['node_modules', '../../node_modules', '<rootDir>'],
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'],
moduleNameMapper: pathsToModuleNameMapper(compilerOptions.paths ?? {}, {
prefix: '<rootDir>/'
}),
moduleNameMapper: {
...pathsToModuleNameMapper(compilerOptions.paths ?? {}, {
prefix: '<rootDir>/'
}),
// react-native-sortables is built against gesture-handler v3, but the app's
// jest mock targets v2; pin every import to the single mocked instance.
'^react-native-gesture-handler$':
'<rootDir>/../../node_modules/react-native-gesture-handler'
},
preset: '@react-native/jest-preset',
resolver: 'react-native-worklets/jest/resolver.js',
setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
Expand Down
2 changes: 1 addition & 1 deletion packages/react-native-sortables/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
"react": "19.2.3",
"react-native": "0.85.3",
"react-native-builder-bob": "0.40.13",
"react-native-gesture-handler": "2.31.1",
"react-native-gesture-handler": "3.0.2",
"react-native-reanimated": "4.5.0",
"react-native-worklets": "0.10.0",
"semantic-release": "^24.0.0",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { StyleProp, ViewStyle } from 'react-native';
import { View } from 'react-native';
import { runOnUI, useAnimatedRef } from 'react-native-reanimated';

import { useEnabledGesture } from '../../integrations/gesture-handler';
import {
useCustomHandleContext,
useIsInPortalOutlet,
Expand Down Expand Up @@ -61,6 +62,7 @@ function CustomHandleComponent({
const { registerHandle, updateActiveHandleMeasurements } =
customHandleContext;
const dragEnabled = mode === 'draggable';
const handleGesture = useEnabledGesture(gesture, dragEnabled);

useEffect(() => {
return registerHandle(itemKey, handleRef, mode === 'fixed-order');
Expand All @@ -74,7 +76,7 @@ function CustomHandleComponent({
}, [itemKey, isActive, updateActiveHandleMeasurements]);

return (
<SortableGestureDetector gesture={gesture.enabled(dragEnabled)}>
<SortableGestureDetector gesture={handleGesture}>
<View
collapsable={false}
ref={handleRef}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { useCallback, useEffect, useRef } from 'react';
import type { GestureType } from 'react-native-gesture-handler';
import { runOnJS, useAnimatedReaction } from 'react-native-reanimated';

import { useStableCallback } from '../../../hooks';
import type { SortableGesture } from '../../../integrations/gesture-handler';
import { useMutableValue } from '../../../integrations/reanimated';
import {
CommonValuesContext,
Expand All @@ -22,7 +22,7 @@ type ActiveItemPortalProps = Pick<
'activationAnimationProgress' | 'baseStyle' | 'isActive' | 'itemKey'
> & {
commonValuesContext: CommonValuesContextType;
gesture: GestureType;
gesture: SortableGesture;
onTeleport: (isTeleported: boolean) => void;
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,12 @@ export type SortableGestureDetectorProps = PropsWithChildren<{
gesture: ComposedGesture | GestureType;
}>;

// Cast `GestureDetector` to its legacy props so it accepts the cross-major
// `SortableGesture` union (its exported types otherwise reject it).
const Detector = GestureDetector as (
props: SortableGestureDetectorProps
) => ReturnType<typeof GestureDetector>;

/**
* Wrapper over gesture handler's `GestureDetector` used by all draggable item
* parts. On native it is a passthrough; the web counterpart (`.web`) layers on
Expand All @@ -18,5 +24,5 @@ export default function SortableGestureDetector({
children,
gesture
}: SortableGestureDetectorProps) {
return <GestureDetector gesture={gesture}>{children}</GestureDetector>;
return <Detector gesture={gesture}>{children}</Detector>;
}
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,15 @@ export type SortableGestureDetectorProps = PropsWithChildren<{
gesture: ComposedGesture | GestureType;
}>;

// Cast `GestureDetector` to its legacy props (plus web layout props) so it
// accepts the cross-major `SortableGesture` union.
const Detector = GestureDetector as (
props: SortableGestureDetectorProps & {
touchAction?: 'pan-x' | 'pan-y';
userSelect?: 'none';
}
) => ReturnType<typeof GestureDetector>;

/**
* Web `GestureDetector`: relaxes `touch-action` to the scroll axis (so items
* don't block scrolling the ScrollView) and blocks native scroll while dragging.
Expand Down Expand Up @@ -67,11 +76,8 @@ export default function SortableGestureDetector({
useEffect(() => () => setBlocking(false), [setBlocking]);

return (
<GestureDetector
gesture={gesture}
touchAction={touchAction}
userSelect='none'>
<Detector gesture={gesture} touchAction={touchAction} userSelect='none'>
{children}
</GestureDetector>
</Detector>
);
}
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
import { type PropsWithChildren, useMemo } from 'react';
import { type PropsWithChildren } from 'react';
import type { ViewProps } from 'react-native';
import { View } from 'react-native';
import type { GestureType } from 'react-native-gesture-handler';
import { Gesture } from 'react-native-gesture-handler';

import { useTouchableGesture } from '../../integrations/gesture-handler';
import { useItemContext } from '../../providers';
import SortableGestureDetector from './SortableGestureDetector';

Expand Down Expand Up @@ -32,65 +31,16 @@ export default function SortableTouchable({
}: SortableTouchableProps) {
const { gesture: externalGesture } = useItemContext();

const gesture = useMemo(() => {
const decorate = <T extends GestureType>(decoratedGesture: T): T => {
decoratedGesture
.simultaneousWithExternalGesture(externalGesture)
.runOnJS(true);
if ('maxDistance' in decoratedGesture) {
(
decoratedGesture as { maxDistance: (distance: number) => GestureType }
).maxDistance(failDistance);
}
return decoratedGesture;
};

const gestures = [];

if (onTap) {
gestures.push(decorate(Gesture.Tap()).onStart(onTap));
}

if (onDoubleTap) {
gestures.push(
decorate(Gesture.Tap()).numberOfTaps(2).onStart(onDoubleTap)
);
}

if (onLongPress) {
gestures.push(decorate(Gesture.LongPress()).onStart(onLongPress));
}

if (onTouchesDown || onTouchesUp) {
// Reuse already added gesture if possible or create a manual gesture
// if there is no other gesture yet
if (!gestures.length) {
gestures.push(decorate(Gesture.Manual()));
}

const lastGesture = gestures[gestures.length - 1]!;

if (onTouchesDown) {
lastGesture.onTouchesDown(onTouchesDown);
}
if (onTouchesUp) {
lastGesture.onTouchesUp(onTouchesUp);
}
}

return gestureMode === 'exclusive'
? Gesture.Exclusive(...gestures)
: Gesture.Simultaneous(...gestures);
}, [
const gesture = useTouchableGesture({
externalGesture,
failDistance,
onTap,
gestureMode,
onDoubleTap,
onLongPress,
onTap,
onTouchesDown,
onTouchesUp,
externalGesture,
gestureMode
]);
onTouchesUp
});

return (
<SortableGestureDetector gesture={gesture}>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import type {
ManualGestureCallbacks,
SortableGesture,
TouchableGestureConfig
} from '../types';

/**
* Unified gesture API used across the library. Each gesture-handler major
* version provides its own implementation (v2 imperative builder, v3 hook API),
* selected once at module load based on which version is installed.
*/
export type GestureHandlerAdapter = {
useDragGesture: (
callbacks: ManualGestureCallbacks,
deps: ReadonlyArray<unknown>
) => SortableGesture;
useEnabledGesture: (
gesture: SortableGesture,
enabled: boolean
) => SortableGesture;
useTouchableGesture: (config: TouchableGestureConfig) => SortableGesture;
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import { useMemo } from 'react';
import type { GestureType } from 'react-native-gesture-handler';
import { Gesture } from 'react-native-gesture-handler';

import { asSortableGesture } from '../types';
import type { GestureHandlerAdapter } from './types';

/**
* gesture-handler v2 imperative builder, used when gesture-handler < 3 is
* installed (e.g. the Old Architecture / Paper). On iOS + New Architecture this
* path still has the upstream issue #349 limitation, which only the v3 hook API
* fixes (see `./v3`).
*/

const useDragGesture: GestureHandlerAdapter['useDragGesture'] = (
callbacks,
deps
) =>
asSortableGesture(
useMemo(
() =>
Gesture.Manual()
.onTouchesDown(callbacks.onTouchesDown)
.onTouchesMove(callbacks.onTouchesMove)
.onTouchesCancelled(callbacks.onTouchesCancelled)
.onTouchesUp(callbacks.onTouchesUp),
// The dependency list is owned by the caller (useItemPanGesture).
// eslint-disable-next-line react-hooks/exhaustive-deps
deps
)
);

const useEnabledGesture: GestureHandlerAdapter['useEnabledGesture'] = (
gesture,
enabled
) => asSortableGesture((gesture as GestureType).enabled(enabled));

const useTouchableGesture: GestureHandlerAdapter['useTouchableGesture'] = ({
externalGesture,
failDistance,
gestureMode,
onDoubleTap,
onLongPress,
onTap,
onTouchesDown,
onTouchesUp
}) =>
useMemo(() => {
const decorate = <T extends GestureType>(gesture: T): T => {
gesture
.simultaneousWithExternalGesture(externalGesture as GestureType)
.runOnJS(true);
if ('maxDistance' in gesture) {
(
gesture as { maxDistance: (distance: number) => GestureType }
).maxDistance(failDistance);
}
return gesture;
};

const gestures: Array<GestureType> = [];

if (onTap) {
gestures.push(decorate(Gesture.Tap()).onStart(onTap));
}
if (onDoubleTap) {
gestures.push(
decorate(Gesture.Tap()).numberOfTaps(2).onStart(onDoubleTap)
);
}
if (onLongPress) {
gestures.push(decorate(Gesture.LongPress()).onStart(onLongPress));
}

if (onTouchesDown || onTouchesUp) {
const target = gestures.at(-1) ?? decorate(Gesture.Manual());
if (!gestures.length) {
gestures.push(target);
}
if (onTouchesDown) {
target.onTouchesDown(onTouchesDown);
}
if (onTouchesUp) {
target.onTouchesUp(onTouchesUp);
}
}

return asSortableGesture(
gestureMode === 'exclusive'
? Gesture.Exclusive(...gestures)
: Gesture.Simultaneous(...gestures)
);
}, [
failDistance,
onTap,
onDoubleTap,
onLongPress,
onTouchesDown,
onTouchesUp,
externalGesture,
gestureMode
]);

export const adapter: GestureHandlerAdapter = {
useDragGesture,
useEnabledGesture,
useTouchableGesture
};
Loading
Loading