@souscheflabs/reanimated-flashlist 0.2.7 → 0.3.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.
@@ -1,7 +1,7 @@
1
- import React, { type ReactNode } from 'react';
2
- import { type SharedValue } from 'react-native-reanimated';
3
- import type { FlashListRef } from '@shopify/flash-list';
4
- import type { DragConfig } from '../types';
1
+ import React, { type ReactNode } from "react";
2
+ import { type SharedValue } from "react-native-reanimated";
3
+ import type { FlashListRef } from "@shopify/flash-list";
4
+ import type { DragConfig } from "../types";
5
5
  /**
6
6
  * Centralized drag state for coordinating animations across list items.
7
7
  * All animation values are Reanimated SharedValues for 60fps UI thread performance.
@@ -43,6 +43,8 @@ export interface DragStateContextValue {
43
43
  setListRef: (ref: FlashListRef<unknown> | null) => void;
44
44
  /** Scroll the list to a specific offset (for autoscroll during drag) */
45
45
  scrollToOffset: (offset: number, animated?: boolean) => void;
46
+ /** Ask FlashList to prepare cells for layout animation on the next render */
47
+ prepareForLayoutAnimationRender: () => void;
46
48
  /** Reset drag state after drop animation completes */
47
49
  resetDragState: () => void;
48
50
  /** Current drag configuration */
@@ -68,6 +70,16 @@ export interface DragStateContextValue {
68
70
  * Call this at the start of a drag operation.
69
71
  */
70
72
  snapshotRegistryForDrag: () => void;
73
+ /**
74
+ * Update registry after reorder to handle FlashList not re-rendering.
75
+ * Call this when reorder completes to keep indices accurate.
76
+ */
77
+ applyReorderToRegistry: (fromId: string, fromIndex: number, toIndex: number) => void;
78
+ /**
79
+ * Mark registry as recently updated to protect against stale overwrites.
80
+ * Call this from handleDragEnd after onEnd updates the registry.
81
+ */
82
+ markRegistryUpdated: () => void;
71
83
  }
72
84
  /**
73
85
  * Hook to access shared drag state from context.
@@ -1 +1 @@
1
- {"version":3,"file":"DragStateContext.d.ts","sourceRoot":"","sources":["../../src/contexts/DragStateContext.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,EAMZ,KAAK,SAAS,EACf,MAAM,OAAO,CAAC;AACf,OAAO,EAAkB,KAAK,WAAW,EAAE,MAAM,yBAAyB,CAAC;AAC3E,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,qBAAqB,CAAC;AACxD,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,UAAU,CAAC;AAG3C;;;;;;;;;GASG;AACH,MAAM,WAAW,qBAAqB;IACpC,2CAA2C;IAC3C,UAAU,EAAE,WAAW,CAAC,OAAO,CAAC,CAAC;IACjC,oEAAoE;IACpE,YAAY,EAAE,WAAW,CAAC,MAAM,CAAC,CAAC;IAClC,oFAAoF;IACpF,aAAa,EAAE,WAAW,CAAC,MAAM,CAAC,CAAC;IACnC,iFAAiF;IACjF,iBAAiB,EAAE,WAAW,CAAC,MAAM,CAAC,CAAC;IACvC,sEAAsE;IACtE,YAAY,EAAE,WAAW,CAAC,MAAM,CAAC,CAAC;IAClC,+DAA+D;IAC/D,YAAY,EAAE,WAAW,CAAC,MAAM,CAAC,CAAC;IAClC,uFAAuF;IACvF,qBAAqB,EAAE,WAAW,CAAC,MAAM,CAAC,CAAC;IAC3C,yEAAyE;IACzE,aAAa,EAAE,WAAW,CAAC,MAAM,CAAC,CAAC;IACnC,qDAAqD;IACrD,aAAa,EAAE,WAAW,CAAC,MAAM,CAAC,CAAC;IACnC,mFAAmF;IACnF,QAAQ,EAAE,WAAW,CAAC,MAAM,CAAC,CAAC;IAC9B,4EAA4E;IAC5E,kBAAkB,EAAE,WAAW,CAAC,MAAM,CAAC,CAAC;IACxC,yDAAyD;IACzD,UAAU,EAAE,WAAW,CAAC,OAAO,CAAC,CAAC;IACjC,gFAAgF;IAChF,YAAY,EAAE,WAAW,CAAC,MAAM,CAAC,CAAC;IAClC,2DAA2D;IAC3D,UAAU,EAAE,CAAC,GAAG,EAAE,YAAY,CAAC,OAAO,CAAC,GAAG,IAAI,KAAK,IAAI,CAAC;IACxD,wEAAwE;IACxE,cAAc,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,QAAQ,CAAC,EAAE,OAAO,KAAK,IAAI,CAAC;IAC7D,sDAAsD;IACtD,cAAc,EAAE,MAAM,IAAI,CAAC;IAC3B,iCAAiC;IACjC,MAAM,EAAE,UAAU,CAAC;IACnB;;;;OAIG;IACH,iBAAiB,EAAE,WAAW,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;IACvD,mEAAmE;IACnE,eAAe,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IACzD,4CAA4C;IAC5C,YAAY,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,MAAM,GAAG,SAAS,CAAC;IACrD;;;;OAIG;IACH,sBAAsB,EAAE,WAAW,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;IAC5D;;;OAGG;IACH,uBAAuB,EAAE,MAAM,IAAI,CAAC;CACrC;AAID;;;GAGG;AACH,eAAO,MAAM,YAAY,QAAO,qBAM/B,CAAC;AAEF,UAAU,sBAAsB;IAC9B,QAAQ,EAAE,SAAS,CAAC;IACpB,4CAA4C;IAC5C,MAAM,CAAC,EAAE,OAAO,CAAC,UAAU,CAAC,CAAC;CAC9B;AAmDD,eAAO,MAAM,iBAAiB,EAAE,KAAK,CAAC,EAAE,CAAC,sBAAsB,CA2J9D,CAAC"}
1
+ {"version":3,"file":"DragStateContext.d.ts","sourceRoot":"","sources":["../../src/contexts/DragStateContext.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,EAMZ,KAAK,SAAS,EACf,MAAM,OAAO,CAAC;AACf,OAAO,EAAkB,KAAK,WAAW,EAAE,MAAM,yBAAyB,CAAC;AAE3E,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,qBAAqB,CAAC;AACxD,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,UAAU,CAAC;AAoB3C;;;;;;;;;GASG;AACH,MAAM,WAAW,qBAAqB;IACpC,2CAA2C;IAC3C,UAAU,EAAE,WAAW,CAAC,OAAO,CAAC,CAAC;IACjC,oEAAoE;IACpE,YAAY,EAAE,WAAW,CAAC,MAAM,CAAC,CAAC;IAClC,oFAAoF;IACpF,aAAa,EAAE,WAAW,CAAC,MAAM,CAAC,CAAC;IACnC,iFAAiF;IACjF,iBAAiB,EAAE,WAAW,CAAC,MAAM,CAAC,CAAC;IACvC,sEAAsE;IACtE,YAAY,EAAE,WAAW,CAAC,MAAM,CAAC,CAAC;IAClC,+DAA+D;IAC/D,YAAY,EAAE,WAAW,CAAC,MAAM,CAAC,CAAC;IAClC,uFAAuF;IACvF,qBAAqB,EAAE,WAAW,CAAC,MAAM,CAAC,CAAC;IAC3C,yEAAyE;IACzE,aAAa,EAAE,WAAW,CAAC,MAAM,CAAC,CAAC;IACnC,qDAAqD;IACrD,aAAa,EAAE,WAAW,CAAC,MAAM,CAAC,CAAC;IACnC,mFAAmF;IACnF,QAAQ,EAAE,WAAW,CAAC,MAAM,CAAC,CAAC;IAC9B,4EAA4E;IAC5E,kBAAkB,EAAE,WAAW,CAAC,MAAM,CAAC,CAAC;IACxC,yDAAyD;IACzD,UAAU,EAAE,WAAW,CAAC,OAAO,CAAC,CAAC;IACjC,gFAAgF;IAChF,YAAY,EAAE,WAAW,CAAC,MAAM,CAAC,CAAC;IAClC,2DAA2D;IAC3D,UAAU,EAAE,CAAC,GAAG,EAAE,YAAY,CAAC,OAAO,CAAC,GAAG,IAAI,KAAK,IAAI,CAAC;IACxD,wEAAwE;IACxE,cAAc,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,QAAQ,CAAC,EAAE,OAAO,KAAK,IAAI,CAAC;IAC7D,6EAA6E;IAC7E,+BAA+B,EAAE,MAAM,IAAI,CAAC;IAC5C,sDAAsD;IACtD,cAAc,EAAE,MAAM,IAAI,CAAC;IAC3B,iCAAiC;IACjC,MAAM,EAAE,UAAU,CAAC;IACnB;;;;OAIG;IACH,iBAAiB,EAAE,WAAW,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;IACvD,mEAAmE;IACnE,eAAe,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IACzD,4CAA4C;IAC5C,YAAY,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,MAAM,GAAG,SAAS,CAAC;IACrD;;;;OAIG;IACH,sBAAsB,EAAE,WAAW,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;IAC5D;;;OAGG;IACH,uBAAuB,EAAE,MAAM,IAAI,CAAC;IACpC;;;OAGG;IACH,sBAAsB,EAAE,CACtB,MAAM,EAAE,MAAM,EACd,SAAS,EAAE,MAAM,EACjB,OAAO,EAAE,MAAM,KACZ,IAAI,CAAC;IACV;;;OAGG;IACH,mBAAmB,EAAE,MAAM,IAAI,CAAC;CACjC;AAID;;;GAGG;AACH,eAAO,MAAM,YAAY,QAAO,qBAM/B,CAAC;AAEF,UAAU,sBAAsB;IAC9B,QAAQ,EAAE,SAAS,CAAC;IACpB,4CAA4C;IAC5C,MAAM,CAAC,EAAE,OAAO,CAAC,UAAU,CAAC,CAAC;CAC9B;AAmDD,eAAO,MAAM,iBAAiB,EAAE,KAAK,CAAC,EAAE,CAAC,sBAAsB,CA+N9D,CAAC"}
@@ -36,7 +36,20 @@ Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.DragStateProvider = exports.useDragState = void 0;
37
37
  const react_1 = __importStar(require("react"));
38
38
  const react_native_reanimated_1 = require("react-native-reanimated");
39
+ const react_native_worklets_1 = require("react-native-worklets");
39
40
  const constants_1 = require("../constants");
41
+ /**
42
+ * Worklet that updates the item index registry on the UI thread.
43
+ * Using scheduleOnUI ensures the SharedValue modification is immediately
44
+ * visible to other worklets (like onStart in useDragGesture).
45
+ */
46
+ const updateRegistryOnUI = (registry, itemId, index) => {
47
+ "worklet";
48
+ const existingIndex = registry.value[itemId];
49
+ if (existingIndex === undefined || existingIndex !== index) {
50
+ registry.value = { ...registry.value, [itemId]: index };
51
+ }
52
+ };
40
53
  const DragStateContext = (0, react_1.createContext)(null);
41
54
  /**
42
55
  * Hook to access shared drag state from context.
@@ -45,7 +58,7 @@ const DragStateContext = (0, react_1.createContext)(null);
45
58
  const useDragState = () => {
46
59
  const context = (0, react_1.useContext)(DragStateContext);
47
60
  if (!context) {
48
- throw new Error('useDragState must be used within DragStateProvider');
61
+ throw new Error("useDragState must be used within DragStateProvider");
49
62
  }
50
63
  return context;
51
64
  };
@@ -67,19 +80,19 @@ exports.useDragState = useDragState;
67
80
  function validateConfig(config) {
68
81
  if (__DEV__) {
69
82
  if (config.itemHeight <= 0) {
70
- console.warn('[AnimatedFlashList] Invalid itemHeight: must be positive. Got:', config.itemHeight);
83
+ console.warn("[AnimatedFlashList] Invalid itemHeight: must be positive. Got:", config.itemHeight);
71
84
  }
72
85
  if (config.longPressDuration <= 0) {
73
- console.warn('[AnimatedFlashList] Invalid longPressDuration: must be positive. Got:', config.longPressDuration);
86
+ console.warn("[AnimatedFlashList] Invalid longPressDuration: must be positive. Got:", config.longPressDuration);
74
87
  }
75
88
  if (config.dragScale <= 0) {
76
- console.warn('[AnimatedFlashList] Invalid dragScale: must be positive. Got:', config.dragScale);
89
+ console.warn("[AnimatedFlashList] Invalid dragScale: must be positive. Got:", config.dragScale);
77
90
  }
78
91
  if (config.edgeThreshold < 0) {
79
- console.warn('[AnimatedFlashList] Invalid edgeThreshold: must be non-negative. Got:', config.edgeThreshold);
92
+ console.warn("[AnimatedFlashList] Invalid edgeThreshold: must be non-negative. Got:", config.edgeThreshold);
80
93
  }
81
94
  if (config.maxScrollSpeed <= 0) {
82
- console.warn('[AnimatedFlashList] Invalid maxScrollSpeed: must be positive. Got:', config.maxScrollSpeed);
95
+ console.warn("[AnimatedFlashList] Invalid maxScrollSpeed: must be positive. Got:", config.maxScrollSpeed);
83
96
  }
84
97
  }
85
98
  }
@@ -93,7 +106,7 @@ const DragStateProvider = ({ children, config: configOverrides, }) => {
93
106
  // Shared values are created once and persist for the lifetime of the provider
94
107
  const isDragging = (0, react_native_reanimated_1.useSharedValue)(false);
95
108
  const draggedIndex = (0, react_native_reanimated_1.useSharedValue)(-1);
96
- const draggedItemId = (0, react_native_reanimated_1.useSharedValue)('');
109
+ const draggedItemId = (0, react_native_reanimated_1.useSharedValue)("");
97
110
  const currentTranslateY = (0, react_native_reanimated_1.useSharedValue)(0);
98
111
  const draggedScale = (0, react_native_reanimated_1.useSharedValue)(1);
99
112
  // Scroll state for viewport-aware calculations and autoscroll
@@ -114,6 +127,8 @@ const DragStateProvider = ({ children, config: configOverrides, }) => {
114
127
  const itemIndexRegistry = (0, react_native_reanimated_1.useSharedValue)({});
115
128
  // Snapshot of registry taken at drag start for consistent index lookup during drag
116
129
  const dragStartIndexSnapshot = (0, react_native_reanimated_1.useSharedValue)({});
130
+ // Timestamp when registry was last updated by onEnd (for protecting against stale overwrites)
131
+ const registryUpdateTimestamp = (0, react_1.useRef)(0);
117
132
  // Ref to FlashList for autoscroll operations
118
133
  const listRef = (0, react_1.useRef)(null);
119
134
  // Register the FlashList ref
@@ -124,17 +139,39 @@ const DragStateProvider = ({ children, config: configOverrides, }) => {
124
139
  const scrollToOffset = (0, react_1.useCallback)((offset, animated = false) => {
125
140
  listRef.current?.scrollToOffset({ offset, animated });
126
141
  }, []);
142
+ // Request FlashList to prepare cells for layout animation on next render
143
+ const prepareForLayoutAnimationRender = (0, react_1.useCallback)(() => {
144
+ listRef.current?.prepareForLayoutAnimationRender?.();
145
+ }, []);
127
146
  // Update an item's index in the registry
128
- // Called on every item render to keep indices fresh
147
+ // Uses scheduleOnUI to ensure SharedValue modifications are immediately visible
148
+ // to UI thread worklets (fixing the registry sync bug).
149
+ // Also uses timestamp-based guard to prevent stale React props from overwriting
150
+ // correct indices set by onEnd. We protect updates for 500ms after onEnd runs.
129
151
  const updateItemIndex = (0, react_1.useCallback)((itemId, index) => {
130
- // Only update if the value has changed to avoid unnecessary SharedValue updates
131
- if (itemIndexRegistry.value[itemId] !== index) {
132
- itemIndexRegistry.value = {
133
- ...itemIndexRegistry.value,
134
- [itemId]: index,
135
- };
152
+ // Read current value (may be slightly stale from JS thread, but OK for guard logic)
153
+ const existingIndex = itemIndexRegistry.value[itemId];
154
+ const timeSinceUpdate = Date.now() - registryUpdateTimestamp.current;
155
+ const isProtected = timeSinceUpdate < 500; // 500ms protection window
156
+ // Add new items always - schedule on UI thread for immediate visibility
157
+ if (existingIndex === undefined) {
158
+ (0, react_native_worklets_1.scheduleOnUI)(updateRegistryOnUI, itemIndexRegistry, itemId, index);
159
+ return;
160
+ }
161
+ // Skip update if within protection window (onEnd just updated the registry)
162
+ // This prevents stale React props from overwriting correct indices
163
+ if (isProtected) {
164
+ return;
165
+ }
166
+ // Outside protection window, update if value changed - schedule on UI thread
167
+ if (existingIndex !== index) {
168
+ (0, react_native_worklets_1.scheduleOnUI)(updateRegistryOnUI, itemIndexRegistry, itemId, index);
136
169
  }
137
170
  }, [itemIndexRegistry]);
171
+ // Mark registry as recently updated (call this from handleDragEnd)
172
+ const markRegistryUpdated = (0, react_1.useCallback)(() => {
173
+ registryUpdateTimestamp.current = Date.now();
174
+ }, []);
138
175
  // Get an item's index from the registry
139
176
  const getItemIndex = (0, react_1.useCallback)((itemId) => {
140
177
  return itemIndexRegistry.value[itemId];
@@ -145,11 +182,39 @@ const DragStateProvider = ({ children, config: configOverrides, }) => {
145
182
  const snapshotRegistryForDrag = (0, react_1.useCallback)(() => {
146
183
  dragStartIndexSnapshot.value = { ...itemIndexRegistry.value };
147
184
  }, [dragStartIndexSnapshot, itemIndexRegistry]);
185
+ // Update registry after reorder to handle FlashList not re-rendering
186
+ const applyReorderToRegistry = (0, react_1.useCallback)((fromId, fromIndex, toIndex) => {
187
+ const registry = itemIndexRegistry.value;
188
+ const newRegistry = { ...registry };
189
+ if (toIndex > fromIndex) {
190
+ // Moving down: items between from and to shift UP (index decreases)
191
+ for (const [id, idx] of Object.entries(registry)) {
192
+ if (idx > fromIndex && idx <= toIndex) {
193
+ newRegistry[id] = idx - 1;
194
+ }
195
+ }
196
+ }
197
+ else if (toIndex < fromIndex) {
198
+ // Moving up: items between to and from shift DOWN (index increases)
199
+ for (const [id, idx] of Object.entries(registry)) {
200
+ if (idx >= toIndex && idx < fromIndex) {
201
+ newRegistry[id] = idx + 1;
202
+ }
203
+ }
204
+ }
205
+ newRegistry[fromId] = toIndex;
206
+ itemIndexRegistry.value = newRegistry;
207
+ }, [itemIndexRegistry]);
148
208
  // Reset drag state after drop
209
+ // Note: We intentionally do NOT clear itemIndexRegistry here.
210
+ // The registry persists across drags and is updated by:
211
+ // 1. applyReorderToRegistry() in useDragGesture.onEnd after each reorder
212
+ // 2. updateItemIndex() when items render
213
+ // Clearing it would cause subsequent drags to fall back to stale React props.
149
214
  const resetDragState = (0, react_1.useCallback)(() => {
150
215
  isDragging.value = false;
151
216
  draggedIndex.value = -1;
152
- draggedItemId.value = '';
217
+ draggedItemId.value = "";
153
218
  currentTranslateY.value = 0;
154
219
  draggedScale.value = 1;
155
220
  dragStartScrollOffset.value = 0;
@@ -174,6 +239,7 @@ const DragStateProvider = ({ children, config: configOverrides, }) => {
174
239
  dragSequence,
175
240
  setListRef,
176
241
  scrollToOffset,
242
+ prepareForLayoutAnimationRender,
177
243
  resetDragState,
178
244
  config,
179
245
  itemIndexRegistry,
@@ -181,6 +247,8 @@ const DragStateProvider = ({ children, config: configOverrides, }) => {
181
247
  getItemIndex,
182
248
  dragStartIndexSnapshot,
183
249
  snapshotRegistryForDrag,
250
+ applyReorderToRegistry,
251
+ markRegistryUpdated,
184
252
  }), [
185
253
  isDragging,
186
254
  draggedIndex,
@@ -204,6 +272,8 @@ const DragStateProvider = ({ children, config: configOverrides, }) => {
204
272
  getItemIndex,
205
273
  dragStartIndexSnapshot,
206
274
  snapshotRegistryForDrag,
275
+ applyReorderToRegistry,
276
+ markRegistryUpdated,
207
277
  ]);
208
278
  return (<DragStateContext.Provider value={value}>
209
279
  {children}
@@ -1,5 +1,5 @@
1
- import type { SharedValue } from 'react-native-reanimated';
2
- import type { UseDragAnimatedStyleResult } from '../../types';
1
+ import type { SharedValue } from "react-native-reanimated";
2
+ import type { UseDragAnimatedStyleResult } from "../../types";
3
3
  /**
4
4
  * Hook that creates animated styles for drag operations.
5
5
  *
@@ -1 +1 @@
1
- {"version":3,"file":"useDragAnimatedStyle.d.ts","sourceRoot":"","sources":["../../../src/hooks/drag/useDragAnimatedStyle.ts"],"names":[],"mappings":"AAOA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,yBAAyB,CAAC;AAI3D,OAAO,KAAK,EAAE,0BAA0B,EAAE,MAAM,aAAa,CAAC;AAE9D;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AACH,wBAAgB,oBAAoB,CAClC,MAAM,EAAE,MAAM,EACd,UAAU,EAAE,WAAW,CAAC,OAAO,CAAC,EAChC,UAAU,EAAE,WAAW,CAAC,MAAM,CAAC,EAC/B,MAAM,EAAE,WAAW,CAAC,MAAM,CAAC,GAC1B,0BAA0B,CAsF5B"}
1
+ {"version":3,"file":"useDragAnimatedStyle.d.ts","sourceRoot":"","sources":["../../../src/hooks/drag/useDragAnimatedStyle.ts"],"names":[],"mappings":"AAOA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,yBAAyB,CAAC;AAI3D,OAAO,KAAK,EAAE,0BAA0B,EAAE,MAAM,aAAa,CAAC;AAE9D;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AACH,wBAAgB,oBAAoB,CAClC,MAAM,EAAE,MAAM,EACd,UAAU,EAAE,WAAW,CAAC,OAAO,CAAC,EAChC,UAAU,EAAE,WAAW,CAAC,MAAM,CAAC,EAC/B,MAAM,EAAE,WAAW,CAAC,MAAM,CAAC,GAC1B,0BAA0B,CAmF5B"}
@@ -35,7 +35,7 @@ const constants_1 = require("../../constants");
35
35
  */
36
36
  function useDragAnimatedStyle(itemId, isDragging, translateY, shiftY) {
37
37
  // Global drag state for scale, identity check, and scroll compensation
38
- const { draggedItemId, draggedScale, config, scrollOffset, dragStartScrollOffset } = (0, DragStateContext_1.useDragState)();
38
+ const { draggedItemId, draggedScale, config, scrollOffset, dragStartScrollOffset, } = (0, DragStateContext_1.useDragState)();
39
39
  // Issue 2 Fix: Track shadow opacity with smooth transitions to prevent "flash"
40
40
  // When drag ends, shadow opacity should fade smoothly instead of snapping
41
41
  const animatedShadowOpacity = (0, react_native_reanimated_1.useSharedValue)(0.1);
@@ -44,7 +44,7 @@ function useDragAnimatedStyle(itemId, isDragging, translateY, shiftY) {
44
44
  isThisItemDragged: draggedItemId.value === itemId,
45
45
  scale: draggedScale.value,
46
46
  }), (current, previous) => {
47
- 'worklet';
47
+ "worklet";
48
48
  if (current.isThisItemDragged) {
49
49
  // When dragged, interpolate shadow opacity based on scale
50
50
  const targetOpacity = (0, react_native_reanimated_1.interpolate)(current.scale, [1, config.dragScale], [0.1, config.dragShadowOpacity]);
@@ -67,14 +67,9 @@ function useDragAnimatedStyle(itemId, isDragging, translateY, shiftY) {
67
67
  const hasSignificantTranslateY = Math.abs(translateY.value) > constants_1.DRAG_THRESHOLDS.SHIFT_SIGNIFICANCE_THRESHOLD;
68
68
  // Calculate scroll delta for position compensation during autoscroll
69
69
  const scrollDelta = scrollOffset.value - dragStartScrollOffset.value;
70
- // Determine when to use translateY for positioning:
71
- // 1. Actively being dragged (isThisItemDragged && isDragging)
72
- // 2. Animating/settling after drop (hasSignificantTranslateY)
73
- //
74
- // Case 2 handles the FALLBACK scenario where FlashList doesn't re-render
75
- // after reorder. The item uses translateY to stay at its new position
76
- // until FlashList eventually re-renders and useDropCompensation runs.
77
- const shouldUseDragOffset = (isThisItemDragged && isDragging.value) || hasSignificantTranslateY;
70
+ // Use translateY only while actively dragging. Once drop begins we rely on
71
+ // the new base layout (shiftY/FlashList) to avoid any post-drop bounce.
72
+ const shouldUseDragOffset = isThisItemDragged && isDragging.value;
78
73
  // Use drag translateY + scroll compensation for dragged/settling item,
79
74
  // shiftY for others or for items at rest
80
75
  const yOffset = shouldUseDragOffset
@@ -83,11 +78,8 @@ function useDragAnimatedStyle(itemId, isDragging, translateY, shiftY) {
83
78
  // Only apply visual effects (scale, elevation, zIndex) when ACTIVELY dragging
84
79
  // Not when just settling at new position after drop
85
80
  const isActivelyDragging = isThisItemDragged && isDragging.value;
86
- // Keep elevated zIndex when actively dragging OR when settling at new position
87
- // This prevents the dropped item from appearing behind other items
88
- // Note: We use hasSignificantTranslateY alone (not isThisItemDragged) because
89
- // after FALLBACK completes, draggedItemId is reset to '' but translateY remains
90
- const shouldBeElevated = isActivelyDragging || hasSignificantTranslateY;
81
+ // Keep elevated zIndex only while actively dragging
82
+ const shouldBeElevated = isActivelyDragging;
91
83
  return {
92
84
  transform: [
93
85
  { translateY: yOffset },
@@ -1,4 +1,4 @@
1
- import type { UseDragGestureConfig, UseDragGestureCallbacks, UseDragGestureResult } from '../../types';
1
+ import type { UseDragGestureConfig, UseDragGestureCallbacks, UseDragGestureResult } from "../../types";
2
2
  /**
3
3
  * Hook that encapsulates all drag gesture logic.
4
4
  *
@@ -1 +1 @@
1
- {"version":3,"file":"useDragGesture.d.ts","sourceRoot":"","sources":["../../../src/hooks/drag/useDragGesture.ts"],"names":[],"mappings":"AAeA,OAAO,KAAK,EACV,oBAAoB,EACpB,uBAAuB,EACvB,oBAAoB,EACrB,MAAM,aAAa,CAAC;AAErB;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH,wBAAgB,cAAc,CAC5B,MAAM,EAAE,oBAAoB,EAC5B,SAAS,EAAE,uBAAuB,GACjC,oBAAoB,CA8WtB"}
1
+ {"version":3,"file":"useDragGesture.d.ts","sourceRoot":"","sources":["../../../src/hooks/drag/useDragGesture.ts"],"names":[],"mappings":"AAqBA,OAAO,KAAK,EACV,oBAAoB,EACpB,uBAAuB,EACvB,oBAAoB,EACrB,MAAM,aAAa,CAAC;AAErB;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH,wBAAgB,cAAc,CAC5B,MAAM,EAAE,oBAAoB,EAC5B,SAAS,EAAE,uBAAuB,GACjC,oBAAoB,CA2atB"}
@@ -41,7 +41,7 @@ function useDragGesture(config, callbacks) {
41
41
  const isDragging = (0, react_native_reanimated_1.useSharedValue)(false);
42
42
  const translateY = (0, react_native_reanimated_1.useSharedValue)(0);
43
43
  // Global drag state for coordinating animations across all items
44
- const { isDragging: globalIsDragging, draggedIndex, draggedItemId, currentTranslateY, draggedScale, scrollOffset, dragStartScrollOffset, contentHeight, visibleHeight, listTopY, measuredItemHeight, isDropping, scrollToOffset, config: dragConfig, itemIndexRegistry, snapshotRegistryForDrag, dragSequence, } = (0, DragStateContext_1.useDragState)();
44
+ const { isDragging: globalIsDragging, draggedIndex, draggedItemId, currentTranslateY, draggedScale, scrollOffset, dragStartScrollOffset, contentHeight, visibleHeight, listTopY, measuredItemHeight, isDropping, scrollToOffset, prepareForLayoutAnimationRender, config: dragConfig, itemIndexRegistry, dragStartIndexSnapshot, dragSequence, markRegistryUpdated, } = (0, DragStateContext_1.useDragState)();
45
45
  // Performance optimization: Track last significant Y position and scroll time
46
46
  // to avoid updating on every pixel movement
47
47
  const lastSignificantY = (0, react_native_reanimated_1.useSharedValue)(0);
@@ -66,7 +66,15 @@ function useDragGesture(config, callbacks) {
66
66
  currentIndex.value = index;
67
67
  setPrevItemId(itemId);
68
68
  }
69
- }, [itemId, prevItemId, setPrevItemId, translateY, isDragging, currentIndex, index]);
69
+ }, [
70
+ itemId,
71
+ prevItemId,
72
+ setPrevItemId,
73
+ translateY,
74
+ isDragging,
75
+ currentIndex,
76
+ index,
77
+ ]);
70
78
  // Sync index to SharedValue in useEffect (not during render per Reanimated docs)
71
79
  (0, react_1.useEffect)(() => {
72
80
  currentIndex.value = index;
@@ -93,8 +101,11 @@ function useDragGesture(config, callbacks) {
93
101
  // Drag was cancelled or reset
94
102
  return;
95
103
  }
96
- // Use configured itemHeight for consistent position calculations
97
- const itemHeight = dragConfig.itemHeight;
104
+ // Use measured item height for accurate position calculations
105
+ // Fall back to config height if measurement failed or not yet available
106
+ const itemHeight = measuredItemHeight.value > 0
107
+ ? measuredItemHeight.value
108
+ : dragConfig.itemHeight;
98
109
  // Calculate how many positions to move based on drag offset
99
110
  const positionDelta = Math.round(finalTranslateY / itemHeight);
100
111
  // Calculate if position actually changes
@@ -106,73 +117,47 @@ function useDragGesture(config, callbacks) {
106
117
  // but we also animate here as a fallback since FlashList's virtualization
107
118
  // may not trigger re-renders when data changes.
108
119
  isDropping.value = true;
109
- onHapticFeedback?.('medium');
120
+ onHapticFeedback?.("medium");
121
+ // Ask FlashList to prepare cells for layout animation on next render
122
+ prepareForLayoutAnimationRender();
110
123
  reorder(currentItemId, positionDelta);
111
- // Note: We no longer call applyReorderToRegistry here.
112
- // The snapshot-based approach means shift calculations use the frozen
113
- // snapshot taken at drag start. After drag ends, React re-renders
114
- // will naturally update the registry via updateItemIndex calls.
124
+ // Mark registry as recently updated to protect against stale React prop overwrites
125
+ // The onEnd callback already updated the registry on the UI thread - this timestamp
126
+ // prevents items re-rendering with stale indices from corrupting those updates.
127
+ markRegistryUpdated();
128
+ // Note: Registry is now updated in onEnd on UI thread to prevent race conditions
129
+ // where a new drag starts before this JS callback runs.
115
130
  // Reset currentTranslateY to stop shift calculations for other items
131
+ // This triggers all shifted items to animate their shiftY back to 0
116
132
  currentTranslateY.value = 0;
117
- // Trigger a micro-scroll to force FlashList to refresh its views
118
- // This helps FlashList notice the data change and re-render
119
- const currentOffset = scrollOffset.value;
120
- (0, react_native_worklets_1.scheduleOnRN)(scrollToOffset, currentOffset + 1, false);
121
- // Scroll back after a short delay
122
- setTimeout(() => {
123
- scrollToOffset(currentOffset, false);
124
- }, 16);
125
- // Fallback: If useDropCompensation doesn't run (no re-render from FlashList),
126
- // we need to animate the item to its new position and reset state.
127
- //
128
- // Since FlashList hasn't re-rendered, the item's base position is still at the
129
- // old index. To make it appear at the new index, we calculate the target translateY
130
- // that positions the item correctly.
131
- //
132
- // When FlashList eventually re-renders (on scroll or interaction), useDropCompensation
133
- // will see the index change and animate translateY back to 0.
134
- (0, react_native_reanimated_1.cancelAnimation)(translateY);
135
- // Calculate the target translateY to position item at its new index
136
- // Since FlashList hasn't updated base position, we need:
137
- // targetTranslateY = (newIndex - currentIndex) * itemHeight
138
- const targetTranslateY = (newIndex - currentIndex) * itemHeight;
139
- // Use a delayed animation to give useDropCompensation a chance to run first
140
- translateY.value = (0, react_native_reanimated_1.withDelay)(150, // Delay to let React re-render and useDropCompensation run first
141
- (0, react_native_reanimated_1.withTiming)(targetTranslateY, // Animate to the new position
142
- { duration: 150, easing: react_native_reanimated_1.Easing.out(react_native_reanimated_1.Easing.ease) }, finished => {
143
- 'worklet';
144
- // Only reset state if still in dropping state (useDropCompensation didn't handle it)
145
- if (finished && isDropping.value) {
146
- // Reset all drag state - the item is now visually at its new position
147
- // via translateY offset. When FlashList re-renders, useDropCompensation
148
- // will compensate and animate to 0.
149
- globalIsDragging.value = false;
150
- isDropping.value = false;
151
- draggedIndex.value = -1;
152
- draggedItemId.value = '';
153
- measuredItemHeight.value = 0;
154
- dragStartScrollOffset.value = 0;
155
- // FlashList doesn't re-render visible items after data changes.
156
- // Since useDropCompensation won't run, we need to animate translateY
157
- // back to 0 ourselves. This causes a visual "snap" but prevents the
158
- // item from being stuck floating forever.
159
- translateY.value = (0, react_native_reanimated_1.withTiming)(0, {
160
- duration: 200,
161
- easing: react_native_reanimated_1.Easing.out(react_native_reanimated_1.Easing.ease),
162
- });
163
- }
164
- }));
133
+ // CRITICAL: Store the current translateY value before any animations start
134
+ // This represents where the item visually is at the moment of drop
135
+ const dropTranslateY = translateY.value;
136
+ // Freeze the visual position at the drop point; do not animate toward 0 here
137
+ // because FlashList has not yet updated base layouts. Animating now would
138
+ // create a visible bounce (the issue you are seeing).
139
+ translateY.value = dropTranslateY;
140
+ console.log("[DROP] Captured translateY at drop:", {
141
+ dropTranslateY,
142
+ currentIndex,
143
+ newIndex,
144
+ });
165
145
  }
166
146
  else {
167
147
  // Same position - animate back and reset state
148
+ // Capture current sequence to guard callback against race conditions
149
+ const currentSequence = dragSequence.value;
168
150
  // Cancel any existing animation before starting the return animation
169
151
  (0, react_native_reanimated_1.cancelAnimation)(translateY);
170
- translateY.value = (0, react_native_reanimated_1.withTiming)(0, { duration: 150, easing: react_native_reanimated_1.Easing.out(react_native_reanimated_1.Easing.ease) }, finished => {
171
- 'worklet';
152
+ translateY.value = (0, react_native_reanimated_1.withTiming)(0, { duration: 150, easing: react_native_reanimated_1.Easing.out(react_native_reanimated_1.Easing.ease) }, (finished) => {
153
+ "worklet";
154
+ // Guard: Only reset if this is still the same drag operation
155
+ if (dragSequence.value !== currentSequence)
156
+ return;
172
157
  if (finished) {
173
158
  globalIsDragging.value = false;
174
159
  draggedIndex.value = -1;
175
- draggedItemId.value = '';
160
+ draggedItemId.value = "";
176
161
  currentTranslateY.value = 0;
177
162
  dragStartScrollOffset.value = 0;
178
163
  measuredItemHeight.value = 0;
@@ -190,17 +175,19 @@ function useDragGesture(config, callbacks) {
190
175
  dragStartScrollOffset,
191
176
  isDropping,
192
177
  onHapticFeedback,
178
+ dragSequence,
179
+ markRegistryUpdated,
193
180
  ]);
194
181
  // Stable haptic callback for drag start
195
182
  const triggerLightHaptic = (0, react_1.useCallback)(() => {
196
- onHapticFeedback?.('light');
183
+ onHapticFeedback?.("light");
197
184
  }, [onHapticFeedback]);
198
185
  // Pan gesture for drag-to-reorder
199
186
  const panGesture = (0, react_1.useMemo)(() => react_native_gesture_handler_1.Gesture.Pan()
200
187
  .activateAfterLongPress(dragConfig.longPressDuration)
201
188
  .enabled(enabled)
202
189
  .onStart(() => {
203
- 'worklet';
190
+ "worklet";
204
191
  // Reset any pending drop state from previous drag
205
192
  // This is critical: if a new drag starts before the previous drop's
206
193
  // fallback completed, isDropping would still be true, causing shift
@@ -208,6 +195,17 @@ function useDragGesture(config, callbacks) {
208
195
  if (isDropping.value) {
209
196
  isDropping.value = false;
210
197
  }
198
+ // Cancel any in-flight animations from previous drag to prevent
199
+ // their callbacks from corrupting our state
200
+ (0, react_native_reanimated_1.cancelAnimation)(translateY);
201
+ (0, react_native_reanimated_1.cancelAnimation)(draggedScale);
202
+ // Reset translateY to prevent inheriting values from previous drag
203
+ // This is critical when the same item is dragged consecutively or
204
+ // when FlashList recycled a view that had a non-zero translateY
205
+ translateY.value = 0;
206
+ // Take snapshot SYNCHRONOUSLY in worklet for consistent index lookup
207
+ // This must happen before any shift calculations read the snapshot
208
+ dragStartIndexSnapshot.value = { ...itemIndexRegistry.value };
211
209
  // Increment drag sequence to force all items to recalculate their shifts
212
210
  // This handles the case where items had non-zero shiftY from a previous
213
211
  // drag that didn't complete its animations
@@ -240,8 +238,8 @@ function useDragGesture(config, callbacks) {
240
238
  });
241
239
  (0, react_native_worklets_1.scheduleOnRN)(triggerLightHaptic);
242
240
  })
243
- .onUpdate(event => {
244
- 'worklet';
241
+ .onUpdate((event) => {
242
+ "worklet";
245
243
  // Always update translateY for smooth visual feedback
246
244
  translateY.value = event.translationY;
247
245
  // Performance optimization: Only update currentTranslateY (which triggers
@@ -261,7 +259,7 @@ function useDragGesture(config, callbacks) {
261
259
  const topEdge = dragConfig.edgeThreshold;
262
260
  const bottomEdge = visibleHeight.value - dragConfig.edgeThreshold;
263
261
  if (fingerInList < topEdge && scrollOffset.value > 0) {
264
- const speed = (0, react_native_reanimated_1.interpolate)(fingerInList, [0, topEdge], [dragConfig.maxScrollSpeed, 0], 'clamp');
262
+ const speed = (0, react_native_reanimated_1.interpolate)(fingerInList, [0, topEdge], [dragConfig.maxScrollSpeed, 0], "clamp");
265
263
  const newOffset = Math.max(0, scrollOffset.value - speed);
266
264
  scrollOffset.value = newOffset;
267
265
  // Throttle actual scroll calls to reduce JS thread pressure
@@ -273,7 +271,7 @@ function useDragGesture(config, callbacks) {
273
271
  else if (fingerInList > bottomEdge) {
274
272
  const maxOffset = Math.max(0, contentHeight.value - visibleHeight.value);
275
273
  if (scrollOffset.value < maxOffset) {
276
- const speed = (0, react_native_reanimated_1.interpolate)(fingerInList, [bottomEdge, visibleHeight.value], [0, dragConfig.maxScrollSpeed], 'clamp');
274
+ const speed = (0, react_native_reanimated_1.interpolate)(fingerInList, [bottomEdge, visibleHeight.value], [0, dragConfig.maxScrollSpeed], "clamp");
277
275
  const newOffset = Math.min(maxOffset, scrollOffset.value + speed);
278
276
  scrollOffset.value = newOffset;
279
277
  // Throttle actual scroll calls to reduce JS thread pressure
@@ -284,24 +282,72 @@ function useDragGesture(config, callbacks) {
284
282
  }
285
283
  }
286
284
  })
287
- .onEnd(event => {
288
- 'worklet';
285
+ .onEnd((event) => {
286
+ "worklet";
289
287
  isDragging.value = false;
290
288
  // Include scroll compensation in final position calculation
291
289
  const scrollDelta = scrollOffset.value - dragStartScrollOffset.value;
292
290
  const finalY = event.translationY + scrollDelta;
293
291
  draggedScale.value = (0, react_native_reanimated_1.withSpring)(1, { damping: 15, stiffness: 400 });
292
+ // CRITICAL: Update registry on UI thread BEFORE scheduling JS callback
293
+ // This prevents race conditions where a new drag starts before handleDragEnd
294
+ // runs on the JS thread, which would cause the new drag's snapshot to have
295
+ // stale indices.
296
+ const currentIdx = draggedIndex.value;
297
+ const total = dragContextRef.current.totalItems;
298
+ // Use measured item height for accurate position calculations
299
+ // Fall back to config height if measurement failed or not yet available
300
+ const itemHeight = measuredItemHeight.value > 0
301
+ ? measuredItemHeight.value
302
+ : dragConfig.itemHeight;
303
+ const positionDelta = Math.round(finalY / itemHeight);
304
+ const newIdx = Math.max(0, Math.min(total - 1, currentIdx + positionDelta));
305
+ if (positionDelta !== 0 && newIdx !== currentIdx) {
306
+ // Mark drop phase immediately on UI thread to prevent non-dragged items
307
+ // from briefly animating back to 0 before the JS drop handler runs.
308
+ isDropping.value = true;
309
+ // Update registry directly on UI thread
310
+ const registry = itemIndexRegistry.value;
311
+ const newRegistry = {};
312
+ // Copy existing entries and adjust indices
313
+ for (const id of Object.keys(registry)) {
314
+ const idx = registry[id];
315
+ if (idx === undefined)
316
+ continue;
317
+ if (newIdx > currentIdx) {
318
+ // Moving down: items between currentIdx and newIdx shift UP
319
+ if (idx > currentIdx && idx <= newIdx) {
320
+ newRegistry[id] = idx - 1;
321
+ }
322
+ else {
323
+ newRegistry[id] = idx;
324
+ }
325
+ }
326
+ else {
327
+ // Moving up: items between newIdx and currentIdx shift DOWN
328
+ if (idx >= newIdx && idx < currentIdx) {
329
+ newRegistry[id] = idx + 1;
330
+ }
331
+ else {
332
+ newRegistry[id] = idx;
333
+ }
334
+ }
335
+ }
336
+ // Set the dragged item's new index
337
+ newRegistry[draggedItemId.value] = newIdx;
338
+ itemIndexRegistry.value = newRegistry;
339
+ }
294
340
  (0, react_native_worklets_1.scheduleOnRN)(handleDragEnd, finalY);
295
341
  })
296
342
  .onFinalize((_event, success) => {
297
- 'worklet';
343
+ "worklet";
298
344
  if (!success) {
299
345
  isDragging.value = false;
300
346
  translateY.value = (0, react_native_reanimated_1.withTiming)(0, { duration: 150 });
301
347
  isDropping.value = false;
302
348
  globalIsDragging.value = false;
303
349
  draggedIndex.value = -1;
304
- draggedItemId.value = '';
350
+ draggedItemId.value = "";
305
351
  currentTranslateY.value = 0;
306
352
  draggedScale.value = 1;
307
353
  dragStartScrollOffset.value = 0;
@@ -309,6 +355,15 @@ function useDragGesture(config, callbacks) {
309
355
  }
310
356
  }),
311
357
  // eslint-disable-next-line react-hooks/exhaustive-deps
312
- [enabled, containerRef, triggerLightHaptic, handleDragEnd, index, itemId]);
358
+ [
359
+ enabled,
360
+ containerRef,
361
+ triggerLightHaptic,
362
+ handleDragEnd,
363
+ index,
364
+ itemId,
365
+ dragStartIndexSnapshot,
366
+ itemIndexRegistry,
367
+ ]);
313
368
  return { panGesture, isDragging, translateY };
314
369
  }
@@ -1,4 +1,4 @@
1
- import type { UseDragShiftConfig, UseDragShiftResult } from '../../types';
1
+ import type { UseDragShiftConfig, UseDragShiftResult } from "../../types";
2
2
  /**
3
3
  * Hook that calculates shift animation for non-dragged items.
4
4
  *
@@ -1 +1 @@
1
- {"version":3,"file":"useDragShift.d.ts","sourceRoot":"","sources":["../../../src/hooks/drag/useDragShift.ts"],"names":[],"mappings":"AAYA,OAAO,KAAK,EAAE,kBAAkB,EAAE,kBAAkB,EAAE,MAAM,aAAa,CAAC;AAE1E;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,YAAY,CAAC,MAAM,EAAE,kBAAkB,GAAG,kBAAkB,CAkK3E"}
1
+ {"version":3,"file":"useDragShift.d.ts","sourceRoot":"","sources":["../../../src/hooks/drag/useDragShift.ts"],"names":[],"mappings":"AAYA,OAAO,KAAK,EAAE,kBAAkB,EAAE,kBAAkB,EAAE,MAAM,aAAa,CAAC;AAE1E;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,YAAY,CAAC,MAAM,EAAE,kBAAkB,GAAG,kBAAkB,CAmP3E"}