react-native-swappable-grid 1.0.0

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.
Files changed (44) hide show
  1. package/README.md +284 -0
  2. package/lib/commonjs/ChildWrapper.js +320 -0
  3. package/lib/commonjs/ChildWrapper.js.map +1 -0
  4. package/lib/commonjs/SwappableGrid.js +378 -0
  5. package/lib/commonjs/SwappableGrid.js.map +1 -0
  6. package/lib/commonjs/index.js +14 -0
  7. package/lib/commonjs/index.js.map +1 -0
  8. package/lib/commonjs/utils/helpers/computerMinHeight.js +11 -0
  9. package/lib/commonjs/utils/helpers/computerMinHeight.js.map +1 -0
  10. package/lib/commonjs/utils/helpers/gestures/PanWithLongPress.js +249 -0
  11. package/lib/commonjs/utils/helpers/gestures/PanWithLongPress.js.map +1 -0
  12. package/lib/commonjs/utils/helpers/indexCalculations.js +73 -0
  13. package/lib/commonjs/utils/helpers/indexCalculations.js.map +1 -0
  14. package/lib/commonjs/utils/useGridLayout.js +205 -0
  15. package/lib/commonjs/utils/useGridLayout.js.map +1 -0
  16. package/lib/module/ChildWrapper.js +313 -0
  17. package/lib/module/ChildWrapper.js.map +1 -0
  18. package/lib/module/SwappableGrid.js +370 -0
  19. package/lib/module/SwappableGrid.js.map +1 -0
  20. package/lib/module/index.js +2 -0
  21. package/lib/module/index.js.map +1 -0
  22. package/lib/module/utils/helpers/computerMinHeight.js +5 -0
  23. package/lib/module/utils/helpers/computerMinHeight.js.map +1 -0
  24. package/lib/module/utils/helpers/gestures/PanWithLongPress.js +242 -0
  25. package/lib/module/utils/helpers/gestures/PanWithLongPress.js.map +1 -0
  26. package/lib/module/utils/helpers/indexCalculations.js +64 -0
  27. package/lib/module/utils/helpers/indexCalculations.js.map +1 -0
  28. package/lib/module/utils/useGridLayout.js +199 -0
  29. package/lib/module/utils/useGridLayout.js.map +1 -0
  30. package/lib/typescript/ChildWrapper.d.ts +23 -0
  31. package/lib/typescript/SwappableGrid.d.ts +85 -0
  32. package/lib/typescript/index.d.ts +2 -0
  33. package/lib/typescript/utils/helpers/computerMinHeight.d.ts +1 -0
  34. package/lib/typescript/utils/helpers/gestures/PanWithLongPress.d.ts +40 -0
  35. package/lib/typescript/utils/helpers/indexCalculations.d.ts +28 -0
  36. package/lib/typescript/utils/useGridLayout.d.ts +46 -0
  37. package/package.json +68 -0
  38. package/src/ChildWrapper.tsx +376 -0
  39. package/src/SwappableGrid.tsx +492 -0
  40. package/src/index.ts +2 -0
  41. package/src/utils/helpers/computerMinHeight.ts +9 -0
  42. package/src/utils/helpers/gestures/PanWithLongPress.ts +304 -0
  43. package/src/utils/helpers/indexCalculations.ts +91 -0
  44. package/src/utils/useGridLayout.ts +236 -0
@@ -0,0 +1,85 @@
1
+ import React, { ReactNode } from "react";
2
+ import { StyleProp, ViewStyle } from "react-native";
3
+ /**
4
+ * Props for the SwappableGrid component
5
+ */
6
+ type SwappableGridProps = {
7
+ /** The child components to render in the grid. Each child should have a unique key. */
8
+ children: ReactNode;
9
+ /** Width of each grid item in pixels */
10
+ itemWidth: number;
11
+ /** Height of each grid item in pixels */
12
+ itemHeight: number;
13
+ /** Gap between grid items in pixels. Defaults to 8. */
14
+ gap?: number;
15
+ /** Padding around the container in pixels. Defaults to 8. */
16
+ containerPadding?: number;
17
+ /** Duration in milliseconds to hold before drag starts. Defaults to 300. */
18
+ longPressMs?: number;
19
+ /** Number of columns in the grid. If not provided, will be calculated automatically based on container width. */
20
+ numColumns?: number;
21
+ /** Wiggle animation configuration when items are in drag mode or delete mode */
22
+ wiggle?: {
23
+ /** Duration of one wiggle cycle in milliseconds */
24
+ duration: number;
25
+ /** Rotation degrees for the wiggle animation */
26
+ degrees: number;
27
+ };
28
+ /** Callback fired when drag ends, providing the ordered array of child nodes */
29
+ onDragEnd?: (ordered: ChildNode[]) => void;
30
+ /** Callback fired when the order changes, providing an array of keys in the new order */
31
+ onOrderChange?: (keys: string[]) => void;
32
+ /** Callback fired when an item is deleted, providing the key of the deleted item */
33
+ onDelete?: (key: string) => void;
34
+ /** Factor by which the dragged item scales up. Defaults to 1.06. */
35
+ dragSizeIncreaseFactor?: number;
36
+ /** Speed of auto-scrolling when dragging near edges. Defaults to 10. */
37
+ scrollSpeed?: number;
38
+ /** Distance from edge in pixels that triggers auto-scroll. Defaults to 100. */
39
+ scrollThreshold?: number;
40
+ /** Custom style for the ScrollView container */
41
+ style?: StyleProp<ViewStyle>;
42
+ /** Component to render after all grid items (e.g., an "Add" button) */
43
+ trailingComponent?: ReactNode;
44
+ /** Component to render as a delete target (shown when dragging). If provided, disables hold-to-delete feature. */
45
+ deleteComponent?: ReactNode;
46
+ /** Custom style for the delete component. If provided, allows custom positioning. */
47
+ deleteComponentStyle?: StyleProp<ViewStyle>;
48
+ /** If true, reverses the order of items (right-to-left, bottom-to-top). Defaults to false. */
49
+ reverse?: boolean;
50
+ };
51
+ /**
52
+ * Ref methods for SwappableGrid component
53
+ */
54
+ export interface SwappableGridRef {
55
+ /** Cancels the delete mode if any item is currently in delete mode */
56
+ cancelDeleteMode: () => void;
57
+ }
58
+ /**
59
+ * SwappableGrid - A React Native component for creating a draggable, swappable grid layout.
60
+ *
61
+ * Features:
62
+ * - Drag and drop to reorder items
63
+ * - Long press to enter drag mode
64
+ * - Auto-scroll when dragging near edges
65
+ * - Optional wiggle animation during drag mode
66
+ * - Optional delete functionality with hold-to-delete or delete component
67
+ * - Support for trailing components (e.g., "Add" button)
68
+ * - Automatic column calculation based on container width
69
+ *
70
+ * @example
71
+ * ```tsx
72
+ * <SwappableGrid
73
+ * itemWidth={100}
74
+ * itemHeight={100}
75
+ * numColumns={3}
76
+ * onOrderChange={(keys) => console.log('New order:', keys)}
77
+ * >
78
+ * {items.map(item => (
79
+ * <View key={item.id}>{item.content}</View>
80
+ * ))}
81
+ * </SwappableGrid>
82
+ * ```
83
+ */
84
+ declare const SwappableGrid: React.ForwardRefExoticComponent<SwappableGridProps & React.RefAttributes<SwappableGridRef>>;
85
+ export default SwappableGrid;
@@ -0,0 +1,2 @@
1
+ export { default as SwappableGrid } from "./SwappableGrid";
2
+ export type { SwappableGridRef } from "./SwappableGrid";
@@ -0,0 +1 @@
1
+ export default function computeMinHeight(count: number, numColumns: number, tileH: number, pad: number): number;
@@ -0,0 +1,40 @@
1
+ import { SharedValue, AnimatedRef } from "react-native-reanimated";
2
+ interface PanProps {
3
+ order: SharedValue<string[]>;
4
+ dynamicNumColumns: SharedValue<number>;
5
+ activeKey: SharedValue<string | null>;
6
+ offsetX: SharedValue<number>;
7
+ offsetY: SharedValue<number>;
8
+ startX: SharedValue<number>;
9
+ startY: SharedValue<number>;
10
+ dragMode: SharedValue<boolean>;
11
+ positions: any;
12
+ itemsByKey: any;
13
+ itemWidth: number;
14
+ itemHeight: number;
15
+ containerPadding: number;
16
+ gap: number;
17
+ setOrderState: React.Dispatch<React.SetStateAction<string[]>>;
18
+ onDragEnd?: (ordered: ChildNode[]) => void;
19
+ onOrderChange?: (keys: string[]) => void;
20
+ scrollSpeed: number;
21
+ scrollThreshold: number;
22
+ scrollViewRef: AnimatedRef<any>;
23
+ scrollOffset: SharedValue<number>;
24
+ viewportH: SharedValue<number>;
25
+ longPressMs: number;
26
+ contentH: SharedValue<number>;
27
+ reverse?: boolean;
28
+ deleteComponentPosition?: SharedValue<{
29
+ x: number;
30
+ y: number;
31
+ width: number;
32
+ height: number;
33
+ } | null>;
34
+ deleteItem?: (key: string) => void;
35
+ contentPaddingBottom?: number;
36
+ }
37
+ export declare const PanWithLongPress: (props: PanProps & {
38
+ longPressMs: number;
39
+ }) => import("react-native-gesture-handler/lib/typescript/handlers/gestures/panGesture").PanGesture;
40
+ export {};
@@ -0,0 +1,28 @@
1
+ import { SharedValue } from "react-native-reanimated";
2
+ interface IndexToXYProps {
3
+ index: number;
4
+ itemHeight: number;
5
+ itemWidth: number;
6
+ dynamicNumColumns: SharedValue<number>;
7
+ containerPadding: number;
8
+ gap: number;
9
+ }
10
+ export declare const indexToXY: ({ index, itemHeight, itemWidth, dynamicNumColumns, containerPadding, gap, }: IndexToXYProps) => {
11
+ x: number;
12
+ y: number;
13
+ };
14
+ interface XYToIndexProps {
15
+ order: SharedValue<string[]>;
16
+ x: number;
17
+ y: number;
18
+ itemHeight: number;
19
+ itemWidth: number;
20
+ dynamicNumColumns: SharedValue<number>;
21
+ containerPadding: number;
22
+ gap: number;
23
+ }
24
+ export declare const xyToIndex: ({ order, x, y, itemHeight, itemWidth, dynamicNumColumns, gap, containerPadding, }: XYToIndexProps) => number;
25
+ export declare const toIndex1ColFromLiveMidlines: (order: SharedValue<string[]>, positions: Record<string, {
26
+ y: SharedValue<number>;
27
+ }>, activeKey: SharedValue<string | null>, itemHeight: number, centerY: number, reverse: boolean) => number;
28
+ export {};
@@ -0,0 +1,46 @@
1
+ import { ReactNode } from "react";
2
+ import { LayoutChangeEvent } from "react-native";
3
+ import { AnimatedRef, SharedValue } from "react-native-reanimated";
4
+ interface useGridLayoutProps {
5
+ reverse?: boolean;
6
+ children: ReactNode;
7
+ itemWidth: number;
8
+ itemHeight: number;
9
+ gap: number;
10
+ containerPadding: number;
11
+ longPressMs: number;
12
+ numColumns?: number;
13
+ onDragEnd?: (ordered: ChildNode[]) => void;
14
+ onOrderChange?: (keys: string[]) => void;
15
+ onDelete?: (key: string) => void;
16
+ scrollViewRef: AnimatedRef<any>;
17
+ scrollSpeed: number;
18
+ scrollThreshold: number;
19
+ contentPaddingBottom?: number;
20
+ }
21
+ export declare function useGridLayout({ reverse, children, itemWidth, itemHeight, gap, containerPadding, longPressMs, numColumns, onDragEnd, onOrderChange, onDelete, scrollViewRef, scrollSpeed, scrollThreshold, contentPaddingBottom, }: useGridLayoutProps): {
22
+ itemsByKey: Record<string, ReactNode>;
23
+ orderState: string[];
24
+ dragMode: SharedValue<boolean>;
25
+ anyItemInDeleteMode: SharedValue<boolean>;
26
+ composed: import("react-native-gesture-handler/lib/typescript/handlers/gestures/gestureComposition").SimultaneousGesture;
27
+ dynamicNumColumns: SharedValue<number>;
28
+ onLayoutContent: (e: LayoutChangeEvent) => void;
29
+ onLayoutScrollView: (e: LayoutChangeEvent) => void;
30
+ positions: Record<string, {
31
+ x: SharedValue<number>;
32
+ y: SharedValue<number>;
33
+ active: SharedValue<number>;
34
+ }>;
35
+ onScroll: import("react-native-reanimated").ScrollHandlerProcessed<Record<string, unknown>>;
36
+ childArray: import("react").ReactElement<unknown, string | import("react").JSXElementConstructor<any>>[];
37
+ order: SharedValue<string[]>;
38
+ deleteItem: (key: string) => void;
39
+ deleteComponentPosition: SharedValue<{
40
+ x: number;
41
+ y: number;
42
+ width: number;
43
+ height: number;
44
+ }>;
45
+ };
46
+ export {};
package/package.json ADDED
@@ -0,0 +1,68 @@
1
+ {
2
+ "main": "lib/commonjs/index.js",
3
+ "module": "lib/module/index.js",
4
+ "types": "lib/typescript/index.d.ts",
5
+ "react-native": "src/index.ts",
6
+ "files": [
7
+ "src",
8
+ "lib",
9
+ "!**/__tests__",
10
+ "!**/__fixtures__",
11
+ "!**/__mocks__"
12
+ ],
13
+ "scripts": {
14
+ "build": "bob build",
15
+ "prepare": "bob build",
16
+ "format": "prettier --write \"./**/*.{js,css,md,tsx,ts}\""
17
+ },
18
+ "peerDependencies": {
19
+ "react": "*",
20
+ "react-native": "*",
21
+ "react-native-gesture-handler": "*",
22
+ "react-native-reanimated": "*"
23
+ },
24
+ "devDependencies": {
25
+ "@types/react": "^19.2.7",
26
+ "@types/react-native": "^0.73.0",
27
+ "react-native-builder-bob": "^0.18.3",
28
+ "react-native-gesture-handler": "^2.30.0",
29
+ "react-native-reanimated": "^4.2.1",
30
+ "typescript": "^5.9.3"
31
+ },
32
+ "name": "react-native-swappable-grid",
33
+ "version": "1.0.0",
34
+ "description": "A React Native component for creating draggable, swappable grid layouts with reordering, delete functionality, and smooth animations",
35
+ "keywords": [
36
+ "react-native",
37
+ "grid",
38
+ "draggable",
39
+ "swappable",
40
+ "reorder",
41
+ "drag-and-drop",
42
+ "gesture",
43
+ "layout"
44
+ ],
45
+ "author": "William Danielsson",
46
+ "license": "ISC",
47
+ "repository": {
48
+ "type": "git",
49
+ "url": "https://github.com/WilliamDanielsson/react-native-swappable-grid"
50
+ },
51
+ "type": "commonjs",
52
+ "react-native-builder-bob": {
53
+ "source": "src",
54
+ "output": "lib",
55
+ "targets": [
56
+ "commonjs",
57
+ "module",
58
+ "typescript"
59
+ ]
60
+ },
61
+ "eslintIgnore": [
62
+ "node_modules/",
63
+ "lib/"
64
+ ],
65
+ "dependencies": {
66
+ "prettier": "^3.7.4"
67
+ }
68
+ }
@@ -0,0 +1,376 @@
1
+ import React, { useEffect, useState } from "react";
2
+ import { Text, View, Pressable } from "react-native";
3
+ import Animated, {
4
+ Easing,
5
+ useAnimatedStyle,
6
+ useAnimatedReaction,
7
+ useSharedValue,
8
+ withRepeat,
9
+ withSequence,
10
+ withTiming,
11
+ SharedValue,
12
+ useDerivedValue,
13
+ cancelAnimation,
14
+ runOnJS,
15
+ } from "react-native-reanimated";
16
+
17
+ type Props = {
18
+ position: {
19
+ x: SharedValue<number>;
20
+ y: SharedValue<number>;
21
+ active: SharedValue<number>;
22
+ };
23
+ itemWidth: number;
24
+ itemHeight: number;
25
+ dragMode: SharedValue<boolean>;
26
+ anyItemInDeleteMode: SharedValue<boolean>;
27
+ children: React.ReactNode;
28
+ wiggle?: { duration: number; degrees: number };
29
+ dragSizeIncreaseFactor: number;
30
+ onDelete?: () => void;
31
+ disableHoldToDelete?: boolean; // If true, disable the hold-to-delete feature
32
+ };
33
+
34
+ export default function ChildWrapper({
35
+ position,
36
+ itemWidth,
37
+ itemHeight,
38
+ dragMode,
39
+ anyItemInDeleteMode,
40
+ children,
41
+ wiggle,
42
+ dragSizeIncreaseFactor,
43
+ onDelete,
44
+ disableHoldToDelete = false,
45
+ }: Props) {
46
+ const rotation = useSharedValue(0);
47
+ const currentWiggleMode = useSharedValue<"none" | "normal" | "delete">(
48
+ "none"
49
+ );
50
+ const previousDragMode = useSharedValue(false);
51
+
52
+ const showDelete = useSharedValue(false);
53
+ const deleteModeActive = useSharedValue(false); // Persistent delete mode state
54
+ const stillTimer = useSharedValue(0);
55
+ const lastX = useSharedValue(position.x.value);
56
+ const lastY = useSharedValue(position.y.value);
57
+ const frameCounter = useSharedValue(0);
58
+ const wasReleasedAfterDeleteMode = useSharedValue(false); // Track if item was released after entering delete mode
59
+
60
+ // Timer logic that runs every frame via useDerivedValue
61
+ useDerivedValue(() => {
62
+ "worklet";
63
+ frameCounter.value = frameCounter.value + 1;
64
+
65
+ // If hold-to-delete is disabled, skip all delete mode logic
66
+ if (disableHoldToDelete) {
67
+ deleteModeActive.value = false;
68
+ showDelete.value = false;
69
+ stillTimer.value = 0;
70
+ anyItemInDeleteMode.value = false;
71
+ return;
72
+ }
73
+
74
+ const isDragging = dragMode.value;
75
+ const isActive = position.active.value > 0.5;
76
+ const x = position.x.value;
77
+ const y = position.y.value;
78
+
79
+ // Track dragMode changes for detecting touches outside
80
+ const dragModeJustEnded = previousDragMode.value && !isDragging;
81
+ previousDragMode.value = isDragging;
82
+
83
+ // If delete mode is active, keep it active unless:
84
+ // 1. Another item becomes active (dragMode true but this item not active)
85
+ // 2. This item becomes active again AFTER it was released (user starts dragging it again)
86
+ // 3. User touches outside (dragMode becomes false and no item is active)
87
+ if (deleteModeActive.value) {
88
+ // Check if item was released (became inactive)
89
+ if (!isActive && !wasReleasedAfterDeleteMode.value) {
90
+ wasReleasedAfterDeleteMode.value = true;
91
+ }
92
+
93
+ if (isDragging && !isActive) {
94
+ // Another item is being dragged, exit delete mode
95
+ deleteModeActive.value = false;
96
+ anyItemInDeleteMode.value = false; // Clear global delete mode
97
+ showDelete.value = false;
98
+ stillTimer.value = 0;
99
+ wasReleasedAfterDeleteMode.value = false;
100
+ } else if (isActive && wasReleasedAfterDeleteMode.value) {
101
+ // This item became active again AFTER it was released, exit delete mode
102
+ deleteModeActive.value = false;
103
+ anyItemInDeleteMode.value = false; // Clear global delete mode
104
+ showDelete.value = false;
105
+ stillTimer.value = 0;
106
+ wasReleasedAfterDeleteMode.value = false;
107
+ } else if (!isDragging && !isActive) {
108
+ // Keep delete mode active (waiting for user interaction)
109
+ // The tap gesture handler in SwappableGrid will cancel it when user taps outside
110
+ showDelete.value = true;
111
+ } else {
112
+ // Keep delete mode active (item can still be held or released)
113
+ showDelete.value = true;
114
+ }
115
+ return;
116
+ }
117
+
118
+ // Reset release tracking when not in delete mode
119
+ wasReleasedAfterDeleteMode.value = false;
120
+
121
+ // If not in drag mode or not active, reset timer
122
+ if (!isDragging || !isActive) {
123
+ stillTimer.value = 0;
124
+ return;
125
+ }
126
+
127
+ // Item is active (being held down) - check if it's still
128
+ // Check if position has changed significantly (more than 10px threshold)
129
+ const moved =
130
+ Math.abs(x - lastX.value) > 10 || Math.abs(y - lastY.value) > 10;
131
+
132
+ if (moved) {
133
+ // Reset timer if item moved while being held
134
+ stillTimer.value = 0;
135
+ lastX.value = x;
136
+ lastY.value = y;
137
+ return;
138
+ }
139
+
140
+ // Initialize last position on first frame when active
141
+ if (stillTimer.value === 0) {
142
+ lastX.value = x;
143
+ lastY.value = y;
144
+ }
145
+
146
+ // If the tile hasn't moved significantly while being held → increment timer
147
+ // Increment by ~16ms per frame (assuming 60fps)
148
+ stillTimer.value += 16;
149
+
150
+ // Enter delete mode after 1 second (1000ms) of being held still
151
+ if (stillTimer.value >= 1000) {
152
+ deleteModeActive.value = true;
153
+ anyItemInDeleteMode.value = true; // Set global delete mode
154
+ showDelete.value = true;
155
+ wasReleasedAfterDeleteMode.value = false; // Reset on entry
156
+ }
157
+ });
158
+
159
+ const deleteButtonStyle = useAnimatedStyle(() => {
160
+ // Show delete button when delete mode is active (persists after release)
161
+ const shouldShow = showDelete.value;
162
+ return {
163
+ opacity: shouldShow ? 1 : 0,
164
+ pointerEvents: shouldShow ? "auto" : "none",
165
+ transform: [
166
+ { scale: withTiming(shouldShow ? 1 : 0.6, { duration: 120 }) },
167
+ ],
168
+ };
169
+ });
170
+
171
+ // Watch for when global delete mode is cancelled (user tapped outside)
172
+ useAnimatedReaction(
173
+ () => anyItemInDeleteMode.value,
174
+ (current, previous) => {
175
+ "worklet";
176
+ // If delete mode was cancelled globally (user tapped outside)
177
+ if (previous && !current && deleteModeActive.value) {
178
+ deleteModeActive.value = false;
179
+ showDelete.value = false;
180
+ stillTimer.value = 0;
181
+ wasReleasedAfterDeleteMode.value = false;
182
+ }
183
+ }
184
+ );
185
+
186
+ // Wiggle animation — triggers on editMode/active changes and delete mode
187
+ useAnimatedReaction(
188
+ () => ({
189
+ isEditMode: dragMode.value,
190
+ isActive: position.active.value > 0.5,
191
+ inDeleteMode: deleteModeActive.value,
192
+ anyInDeleteMode: anyItemInDeleteMode.value,
193
+ }),
194
+ ({ isEditMode, isActive, inDeleteMode, anyInDeleteMode }) => {
195
+ if (!wiggle) {
196
+ if (currentWiggleMode.value !== "none") {
197
+ cancelAnimation(rotation);
198
+ currentWiggleMode.value = "none";
199
+ }
200
+ rotation.value = withTiming(0, { duration: 150 });
201
+ return;
202
+ }
203
+
204
+ // Determine the target wiggle mode
205
+ let targetMode: "none" | "normal" | "delete" = "none";
206
+ if (inDeleteMode) {
207
+ targetMode = "delete";
208
+ } else if (anyInDeleteMode && !isActive) {
209
+ targetMode = "normal";
210
+ } else if (isEditMode && !isActive) {
211
+ targetMode = "normal";
212
+ }
213
+
214
+ // Only restart animation if mode changed
215
+ if (currentWiggleMode.value === targetMode) {
216
+ return; // Already in the correct mode, don't restart
217
+ }
218
+
219
+ const previousMode = currentWiggleMode.value;
220
+ currentWiggleMode.value = targetMode;
221
+
222
+ // Cancel current animation
223
+ cancelAnimation(rotation);
224
+
225
+ // If this item is in delete mode, wiggle more (2x degrees, faster)
226
+ if (targetMode === "delete") {
227
+ const deleteWiggleDegrees = wiggle.degrees * 2;
228
+ const deleteWiggleDuration = wiggle.duration * 0.7; // Faster wiggle
229
+
230
+ // If transitioning from normal wiggle, preserve the phase by scaling
231
+ if (previousMode === "normal") {
232
+ const currentRot = rotation.value;
233
+ const scaleFactor = deleteWiggleDegrees / wiggle.degrees;
234
+ rotation.value = currentRot * scaleFactor;
235
+ }
236
+
237
+ rotation.value = withRepeat(
238
+ withSequence(
239
+ withTiming(deleteWiggleDegrees, {
240
+ duration: deleteWiggleDuration,
241
+ easing: Easing.linear,
242
+ }),
243
+ withTiming(-deleteWiggleDegrees, {
244
+ duration: deleteWiggleDuration,
245
+ easing: Easing.linear,
246
+ })
247
+ ),
248
+ -1, // infinite
249
+ true
250
+ );
251
+ }
252
+ // Normal wiggle (when dragging but not this item, or any item in delete mode)
253
+ else if (targetMode === "normal") {
254
+ // If transitioning from delete wiggle, preserve the phase by scaling
255
+ if (previousMode === "delete") {
256
+ const currentRot = rotation.value;
257
+ const scaleFactor = wiggle.degrees / (wiggle.degrees * 2);
258
+ rotation.value = currentRot * scaleFactor;
259
+ }
260
+
261
+ rotation.value = withRepeat(
262
+ withSequence(
263
+ withTiming(wiggle.degrees, {
264
+ duration: wiggle.duration,
265
+ easing: Easing.linear,
266
+ }),
267
+ withTiming(-wiggle.degrees, {
268
+ duration: wiggle.duration,
269
+ easing: Easing.linear,
270
+ })
271
+ ),
272
+ -1, // infinite
273
+ true
274
+ );
275
+ }
276
+ // Stop wiggling
277
+ else {
278
+ rotation.value = withTiming(0, { duration: 150 });
279
+ }
280
+ },
281
+ [dragMode, position.active, deleteModeActive, anyItemInDeleteMode]
282
+ );
283
+
284
+ const animatedStyle = useAnimatedStyle(() => {
285
+ const scale = position.active.value
286
+ ? withTiming(dragSizeIncreaseFactor, { duration: 120 })
287
+ : withTiming(1, { duration: 120 });
288
+
289
+ return {
290
+ position: "absolute",
291
+ width: itemWidth,
292
+ height: itemHeight,
293
+ transform: [
294
+ { translateX: position.x.value as any },
295
+ { translateY: position.y.value as any },
296
+ { scale: scale as any },
297
+ { rotate: `${rotation.value}deg` as any },
298
+ ],
299
+ zIndex: position.active.value ? 2 : 0,
300
+ } as any;
301
+ });
302
+
303
+ // Track delete mode on JS thread for conditional rendering
304
+ const [isInDeleteMode, setIsInDeleteMode] = useState(false);
305
+
306
+ useAnimatedReaction(
307
+ () => deleteModeActive.value,
308
+ (current) => {
309
+ runOnJS(setIsInDeleteMode)(current);
310
+ }
311
+ );
312
+
313
+ const handleDelete = () => {
314
+ // Exit delete mode when delete button is pressed
315
+ deleteModeActive.value = false;
316
+ anyItemInDeleteMode.value = false; // Clear global delete mode
317
+ showDelete.value = false;
318
+ stillTimer.value = 0;
319
+ wasReleasedAfterDeleteMode.value = false;
320
+ if (onDelete) {
321
+ onDelete();
322
+ }
323
+ };
324
+
325
+ return (
326
+ <Animated.View style={animatedStyle} pointerEvents="box-none">
327
+ {/* Full-item Pressable for delete - only active when in delete mode */}
328
+ {isInDeleteMode && (
329
+ <Pressable
330
+ onPress={handleDelete}
331
+ style={{
332
+ position: "absolute",
333
+ top: 0,
334
+ left: 0,
335
+ right: 0,
336
+ bottom: 0,
337
+ width: itemWidth,
338
+ height: itemHeight,
339
+ zIndex: 2,
340
+ }}
341
+ />
342
+ )}
343
+
344
+ {/* Delete button (×) - visual indicator only */}
345
+ <Animated.View
346
+ style={[
347
+ {
348
+ position: "absolute",
349
+ top: itemHeight * 0.01,
350
+ right: itemWidth * 0.04,
351
+ width: itemWidth * 0.2,
352
+ height: itemHeight * 0.2,
353
+ borderRadius: 12,
354
+ justifyContent: "center",
355
+ alignItems: "center",
356
+ zIndex: 3,
357
+ },
358
+ deleteButtonStyle,
359
+ ]}
360
+ pointerEvents="none"
361
+ >
362
+ <Text
363
+ style={{
364
+ fontSize: itemWidth * 0.2,
365
+ color: "black",
366
+ fontWeight: 500,
367
+ }}
368
+ >
369
+ ×
370
+ </Text>
371
+ </Animated.View>
372
+
373
+ {children}
374
+ </Animated.View>
375
+ );
376
+ }