react-native-reanimated-dnd 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +633 -0
- package/lib/components/Draggable.d.ts +5 -0
- package/lib/components/Draggable.js +265 -0
- package/lib/components/Droppable.d.ts +264 -0
- package/lib/components/Droppable.js +284 -0
- package/lib/components/Sortable.d.ts +184 -0
- package/lib/components/Sortable.js +225 -0
- package/lib/components/SortableItem.d.ts +158 -0
- package/lib/components/SortableItem.js +251 -0
- package/lib/components/sortableUtils.d.ts +21 -0
- package/lib/components/sortableUtils.js +50 -0
- package/lib/context/DropContext.d.ts +118 -0
- package/lib/context/DropContext.js +233 -0
- package/lib/hooks/index.d.ts +4 -0
- package/lib/hooks/index.js +5 -0
- package/lib/hooks/useDraggable.d.ts +101 -0
- package/lib/hooks/useDraggable.js +567 -0
- package/lib/hooks/useDroppable.d.ts +129 -0
- package/lib/hooks/useDroppable.js +261 -0
- package/lib/hooks/useSortable.d.ts +174 -0
- package/lib/hooks/useSortable.js +361 -0
- package/lib/hooks/useSortableList.d.ts +182 -0
- package/lib/hooks/useSortableList.js +211 -0
- package/lib/index.d.ts +11 -0
- package/lib/index.js +16 -0
- package/lib/types/context.d.ts +166 -0
- package/lib/types/context.js +80 -0
- package/lib/types/draggable.d.ts +313 -0
- package/lib/types/draggable.js +31 -0
- package/lib/types/droppable.d.ts +197 -0
- package/lib/types/droppable.js +1 -0
- package/lib/types/index.d.ts +4 -0
- package/lib/types/index.js +8 -0
- package/lib/types/sortable.d.ts +432 -0
- package/lib/types/sortable.js +6 -0
- package/package.json +59 -0
|
@@ -0,0 +1,567 @@
|
|
|
1
|
+
// hooks/useDraggable.ts
|
|
2
|
+
import React, { useRef, useCallback, useContext, useEffect, useState, } from "react";
|
|
3
|
+
import { useSharedValue, useAnimatedStyle, withSpring, runOnJS, runOnUI, useAnimatedReaction, useAnimatedRef, measure, } from "react-native-reanimated";
|
|
4
|
+
import { Gesture, } from "react-native-gesture-handler";
|
|
5
|
+
import { SlotsContext, } from "../types/context";
|
|
6
|
+
import { DraggableState, } from "../types/draggable";
|
|
7
|
+
/**
|
|
8
|
+
* A powerful hook for creating draggable components with advanced features like
|
|
9
|
+
* collision detection, bounded dragging, axis constraints, and custom animations.
|
|
10
|
+
*
|
|
11
|
+
* This hook provides the core functionality for drag-and-drop interactions,
|
|
12
|
+
* handling gesture recognition, position tracking, collision detection with drop zones,
|
|
13
|
+
* and smooth animations.
|
|
14
|
+
*
|
|
15
|
+
* @template TData - The type of data associated with the draggable item
|
|
16
|
+
* @param options - Configuration options for the draggable behavior
|
|
17
|
+
* @returns Object containing props, gesture handlers, and state for the draggable component
|
|
18
|
+
*
|
|
19
|
+
* @example
|
|
20
|
+
* Basic draggable component:
|
|
21
|
+
* ```typescript
|
|
22
|
+
* import { useDraggable } from './hooks/useDraggable';
|
|
23
|
+
*
|
|
24
|
+
* function MyDraggable() {
|
|
25
|
+
* const { animatedViewProps, gesture, state } = useDraggable({
|
|
26
|
+
* data: { id: '1', name: 'Draggable Item' },
|
|
27
|
+
* onDragStart: (data) => console.log('Started dragging:', data.name),
|
|
28
|
+
* onDragEnd: (data) => console.log('Finished dragging:', data.name),
|
|
29
|
+
* });
|
|
30
|
+
*
|
|
31
|
+
* return (
|
|
32
|
+
* <GestureDetector gesture={gesture}>
|
|
33
|
+
* <Animated.View {...animatedViewProps}>
|
|
34
|
+
* <Text>Drag me!</Text>
|
|
35
|
+
* </Animated.View>
|
|
36
|
+
* </GestureDetector>
|
|
37
|
+
* );
|
|
38
|
+
* }
|
|
39
|
+
* ```
|
|
40
|
+
*
|
|
41
|
+
* @example
|
|
42
|
+
* Draggable with custom animation and bounds:
|
|
43
|
+
* ```typescript
|
|
44
|
+
* function BoundedDraggable() {
|
|
45
|
+
* const boundsRef = useRef<View>(null);
|
|
46
|
+
*
|
|
47
|
+
* const { animatedViewProps, gesture } = useDraggable({
|
|
48
|
+
* data: { id: '2', type: 'bounded' },
|
|
49
|
+
* dragBoundsRef: boundsRef,
|
|
50
|
+
* dragAxis: 'x', // Only horizontal movement
|
|
51
|
+
* animationFunction: (toValue) => {
|
|
52
|
+
* 'worklet';
|
|
53
|
+
* return withTiming(toValue, { duration: 300 });
|
|
54
|
+
* },
|
|
55
|
+
* collisionAlgorithm: 'center',
|
|
56
|
+
* });
|
|
57
|
+
*
|
|
58
|
+
* return (
|
|
59
|
+
* <View ref={boundsRef} style={styles.container}>
|
|
60
|
+
* <GestureDetector gesture={gesture}>
|
|
61
|
+
* <Animated.View {...animatedViewProps}>
|
|
62
|
+
* <Text>Bounded horizontal draggable</Text>
|
|
63
|
+
* </Animated.View>
|
|
64
|
+
* </GestureDetector>
|
|
65
|
+
* </View>
|
|
66
|
+
* );
|
|
67
|
+
* }
|
|
68
|
+
* ```
|
|
69
|
+
*
|
|
70
|
+
* @example
|
|
71
|
+
* Draggable with state tracking:
|
|
72
|
+
* ```typescript
|
|
73
|
+
* function StatefulDraggable() {
|
|
74
|
+
* const [dragState, setDragState] = useState(DraggableState.IDLE);
|
|
75
|
+
*
|
|
76
|
+
* const { animatedViewProps, gesture } = useDraggable({
|
|
77
|
+
* data: { id: '3', status: 'active' },
|
|
78
|
+
* onStateChange: setDragState,
|
|
79
|
+
* onDragging: ({ x, y, tx, ty }) => {
|
|
80
|
+
* console.log(`Position: (${x + tx}, ${y + ty})`);
|
|
81
|
+
* },
|
|
82
|
+
* });
|
|
83
|
+
*
|
|
84
|
+
* return (
|
|
85
|
+
* <GestureDetector gesture={gesture}>
|
|
86
|
+
* <Animated.View
|
|
87
|
+
* {...animatedViewProps}
|
|
88
|
+
* style={[
|
|
89
|
+
* animatedViewProps.style,
|
|
90
|
+
* { opacity: dragState === DraggableState.DRAGGING ? 0.7 : 1 }
|
|
91
|
+
* ]}
|
|
92
|
+
* >
|
|
93
|
+
* <Text>State: {dragState}</Text>
|
|
94
|
+
* </Animated.View>
|
|
95
|
+
* </GestureDetector>
|
|
96
|
+
* );
|
|
97
|
+
* }
|
|
98
|
+
* ```
|
|
99
|
+
*
|
|
100
|
+
* @see {@link DraggableState} for state management
|
|
101
|
+
* @see {@link CollisionAlgorithm} for collision detection options
|
|
102
|
+
* @see {@link AnimationFunction} for custom animations
|
|
103
|
+
* @see {@link UseDraggableOptions} for configuration options
|
|
104
|
+
* @see {@link UseDraggableReturn} for return value details
|
|
105
|
+
*/
|
|
106
|
+
export const useDraggable = (options) => {
|
|
107
|
+
const { data, draggableId, dragDisabled = false, onDragStart, onDragEnd, onDragging, onStateChange, animationFunction, dragBoundsRef, dragAxis = "both", collisionAlgorithm = "intersect", children, handleComponent, } = options;
|
|
108
|
+
// Create animated ref first
|
|
109
|
+
const animatedViewRef = useAnimatedRef();
|
|
110
|
+
// Add state management
|
|
111
|
+
const [state, setState] = useState(DraggableState.IDLE);
|
|
112
|
+
const [hasHandle, setHasHandle] = useState(false);
|
|
113
|
+
// Check if any child is a Handle component
|
|
114
|
+
useEffect(() => {
|
|
115
|
+
if (!children || !handleComponent) {
|
|
116
|
+
setHasHandle(false);
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
// Check if children contain a Handle component
|
|
120
|
+
const checkForHandle = (child) => {
|
|
121
|
+
if (React.isValidElement(child)) {
|
|
122
|
+
// Check for direct component type match
|
|
123
|
+
if (child.type === handleComponent) {
|
|
124
|
+
return true;
|
|
125
|
+
}
|
|
126
|
+
// Check children recursively
|
|
127
|
+
if (child.props && child.props.children) {
|
|
128
|
+
if (React.Children.toArray(child.props.children).some(checkForHandle)) {
|
|
129
|
+
return true;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return false;
|
|
134
|
+
};
|
|
135
|
+
setHasHandle(React.Children.toArray(children).some(checkForHandle));
|
|
136
|
+
}, [children, handleComponent]);
|
|
137
|
+
useEffect(() => {
|
|
138
|
+
onStateChange === null || onStateChange === void 0 ? void 0 : onStateChange(state);
|
|
139
|
+
}, [state, onStateChange]);
|
|
140
|
+
const tx = useSharedValue(0);
|
|
141
|
+
const ty = useSharedValue(0);
|
|
142
|
+
const offsetX = useSharedValue(0);
|
|
143
|
+
const offsetY = useSharedValue(0);
|
|
144
|
+
const dragDisabledShared = useSharedValue(dragDisabled);
|
|
145
|
+
const dragAxisShared = useSharedValue(dragAxis);
|
|
146
|
+
const originX = useSharedValue(0);
|
|
147
|
+
const originY = useSharedValue(0);
|
|
148
|
+
const itemW = useSharedValue(0);
|
|
149
|
+
const itemH = useSharedValue(0);
|
|
150
|
+
const isOriginSet = useRef(false);
|
|
151
|
+
const internalDraggableId = useRef(draggableId || `draggable-${Math.random().toString(36).substr(2, 9)}`).current;
|
|
152
|
+
const boundsX = useSharedValue(0);
|
|
153
|
+
const boundsY = useSharedValue(0);
|
|
154
|
+
const boundsWidth = useSharedValue(0);
|
|
155
|
+
const boundsHeight = useSharedValue(0);
|
|
156
|
+
const boundsAreSet = useSharedValue(false);
|
|
157
|
+
const { getSlots, setActiveHoverSlot, activeHoverSlotId, registerPositionUpdateListener, unregisterPositionUpdateListener, registerDroppedItem, unregisterDroppedItem, hasAvailableCapacity, onDragging: contextOnDragging, onDragStart: contextOnDragStart, onDragEnd: contextOnDragEnd, } = useContext(SlotsContext);
|
|
158
|
+
useEffect(() => {
|
|
159
|
+
dragDisabledShared.value = dragDisabled;
|
|
160
|
+
}, [dragDisabled, dragDisabledShared]);
|
|
161
|
+
useEffect(() => {
|
|
162
|
+
dragAxisShared.value = dragAxis;
|
|
163
|
+
}, [dragAxis, dragAxisShared]);
|
|
164
|
+
const updateDraggablePosition = useCallback(() => {
|
|
165
|
+
runOnUI(() => {
|
|
166
|
+
"worklet";
|
|
167
|
+
const measurement = measure(animatedViewRef);
|
|
168
|
+
if (measurement === null) {
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
const currentTx = tx.value;
|
|
172
|
+
const currentTy = ty.value;
|
|
173
|
+
//only update the origin if the tx and ty are 0
|
|
174
|
+
if (currentTx === 0 && currentTy === 0) {
|
|
175
|
+
const newOriginX = measurement.pageX - currentTx;
|
|
176
|
+
const newOriginY = measurement.pageY - currentTy;
|
|
177
|
+
originX.value = newOriginX;
|
|
178
|
+
originY.value = newOriginY;
|
|
179
|
+
}
|
|
180
|
+
itemW.value = measurement.width;
|
|
181
|
+
itemH.value = measurement.height;
|
|
182
|
+
if (!isOriginSet.current) {
|
|
183
|
+
isOriginSet.current = true;
|
|
184
|
+
}
|
|
185
|
+
})();
|
|
186
|
+
}, [animatedViewRef, originX, originY, itemW, itemH, tx, ty]);
|
|
187
|
+
// Worklet version for use within UI thread contexts
|
|
188
|
+
const updateDraggablePositionWorklet = useCallback(() => {
|
|
189
|
+
"worklet";
|
|
190
|
+
const measurement = measure(animatedViewRef);
|
|
191
|
+
if (measurement === null) {
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
const currentTx = tx.value;
|
|
195
|
+
const currentTy = ty.value;
|
|
196
|
+
//only update the origin if the tx and ty are 0
|
|
197
|
+
if (currentTx === 0 && currentTy === 0) {
|
|
198
|
+
const newOriginX = measurement.pageX - currentTx;
|
|
199
|
+
const newOriginY = measurement.pageY - currentTy;
|
|
200
|
+
originX.value = newOriginX;
|
|
201
|
+
originY.value = newOriginY;
|
|
202
|
+
}
|
|
203
|
+
itemW.value = measurement.width;
|
|
204
|
+
itemH.value = measurement.height;
|
|
205
|
+
if (!isOriginSet.current) {
|
|
206
|
+
isOriginSet.current = true;
|
|
207
|
+
}
|
|
208
|
+
}, [animatedViewRef, originX, originY, itemW, itemH, tx, ty]);
|
|
209
|
+
const updateBounds = useCallback(() => {
|
|
210
|
+
const currentBoundsView = dragBoundsRef === null || dragBoundsRef === void 0 ? void 0 : dragBoundsRef.current;
|
|
211
|
+
if (currentBoundsView) {
|
|
212
|
+
currentBoundsView.measure((_x, _y, width, height, pageX, pageY) => {
|
|
213
|
+
if (typeof pageX === "number" &&
|
|
214
|
+
typeof pageY === "number" &&
|
|
215
|
+
width > 0 &&
|
|
216
|
+
height > 0) {
|
|
217
|
+
runOnUI(() => {
|
|
218
|
+
"worklet";
|
|
219
|
+
boundsX.value = pageX;
|
|
220
|
+
boundsY.value = pageY;
|
|
221
|
+
boundsWidth.value = width;
|
|
222
|
+
boundsHeight.value = height;
|
|
223
|
+
if (!boundsAreSet.value) {
|
|
224
|
+
boundsAreSet.value = true;
|
|
225
|
+
}
|
|
226
|
+
})();
|
|
227
|
+
}
|
|
228
|
+
else {
|
|
229
|
+
console.warn("useDraggable: dragBoundsRef measurement failed or returned invalid dimensions. Bounds may be stale or item unbounded.");
|
|
230
|
+
}
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
else {
|
|
234
|
+
runOnUI(() => {
|
|
235
|
+
"worklet";
|
|
236
|
+
if (boundsAreSet.value) {
|
|
237
|
+
boundsAreSet.value = false;
|
|
238
|
+
}
|
|
239
|
+
})();
|
|
240
|
+
}
|
|
241
|
+
}, [
|
|
242
|
+
dragBoundsRef,
|
|
243
|
+
boundsX,
|
|
244
|
+
boundsY,
|
|
245
|
+
boundsWidth,
|
|
246
|
+
boundsHeight,
|
|
247
|
+
boundsAreSet,
|
|
248
|
+
]);
|
|
249
|
+
useEffect(() => {
|
|
250
|
+
const handlePositionUpdate = () => {
|
|
251
|
+
updateDraggablePosition();
|
|
252
|
+
updateBounds();
|
|
253
|
+
};
|
|
254
|
+
registerPositionUpdateListener(internalDraggableId, handlePositionUpdate);
|
|
255
|
+
return () => {
|
|
256
|
+
unregisterPositionUpdateListener(internalDraggableId);
|
|
257
|
+
};
|
|
258
|
+
}, [
|
|
259
|
+
internalDraggableId,
|
|
260
|
+
registerPositionUpdateListener,
|
|
261
|
+
unregisterPositionUpdateListener,
|
|
262
|
+
updateDraggablePosition,
|
|
263
|
+
updateBounds,
|
|
264
|
+
]);
|
|
265
|
+
useEffect(() => {
|
|
266
|
+
updateBounds();
|
|
267
|
+
}, [updateBounds]);
|
|
268
|
+
const handleLayoutHandler = useCallback((event) => {
|
|
269
|
+
updateDraggablePosition();
|
|
270
|
+
}, [updateDraggablePosition]);
|
|
271
|
+
const animateDragEndPosition = useCallback((targetXValue, targetYValue) => {
|
|
272
|
+
"worklet";
|
|
273
|
+
if (animationFunction) {
|
|
274
|
+
tx.value = animationFunction(targetXValue);
|
|
275
|
+
ty.value = animationFunction(targetYValue);
|
|
276
|
+
}
|
|
277
|
+
else {
|
|
278
|
+
tx.value = withSpring(targetXValue);
|
|
279
|
+
ty.value = withSpring(targetYValue);
|
|
280
|
+
}
|
|
281
|
+
}, [animationFunction, tx, ty]);
|
|
282
|
+
const performCollisionCheck = useCallback((draggableX, draggableY, draggableW, draggableH, slot, algo) => {
|
|
283
|
+
if (algo === "intersect") {
|
|
284
|
+
return (draggableX < slot.x + slot.width &&
|
|
285
|
+
draggableX + draggableW > slot.x &&
|
|
286
|
+
draggableY < slot.y + slot.height &&
|
|
287
|
+
draggableY + draggableH > slot.y);
|
|
288
|
+
}
|
|
289
|
+
else if (algo === "contain") {
|
|
290
|
+
return (draggableX >= slot.x &&
|
|
291
|
+
draggableX + draggableW <= slot.x + slot.width &&
|
|
292
|
+
draggableY >= slot.y &&
|
|
293
|
+
draggableY + draggableH <= slot.y + slot.height);
|
|
294
|
+
}
|
|
295
|
+
else {
|
|
296
|
+
const draggableCenterX = draggableX + draggableW / 2;
|
|
297
|
+
const draggableCenterY = draggableY + draggableH / 2;
|
|
298
|
+
return (draggableCenterX >= slot.x &&
|
|
299
|
+
draggableCenterX <= slot.x + slot.width &&
|
|
300
|
+
draggableCenterY >= slot.y &&
|
|
301
|
+
draggableCenterY <= slot.y + slot.height);
|
|
302
|
+
}
|
|
303
|
+
}, []);
|
|
304
|
+
const processDropAndAnimate = useCallback((currentTxVal, currentTyVal, draggableData, currentOriginX, currentOriginY, currentItemW, currentItemH) => {
|
|
305
|
+
const slots = getSlots();
|
|
306
|
+
const currentDraggableX = currentOriginX + currentTxVal;
|
|
307
|
+
const currentDraggableY = currentOriginY + currentTyVal;
|
|
308
|
+
let hitSlotData = null;
|
|
309
|
+
let hitSlotId = null;
|
|
310
|
+
for (const key in slots) {
|
|
311
|
+
const slotId = parseInt(key, 10);
|
|
312
|
+
const s = slots[slotId];
|
|
313
|
+
const isCollision = performCollisionCheck(currentDraggableX, currentDraggableY, currentItemW, currentItemH, s, collisionAlgorithm);
|
|
314
|
+
if (isCollision) {
|
|
315
|
+
const hasCapacity = hasAvailableCapacity(s.id);
|
|
316
|
+
if (hasCapacity) {
|
|
317
|
+
hitSlotData = s;
|
|
318
|
+
hitSlotId = slotId;
|
|
319
|
+
break;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
let finalTxValue;
|
|
324
|
+
let finalTyValue;
|
|
325
|
+
if (hitSlotData && hitSlotId !== null) {
|
|
326
|
+
if (hitSlotData.onDrop) {
|
|
327
|
+
runOnJS(hitSlotData.onDrop)(draggableData);
|
|
328
|
+
}
|
|
329
|
+
runOnJS(registerDroppedItem)(internalDraggableId, hitSlotData.id, draggableData);
|
|
330
|
+
runOnJS(setState)(DraggableState.DROPPED);
|
|
331
|
+
const alignment = hitSlotData.dropAlignment || "center";
|
|
332
|
+
const offset = hitSlotData.dropOffset || { x: 0, y: 0 };
|
|
333
|
+
let targetX = 0;
|
|
334
|
+
let targetY = 0;
|
|
335
|
+
switch (alignment) {
|
|
336
|
+
case "top-left":
|
|
337
|
+
targetX = hitSlotData.x;
|
|
338
|
+
targetY = hitSlotData.y;
|
|
339
|
+
break;
|
|
340
|
+
case "top-center":
|
|
341
|
+
targetX = hitSlotData.x + hitSlotData.width / 2 - currentItemW / 2;
|
|
342
|
+
targetY = hitSlotData.y;
|
|
343
|
+
break;
|
|
344
|
+
case "top-right":
|
|
345
|
+
targetX = hitSlotData.x + hitSlotData.width - currentItemW;
|
|
346
|
+
targetY = hitSlotData.y;
|
|
347
|
+
break;
|
|
348
|
+
case "center-left":
|
|
349
|
+
targetX = hitSlotData.x;
|
|
350
|
+
targetY = hitSlotData.y + hitSlotData.height / 2 - currentItemH / 2;
|
|
351
|
+
break;
|
|
352
|
+
case "center":
|
|
353
|
+
targetX = hitSlotData.x + hitSlotData.width / 2 - currentItemW / 2;
|
|
354
|
+
targetY = hitSlotData.y + hitSlotData.height / 2 - currentItemH / 2;
|
|
355
|
+
break;
|
|
356
|
+
case "center-right":
|
|
357
|
+
targetX = hitSlotData.x + hitSlotData.width - currentItemW;
|
|
358
|
+
targetY = hitSlotData.y + hitSlotData.height / 2 - currentItemH / 2;
|
|
359
|
+
break;
|
|
360
|
+
case "bottom-left":
|
|
361
|
+
targetX = hitSlotData.x;
|
|
362
|
+
targetY = hitSlotData.y + hitSlotData.height - currentItemH;
|
|
363
|
+
break;
|
|
364
|
+
case "bottom-center":
|
|
365
|
+
targetX = hitSlotData.x + hitSlotData.width / 2 - currentItemW / 2;
|
|
366
|
+
targetY = hitSlotData.y + hitSlotData.height - currentItemH;
|
|
367
|
+
break;
|
|
368
|
+
case "bottom-right":
|
|
369
|
+
targetX = hitSlotData.x + hitSlotData.width - currentItemW;
|
|
370
|
+
targetY = hitSlotData.y + hitSlotData.height - currentItemH;
|
|
371
|
+
break;
|
|
372
|
+
default:
|
|
373
|
+
targetX = hitSlotData.x + hitSlotData.width / 2 - currentItemW / 2;
|
|
374
|
+
targetY = hitSlotData.y + hitSlotData.height / 2 - currentItemH / 2;
|
|
375
|
+
}
|
|
376
|
+
const draggableTargetX = targetX + offset.x;
|
|
377
|
+
const draggableTargetY = targetY + offset.y;
|
|
378
|
+
finalTxValue = draggableTargetX - currentOriginX;
|
|
379
|
+
finalTyValue = draggableTargetY - currentOriginY;
|
|
380
|
+
}
|
|
381
|
+
else {
|
|
382
|
+
// No hit slot or no capacity available - reset to original position and set state to IDLE
|
|
383
|
+
finalTxValue = 0;
|
|
384
|
+
finalTyValue = 0;
|
|
385
|
+
runOnJS(setState)(DraggableState.IDLE);
|
|
386
|
+
runOnJS(unregisterDroppedItem)(internalDraggableId);
|
|
387
|
+
}
|
|
388
|
+
runOnUI(animateDragEndPosition)(finalTxValue, finalTyValue);
|
|
389
|
+
}, [
|
|
390
|
+
getSlots,
|
|
391
|
+
animateDragEndPosition,
|
|
392
|
+
collisionAlgorithm,
|
|
393
|
+
performCollisionCheck,
|
|
394
|
+
setState,
|
|
395
|
+
internalDraggableId,
|
|
396
|
+
registerDroppedItem,
|
|
397
|
+
unregisterDroppedItem,
|
|
398
|
+
hasAvailableCapacity,
|
|
399
|
+
]);
|
|
400
|
+
const updateHoverState = useCallback((currentTxVal, currentTyVal, currentOriginX, currentOriginY, currentItemW, currentItemH) => {
|
|
401
|
+
const slots = getSlots();
|
|
402
|
+
const currentDraggableX = currentOriginX + currentTxVal;
|
|
403
|
+
const currentDraggableY = currentOriginY + currentTyVal;
|
|
404
|
+
let newHoveredSlotId = null;
|
|
405
|
+
for (const key in slots) {
|
|
406
|
+
const slotId = parseInt(key, 10);
|
|
407
|
+
const s = slots[slotId];
|
|
408
|
+
const isCollision = performCollisionCheck(currentDraggableX, currentDraggableY, currentItemW, currentItemH, s, collisionAlgorithm);
|
|
409
|
+
if (isCollision) {
|
|
410
|
+
newHoveredSlotId = slotId;
|
|
411
|
+
break;
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
if (activeHoverSlotId !== newHoveredSlotId) {
|
|
415
|
+
setActiveHoverSlot(newHoveredSlotId);
|
|
416
|
+
}
|
|
417
|
+
}, [
|
|
418
|
+
getSlots,
|
|
419
|
+
setActiveHoverSlot,
|
|
420
|
+
activeHoverSlotId,
|
|
421
|
+
collisionAlgorithm,
|
|
422
|
+
performCollisionCheck,
|
|
423
|
+
]);
|
|
424
|
+
const gesture = React.useMemo(() => Gesture.Pan()
|
|
425
|
+
.onBegin(() => {
|
|
426
|
+
"worklet";
|
|
427
|
+
//first update the position
|
|
428
|
+
updateDraggablePositionWorklet();
|
|
429
|
+
if (dragDisabledShared.value)
|
|
430
|
+
return;
|
|
431
|
+
offsetX.value = tx.value;
|
|
432
|
+
offsetY.value = ty.value;
|
|
433
|
+
// Update state to DRAGGING when drag begins
|
|
434
|
+
runOnJS(setState)(DraggableState.DRAGGING);
|
|
435
|
+
if (onDragStart)
|
|
436
|
+
runOnJS(onDragStart)(data);
|
|
437
|
+
if (contextOnDragStart)
|
|
438
|
+
runOnJS(contextOnDragStart)(data);
|
|
439
|
+
})
|
|
440
|
+
.onUpdate((event) => {
|
|
441
|
+
"worklet";
|
|
442
|
+
if (dragDisabledShared.value)
|
|
443
|
+
return;
|
|
444
|
+
let newTx = offsetX.value + event.translationX;
|
|
445
|
+
let newTy = offsetY.value + event.translationY;
|
|
446
|
+
if (boundsAreSet.value) {
|
|
447
|
+
const currentItemW = itemW.value;
|
|
448
|
+
const currentItemH = itemH.value;
|
|
449
|
+
const minTx = boundsX.value - originX.value;
|
|
450
|
+
const maxTx = boundsX.value + boundsWidth.value - originX.value - currentItemW;
|
|
451
|
+
const minTy = boundsY.value - originY.value;
|
|
452
|
+
const maxTy = boundsY.value + boundsHeight.value - originY.value - currentItemH;
|
|
453
|
+
newTx = Math.max(minTx, Math.min(newTx, maxTx));
|
|
454
|
+
newTy = Math.max(minTy, Math.min(newTy, maxTy));
|
|
455
|
+
}
|
|
456
|
+
if (dragAxisShared.value === "x") {
|
|
457
|
+
tx.value = newTx;
|
|
458
|
+
}
|
|
459
|
+
else if (dragAxisShared.value === "y") {
|
|
460
|
+
ty.value = newTy;
|
|
461
|
+
}
|
|
462
|
+
else {
|
|
463
|
+
tx.value = newTx;
|
|
464
|
+
ty.value = newTy;
|
|
465
|
+
}
|
|
466
|
+
if (onDragging) {
|
|
467
|
+
runOnJS(onDragging)({
|
|
468
|
+
x: originX.value,
|
|
469
|
+
y: originY.value,
|
|
470
|
+
tx: tx.value,
|
|
471
|
+
ty: ty.value,
|
|
472
|
+
itemData: data,
|
|
473
|
+
});
|
|
474
|
+
}
|
|
475
|
+
if (contextOnDragging) {
|
|
476
|
+
runOnJS(contextOnDragging)({
|
|
477
|
+
x: originX.value,
|
|
478
|
+
y: originY.value,
|
|
479
|
+
tx: tx.value,
|
|
480
|
+
ty: ty.value,
|
|
481
|
+
itemData: data,
|
|
482
|
+
});
|
|
483
|
+
}
|
|
484
|
+
runOnJS(updateHoverState)(tx.value, ty.value, originX.value, originY.value, itemW.value, itemH.value);
|
|
485
|
+
})
|
|
486
|
+
.onEnd(() => {
|
|
487
|
+
"worklet";
|
|
488
|
+
if (dragDisabledShared.value)
|
|
489
|
+
return;
|
|
490
|
+
if (onDragEnd)
|
|
491
|
+
runOnJS(onDragEnd)(data);
|
|
492
|
+
if (contextOnDragEnd)
|
|
493
|
+
runOnJS(contextOnDragEnd)(data);
|
|
494
|
+
runOnJS(processDropAndAnimate)(tx.value, ty.value, data, originX.value, originY.value, itemW.value, itemH.value);
|
|
495
|
+
runOnJS(setActiveHoverSlot)(null);
|
|
496
|
+
}), [
|
|
497
|
+
dragDisabledShared,
|
|
498
|
+
offsetX,
|
|
499
|
+
offsetY,
|
|
500
|
+
tx,
|
|
501
|
+
ty,
|
|
502
|
+
originX,
|
|
503
|
+
originY,
|
|
504
|
+
itemW,
|
|
505
|
+
itemH,
|
|
506
|
+
onDragStart,
|
|
507
|
+
onDragEnd,
|
|
508
|
+
data,
|
|
509
|
+
processDropAndAnimate,
|
|
510
|
+
updateHoverState,
|
|
511
|
+
setActiveHoverSlot,
|
|
512
|
+
animationFunction,
|
|
513
|
+
onDragging,
|
|
514
|
+
boundsAreSet,
|
|
515
|
+
boundsX,
|
|
516
|
+
boundsY,
|
|
517
|
+
boundsWidth,
|
|
518
|
+
boundsHeight,
|
|
519
|
+
dragAxisShared,
|
|
520
|
+
setState,
|
|
521
|
+
updateDraggablePositionWorklet,
|
|
522
|
+
contextOnDragging,
|
|
523
|
+
contextOnDragStart,
|
|
524
|
+
contextOnDragEnd,
|
|
525
|
+
]);
|
|
526
|
+
const animatedStyleProp = useAnimatedStyle(() => {
|
|
527
|
+
"worklet";
|
|
528
|
+
return {
|
|
529
|
+
transform: [{ translateX: tx.value }, { translateY: ty.value }],
|
|
530
|
+
};
|
|
531
|
+
}, [tx, ty]);
|
|
532
|
+
// Replace the React useEffect with useAnimatedReaction to properly handle shared values
|
|
533
|
+
useAnimatedReaction(() => {
|
|
534
|
+
// This runs on the UI thread and detects when position is back to origin
|
|
535
|
+
// and state needs to be reset
|
|
536
|
+
return {
|
|
537
|
+
txValue: tx.value,
|
|
538
|
+
tyValue: ty.value,
|
|
539
|
+
isZero: tx.value === 0 && ty.value === 0,
|
|
540
|
+
};
|
|
541
|
+
}, (result, previous) => {
|
|
542
|
+
// Only trigger when values change to zero (returned to original position)
|
|
543
|
+
if (result.isZero && previous && !previous.isZero) {
|
|
544
|
+
// Use runOnJS to call setState from the UI thread
|
|
545
|
+
runOnJS(setState)(DraggableState.IDLE);
|
|
546
|
+
// When returning to origin position, we know we're no longer dropped
|
|
547
|
+
runOnJS(unregisterDroppedItem)(internalDraggableId);
|
|
548
|
+
}
|
|
549
|
+
}, [setState, unregisterDroppedItem, internalDraggableId]);
|
|
550
|
+
// Clean up on unmount
|
|
551
|
+
useEffect(() => {
|
|
552
|
+
return () => {
|
|
553
|
+
// Clean up any registered drops when unmounting
|
|
554
|
+
unregisterDroppedItem(internalDraggableId);
|
|
555
|
+
};
|
|
556
|
+
}, [internalDraggableId, unregisterDroppedItem]);
|
|
557
|
+
return {
|
|
558
|
+
animatedViewProps: {
|
|
559
|
+
style: animatedStyleProp,
|
|
560
|
+
onLayout: handleLayoutHandler,
|
|
561
|
+
},
|
|
562
|
+
gesture,
|
|
563
|
+
state,
|
|
564
|
+
animatedViewRef,
|
|
565
|
+
hasHandle,
|
|
566
|
+
};
|
|
567
|
+
};
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { UseDroppableOptions, UseDroppableReturn } from "../types/droppable";
|
|
2
|
+
/**
|
|
3
|
+
* A hook for creating drop zones that can receive draggable items.
|
|
4
|
+
*
|
|
5
|
+
* This hook handles the registration of drop zones, collision detection with draggable items,
|
|
6
|
+
* visual feedback during hover states, and proper positioning of dropped items within the zone.
|
|
7
|
+
* It integrates seamlessly with the drag-and-drop context to provide a complete solution.
|
|
8
|
+
*
|
|
9
|
+
* @template TData - The type of data that can be dropped on this droppable
|
|
10
|
+
* @param options - Configuration options for the droppable behavior
|
|
11
|
+
* @returns Object containing view props, active state, and internal references
|
|
12
|
+
*
|
|
13
|
+
* @example
|
|
14
|
+
* Basic drop zone:
|
|
15
|
+
* ```typescript
|
|
16
|
+
* import { useDroppable } from './hooks/useDroppable';
|
|
17
|
+
*
|
|
18
|
+
* function BasicDropZone() {
|
|
19
|
+
* const { viewProps, isActive } = useDroppable({
|
|
20
|
+
* onDrop: (data) => {
|
|
21
|
+
* console.log('Item dropped:', data);
|
|
22
|
+
* // Handle the dropped item
|
|
23
|
+
* }
|
|
24
|
+
* });
|
|
25
|
+
*
|
|
26
|
+
* return (
|
|
27
|
+
* <Animated.View
|
|
28
|
+
* {...viewProps}
|
|
29
|
+
* style={[
|
|
30
|
+
* styles.dropZone,
|
|
31
|
+
* viewProps.style, // Important: include the active style
|
|
32
|
+
* isActive && styles.highlighted
|
|
33
|
+
* ]}
|
|
34
|
+
* >
|
|
35
|
+
* <Text>Drop items here</Text>
|
|
36
|
+
* </Animated.View>
|
|
37
|
+
* );
|
|
38
|
+
* }
|
|
39
|
+
* ```
|
|
40
|
+
*
|
|
41
|
+
* @example
|
|
42
|
+
* Drop zone with custom alignment and capacity:
|
|
43
|
+
* ```typescript
|
|
44
|
+
* function TaskColumn() {
|
|
45
|
+
* const [tasks, setTasks] = useState<Task[]>([]);
|
|
46
|
+
*
|
|
47
|
+
* const { viewProps, isActive } = useDroppable({
|
|
48
|
+
* droppableId: 'in-progress-column',
|
|
49
|
+
* onDrop: (task: Task) => {
|
|
50
|
+
* setTasks(prev => [...prev, task]);
|
|
51
|
+
* updateTaskStatus(task.id, 'in-progress');
|
|
52
|
+
* },
|
|
53
|
+
* dropAlignment: 'top-center',
|
|
54
|
+
* dropOffset: { x: 0, y: 10 },
|
|
55
|
+
* capacity: 10, // Max 10 tasks in this column
|
|
56
|
+
* activeStyle: {
|
|
57
|
+
* backgroundColor: 'rgba(59, 130, 246, 0.1)',
|
|
58
|
+
* borderColor: '#3b82f6',
|
|
59
|
+
* borderWidth: 2,
|
|
60
|
+
* borderStyle: 'dashed'
|
|
61
|
+
* }
|
|
62
|
+
* });
|
|
63
|
+
*
|
|
64
|
+
* return (
|
|
65
|
+
* <Animated.View {...viewProps} style={[styles.column, viewProps.style]}>
|
|
66
|
+
* <Text style={styles.columnTitle}>In Progress ({tasks.length}/10)</Text>
|
|
67
|
+
* {tasks.map(task => (
|
|
68
|
+
* <TaskCard key={task.id} task={task} />
|
|
69
|
+
* ))}
|
|
70
|
+
* {isActive && (
|
|
71
|
+
* <Text style={styles.dropHint}>Release to add task</Text>
|
|
72
|
+
* )}
|
|
73
|
+
* </Animated.View>
|
|
74
|
+
* );
|
|
75
|
+
* }
|
|
76
|
+
* ```
|
|
77
|
+
*
|
|
78
|
+
* @example
|
|
79
|
+
* Conditional drop zone with validation:
|
|
80
|
+
* ```typescript
|
|
81
|
+
* function RestrictedDropZone() {
|
|
82
|
+
* const [canAcceptItems, setCanAcceptItems] = useState(true);
|
|
83
|
+
*
|
|
84
|
+
* const { viewProps, isActive } = useDroppable({
|
|
85
|
+
* onDrop: (data: FileData) => {
|
|
86
|
+
* if (data.type === 'image' && data.size < 5000000) {
|
|
87
|
+
* uploadFile(data);
|
|
88
|
+
* } else {
|
|
89
|
+
* showError('Only images under 5MB allowed');
|
|
90
|
+
* }
|
|
91
|
+
* },
|
|
92
|
+
* dropDisabled: !canAcceptItems,
|
|
93
|
+
* onActiveChange: (active) => {
|
|
94
|
+
* if (active) {
|
|
95
|
+
* setHoverFeedback('Drop your image here');
|
|
96
|
+
* } else {
|
|
97
|
+
* setHoverFeedback('');
|
|
98
|
+
* }
|
|
99
|
+
* },
|
|
100
|
+
* activeStyle: {
|
|
101
|
+
* backgroundColor: canAcceptItems ? 'rgba(34, 197, 94, 0.1)' : 'rgba(239, 68, 68, 0.1)',
|
|
102
|
+
* borderColor: canAcceptItems ? '#22c55e' : '#ef4444'
|
|
103
|
+
* }
|
|
104
|
+
* });
|
|
105
|
+
*
|
|
106
|
+
* return (
|
|
107
|
+
* <Animated.View
|
|
108
|
+
* {...viewProps}
|
|
109
|
+
* style={[
|
|
110
|
+
* styles.uploadZone,
|
|
111
|
+
* viewProps.style,
|
|
112
|
+
* !canAcceptItems && styles.disabled
|
|
113
|
+
* ]}
|
|
114
|
+
* >
|
|
115
|
+
* <Text>
|
|
116
|
+
* {canAcceptItems ? 'Drop images here' : 'Upload disabled'}
|
|
117
|
+
* </Text>
|
|
118
|
+
* {isActive && <Text>Release to upload</Text>}
|
|
119
|
+
* </Animated.View>
|
|
120
|
+
* );
|
|
121
|
+
* }
|
|
122
|
+
* ```
|
|
123
|
+
*
|
|
124
|
+
* @see {@link DropAlignment} for alignment options
|
|
125
|
+
* @see {@link DropOffset} for offset configuration
|
|
126
|
+
* @see {@link UseDroppableOptions} for configuration options
|
|
127
|
+
* @see {@link UseDroppableReturn} for return value details
|
|
128
|
+
*/
|
|
129
|
+
export declare const useDroppable: <TData = unknown>(options: UseDroppableOptions<TData>) => UseDroppableReturn;
|