Skip to content

Commit e51ca61

Browse files
authored
feat: Base zone with event callbacks (#413)
## Description This PR adds base zone implementation that detects whether the item entered/left or was dropped over the zone. This implementation will help build other zones, like delete zone or drop zone and dragging between different sections.
1 parent ab6435b commit e51ca61

28 files changed

Lines changed: 451 additions & 329 deletions
Lines changed: 34 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,19 @@
1-
import { useCallback } from 'react';
1+
import { useCallback, useRef, useState } from 'react';
2+
import { StyleSheet } from 'react-native';
23
import type { SortableGridRenderItem } from 'react-native-sortables';
34
import Sortable from 'react-native-sortables';
45

56
import { GridCard, ScrollScreen, Section } from '@/components';
67
import { spacing } from '@/theme';
78
import { getItems } from '@/utils';
89

9-
const DATA = getItems(8);
10+
const DATA = getItems(12);
1011
const COLUMNS = 4;
1112

1213
export default function MultiZoneExample() {
14+
const [data, setData] = useState(DATA);
15+
const activeItemKeyRef = useRef<null | string>(null);
16+
1317
const renderItem = useCallback<SortableGridRenderItem<string>>(
1418
({ item }) => <GridCard>{item}</GridCard>,
1519
[]
@@ -22,24 +26,43 @@ export default function MultiZoneExample() {
2226
<Sortable.Grid
2327
columnGap={spacing.xs}
2428
columns={COLUMNS}
25-
data={DATA}
29+
data={data}
30+
dimensionsAnimationType='worklet'
2631
renderItem={renderItem}
27-
rowGap={spacing.xs}
32+
rowGap={50}
2833
debug
34+
onDragEnd={({ data: newData }) => setData(newData)}
35+
onActiveItemDropped={() => {
36+
activeItemKeyRef.current = null;
37+
}}
38+
onDragStart={({ key }) => {
39+
activeItemKeyRef.current = key;
40+
}}
2941
/>
3042
</Section>
3143

3244
<Section title='Section 2'>
33-
<Sortable.Grid
34-
columnGap={spacing.xs}
35-
columns={COLUMNS}
36-
data={DATA}
37-
renderItem={renderItem}
38-
rowGap={spacing.xs}
39-
debug
45+
<Sortable.BaseZone
46+
style={styles.zone}
47+
onItemDrop={() => {
48+
console.log('Item dropped');
49+
setData(data.filter(item => item !== activeItemKeyRef.current));
50+
}}
51+
onItemEnter={() => {
52+
console.log('Item entered');
53+
}}
54+
onItemLeave={() => {
55+
console.log('Item left');
56+
}}
4057
/>
4158
</Section>
4259
</Sortable.MultiZoneProvider>
4360
</ScrollScreen>
4461
);
4562
}
63+
64+
const styles = StyleSheet.create({
65+
zone: {
66+
height: 100
67+
}
68+
});
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
export * from './shared';
22
export { default as SortableFlex } from './SortableFlex';
33
export { default as SortableGrid } from './SortableGrid';
4+
export * from './zones';

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

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@ import { runOnUI, useAnimatedRef } from 'react-native-reanimated';
55

66
import {
77
useCustomHandleContext,
8-
useItemContext,
9-
usePortalOutletContext
8+
useIsInPortalOutlet,
9+
useItemContext
1010
} from '../../providers';
1111
import { error } from '../../utils';
1212

@@ -23,9 +23,8 @@ export type CustomHandleProps = PropsWithChildren<{
2323

2424
export default function CustomHandle(props: CustomHandleProps) {
2525
// The item is teleported when it is rendered within the PortalOutlet
26-
// component. Because PortalOutlet creates a context, we can use it to
27-
// check if the item is teleported
28-
const isTeleported = !!usePortalOutletContext();
26+
// component
27+
const isTeleported = useIsInPortalOutlet();
2928

3029
// In case of teleported handle items, we want to render just the
3130
// handle component without any functionality

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

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,43 +7,56 @@ import {
77

88
import { useMutableValue } from '../../../integrations/reanimated';
99
import { usePortalContext } from '../../../providers';
10+
import type { CommonValuesContextType } from '../../../types';
1011

1112
type ActiveItemPortalProps = PropsWithChildren<{
12-
teleportedItemId: string;
13+
itemKey: string;
1314
activationAnimationProgress: SharedValue<number>;
15+
commonValuesContext: CommonValuesContextType;
1416
renderTeleportedItemCell: () => ReactNode;
1517
}>;
1618

1719
export default function ActiveItemPortal({
1820
activationAnimationProgress,
1921
children,
20-
renderTeleportedItemCell,
21-
teleportedItemId
22+
commonValuesContext,
23+
itemKey,
24+
renderTeleportedItemCell
2225
}: ActiveItemPortalProps) {
23-
const { teleport } = usePortalContext()!;
26+
const { containerId } = commonValuesContext;
27+
const { measurePortalOutlet, teleport } = usePortalContext() ?? {};
28+
2429
const teleportEnabled = useMutableValue(false);
2530

31+
const teleportedItemId = `${containerId}-${itemKey}`;
32+
2633
useEffect(() => {
2734
if (teleportEnabled.value) {
28-
teleport(teleportedItemId, renderTeleportedItemCell());
35+
teleport?.(teleportedItemId, renderTeleportedItemCell());
2936
}
3037
// This is fine, we want to update the teleported item cell only when
3138
// the children change
3239
// eslint-disable-next-line react-hooks/exhaustive-deps
3340
}, [children]);
3441

3542
const enableTeleport = () => {
36-
teleport(teleportedItemId, renderTeleportedItemCell());
43+
teleport?.(teleportedItemId, renderTeleportedItemCell());
3744
};
3845

3946
const disableTeleport = () => {
40-
runOnJS(teleport)(teleportedItemId, null);
47+
if (teleport) {
48+
runOnJS(teleport)(teleportedItemId, null);
49+
}
4150
};
4251

4352
useAnimatedReaction(
4453
() => activationAnimationProgress.value,
4554
(progress, prevProgress) => {
4655
if (prevProgress && progress > prevProgress && !teleportEnabled.value) {
56+
// We have to ensure that the portal outlet ref is measured before the
57+
// teleported item is rendered within it because portal outlet position
58+
// must be known to calculate the teleported item position
59+
measurePortalOutlet?.();
4760
teleportEnabled.value = true;
4861
runOnJS(enableTeleport)();
4962
} else if (progress === 0 && teleportEnabled.value) {

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

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
CommonValuesContext,
1717
ItemContextProvider,
1818
useCommonValuesContext,
19+
useDragContext,
1920
useItemPanGesture,
2021
useItemStyles,
2122
useMeasurementsContext,
@@ -45,26 +46,31 @@ function DraggableView({
4546
const commonValuesContext = useCommonValuesContext();
4647
const { handleItemMeasurement, removeItemMeasurements } =
4748
useMeasurementsContext();
49+
const { handleDragEnd } = useDragContext();
4850
const { activeItemKey, containerId, customHandle } = commonValuesContext;
4951

50-
const teleportedItemId = `${containerId}-${key}`;
51-
5252
const [isTeleported, setIsTeleported] = useState(false);
5353
const activationAnimationProgress = useMutableValue(0);
5454
const isActive = useDerivedValue(() => activeItemKey.value === key);
5555
const itemStyles = useItemStyles(key, isActive, activationAnimationProgress);
5656
const gesture = useItemPanGesture(key, activationAnimationProgress);
5757

5858
useEffect(
59-
() => runOnUI(removeItemMeasurements)(key),
60-
[key, removeItemMeasurements]
59+
() =>
60+
runOnUI(() => {
61+
handleDragEnd(key, activationAnimationProgress);
62+
removeItemMeasurements(key);
63+
}),
64+
[activationAnimationProgress, handleDragEnd, key, removeItemMeasurements]
6165
);
6266

6367
useEffect(() => {
6468
if (!portalContext) {
6569
setIsTeleported(false);
70+
return;
6671
}
6772

73+
const teleportedItemId = `${containerId}-${key}`;
6874
const unsubscribe = portalContext?.subscribe?.(
6975
teleportedItemId,
7076
setIsTeleported
@@ -74,7 +80,7 @@ function DraggableView({
7480
portalContext?.teleport?.(teleportedItemId, null);
7581
unsubscribe?.();
7682
};
77-
}, [portalContext, teleportedItemId]);
83+
}, [portalContext, containerId, key]);
7884

7985
const onMeasure = (width: number, height: number) =>
8086
handleItemMeasurement(key, { height, width });
@@ -145,8 +151,9 @@ function DraggableView({
145151
{renderItemCell(isTeleported)}
146152
<ActiveItemPortal
147153
activationAnimationProgress={activationAnimationProgress}
148-
renderTeleportedItemCell={renderTeleportedItemCell}
149-
teleportedItemId={teleportedItemId}>
154+
commonValuesContext={commonValuesContext}
155+
itemKey={key}
156+
renderTeleportedItemCell={renderTeleportedItemCell}>
150157
{children}
151158
</ActiveItemPortal>
152159
</Fragment>

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

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,8 @@ import Animated, {
1414
import { EMPTY_OBJECT, IS_WEB } from '../../constants';
1515
import { DebugOutlet } from '../../debug';
1616
import {
17-
MultiZoneOutlet,
1817
useCommonValuesContext,
19-
useMeasurementsContext,
20-
useMultiZoneContext
18+
useMeasurementsContext
2119
} from '../../providers';
2220
import type {
2321
DimensionsAnimation,
@@ -60,7 +58,6 @@ export default function SortableContainer({
6058
usesAbsoluteLayout
6159
} = useCommonValuesContext();
6260
const { handleHelperContainerMeasurement } = useMeasurementsContext();
63-
const multiZoneContext = useMultiZoneContext();
6461

6562
const animateWorklet = dimensionsAnimationType === 'worklet';
6663
const animateLayout = dimensionsAnimationType === 'layout';
@@ -143,7 +140,6 @@ export default function SortableContainer({
143140
ref={innerContainerRef}
144141
style={[style, innerContainerStyle]}>
145142
{children}
146-
{multiZoneContext && <MultiZoneOutlet />}
147143
</Animated.View>
148144
{debug && <DebugOutlet />}
149145
{/* Renders an overlay view helpful for debugging */}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { View, type ViewProps } from 'react-native';
2+
import { useAnimatedRef } from 'react-native-reanimated';
3+
4+
import { useStableCallbackValue } from '../../integrations/reanimated';
5+
import type { ZoneHandlers } from '../../providers';
6+
import { useZoneHandlers } from '../../providers';
7+
8+
export type BaseZoneProps = ViewProps &
9+
ZoneHandlers & {
10+
minActivationDistance?: number;
11+
};
12+
13+
export default function BaseZone({
14+
minActivationDistance = 0,
15+
onItemDrop,
16+
onItemEnter,
17+
onItemLeave,
18+
...rest
19+
}: BaseZoneProps) {
20+
const containerRef = useAnimatedRef<View>();
21+
22+
const stableOnItemEnter = useStableCallbackValue(onItemEnter);
23+
const stableOnItemLeave = useStableCallbackValue(onItemLeave);
24+
const stableOnItemDrop = useStableCallbackValue(onItemDrop);
25+
26+
useZoneHandlers(containerRef, minActivationDistance, {
27+
onItemDrop: stableOnItemDrop,
28+
onItemEnter: stableOnItemEnter,
29+
onItemLeave: stableOnItemLeave
30+
});
31+
32+
return <View ref={containerRef} {...rest} />;
33+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { default as BaseZone } from './BaseZone';

packages/react-native-sortables/src/index.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import {
2+
BaseZone,
23
CustomHandle,
34
SortableFlex,
45
SortableGrid,
@@ -7,7 +8,7 @@ import {
78
} from './components';
89
export { useItemContext } from './providers';
910

10-
import { MultiZoneProvider, PortalProvider } from './providers';
11+
import { MultiZoneProvider, PortalProvider, useZoneContext } from './providers';
1112
export type { CustomHandleProps, SortableLayerProps } from './components';
1213
export * from './constants/layoutAnimations';
1314
export type {
@@ -39,8 +40,13 @@ export type {
3940
} from './types';
4041
export { DragActivationState } from './types';
4142

43+
const zones = {
44+
BaseZone
45+
};
46+
4247
/** Collection of sortable components and utilities for React Native */
4348
const Sortable = {
49+
...zones,
4450
/** Flexible container component that allows reordering of child elements through drag and drop.
4551
* Uses flexbox layout for arranging items, making it ideal for dynamic layouts
4652
* where items need to flow naturally based on available space.
@@ -170,7 +176,6 @@ const Sortable = {
170176

171177
// TODO - add doc string
172178
MultiZoneProvider,
173-
174179
/** Optional provider that renders dragged items above all other components that are
175180
* wrapped within the PortalProvider.
176181
*
@@ -227,4 +232,6 @@ const Sortable = {
227232
Touchable: SortableTouchable
228233
};
229234

235+
export { useZoneContext };
236+
230237
export default Sortable;

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

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,13 @@ import type {
1919
SortableCallbacks
2020
} from '../types';
2121
import {
22-
ActiveItemValuesProvider,
2322
AutoScrollProvider,
2423
CommonValuesProvider,
2524
CustomHandleProvider,
2625
DragProvider,
2726
LayerProvider,
28-
MeasurementsProvider
27+
MeasurementsProvider,
28+
useMultiZoneContext
2929
} from './shared';
3030
import { ContextProviderComposer } from './utils';
3131

@@ -67,6 +67,8 @@ export default function SharedProvider({
6767
scrollableRef,
6868
...rest
6969
}: SharedProviderProps) {
70+
const inMultiZone = !!useMultiZoneContext();
71+
7072
if (__DEV__) {
7173
useWarnOnPropChange('debug', debug);
7274
useWarnOnPropChange('customHandle', customHandle);
@@ -75,11 +77,9 @@ export default function SharedProvider({
7577

7678
const providers = [
7779
// Provider used for proper zIndex management
78-
<LayerProvider />,
80+
!inMultiZone && <LayerProvider />,
7981
// Provider used for layout debugging (can be used only in dev mode)
8082
__DEV__ && debug && <DebugProvider />,
81-
// Provider used for active item values
82-
<ActiveItemValuesProvider />,
8383
// Provider used for shared values between all providers below
8484
<CommonValuesProvider
8585
customHandle={customHandle}

0 commit comments

Comments
 (0)