@souscheflabs/reanimated-flashlist 0.1.7

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 (104) hide show
  1. package/README.md +282 -0
  2. package/lib/AnimatedFlashList.d.ts +6 -0
  3. package/lib/AnimatedFlashList.d.ts.map +1 -0
  4. package/lib/AnimatedFlashList.js +207 -0
  5. package/lib/AnimatedFlashListItem.d.ts +33 -0
  6. package/lib/AnimatedFlashListItem.d.ts.map +1 -0
  7. package/lib/AnimatedFlashListItem.js +155 -0
  8. package/lib/__tests__/utils/test-utils.d.ts +82 -0
  9. package/lib/__tests__/utils/test-utils.d.ts.map +1 -0
  10. package/lib/__tests__/utils/test-utils.js +115 -0
  11. package/lib/constants/animations.d.ts +39 -0
  12. package/lib/constants/animations.d.ts.map +1 -0
  13. package/lib/constants/animations.js +100 -0
  14. package/lib/constants/drag.d.ts +11 -0
  15. package/lib/constants/drag.d.ts.map +1 -0
  16. package/lib/constants/drag.js +47 -0
  17. package/lib/constants/index.d.ts +3 -0
  18. package/lib/constants/index.d.ts.map +1 -0
  19. package/lib/constants/index.js +18 -0
  20. package/lib/contexts/DragStateContext.d.ts +73 -0
  21. package/lib/contexts/DragStateContext.d.ts.map +1 -0
  22. package/lib/contexts/DragStateContext.js +148 -0
  23. package/lib/contexts/ListAnimationContext.d.ts +104 -0
  24. package/lib/contexts/ListAnimationContext.d.ts.map +1 -0
  25. package/lib/contexts/ListAnimationContext.js +184 -0
  26. package/lib/contexts/index.d.ts +5 -0
  27. package/lib/contexts/index.d.ts.map +1 -0
  28. package/lib/contexts/index.js +10 -0
  29. package/lib/hooks/animations/index.d.ts +9 -0
  30. package/lib/hooks/animations/index.d.ts.map +1 -0
  31. package/lib/hooks/animations/index.js +13 -0
  32. package/lib/hooks/animations/useListEntryAnimation.d.ts +38 -0
  33. package/lib/hooks/animations/useListEntryAnimation.d.ts.map +1 -0
  34. package/lib/hooks/animations/useListEntryAnimation.js +90 -0
  35. package/lib/hooks/animations/useListExitAnimation.d.ts +67 -0
  36. package/lib/hooks/animations/useListExitAnimation.d.ts.map +1 -0
  37. package/lib/hooks/animations/useListExitAnimation.js +146 -0
  38. package/lib/hooks/drag/index.d.ts +20 -0
  39. package/lib/hooks/drag/index.d.ts.map +1 -0
  40. package/lib/hooks/drag/index.js +26 -0
  41. package/lib/hooks/drag/useDragAnimatedStyle.d.ts +33 -0
  42. package/lib/hooks/drag/useDragAnimatedStyle.d.ts.map +1 -0
  43. package/lib/hooks/drag/useDragAnimatedStyle.js +61 -0
  44. package/lib/hooks/drag/useDragGesture.d.ts +30 -0
  45. package/lib/hooks/drag/useDragGesture.d.ts.map +1 -0
  46. package/lib/hooks/drag/useDragGesture.js +189 -0
  47. package/lib/hooks/drag/useDragShift.d.ts +21 -0
  48. package/lib/hooks/drag/useDragShift.d.ts.map +1 -0
  49. package/lib/hooks/drag/useDragShift.js +85 -0
  50. package/lib/hooks/drag/useDropCompensation.d.ts +27 -0
  51. package/lib/hooks/drag/useDropCompensation.d.ts.map +1 -0
  52. package/lib/hooks/drag/useDropCompensation.js +90 -0
  53. package/lib/hooks/index.d.ts +8 -0
  54. package/lib/hooks/index.d.ts.map +1 -0
  55. package/lib/hooks/index.js +18 -0
  56. package/lib/index.d.ts +42 -0
  57. package/lib/index.d.ts.map +1 -0
  58. package/lib/index.js +69 -0
  59. package/lib/types/animations.d.ts +71 -0
  60. package/lib/types/animations.d.ts.map +1 -0
  61. package/lib/types/animations.js +2 -0
  62. package/lib/types/drag.d.ts +94 -0
  63. package/lib/types/drag.d.ts.map +1 -0
  64. package/lib/types/drag.js +2 -0
  65. package/lib/types/index.d.ts +4 -0
  66. package/lib/types/index.d.ts.map +1 -0
  67. package/lib/types/index.js +19 -0
  68. package/lib/types/list.d.ts +136 -0
  69. package/lib/types/list.d.ts.map +1 -0
  70. package/lib/types/list.js +2 -0
  71. package/package.json +73 -0
  72. package/src/AnimatedFlashList.tsx +411 -0
  73. package/src/AnimatedFlashListItem.tsx +212 -0
  74. package/src/__tests__/components/AnimatedFlashList.test.tsx +365 -0
  75. package/src/__tests__/components/AnimatedFlashListItem.test.tsx +371 -0
  76. package/src/__tests__/contexts/DragStateContext.test.tsx +169 -0
  77. package/src/__tests__/contexts/ListAnimationContext.test.tsx +324 -0
  78. package/src/__tests__/hooks/useDragAnimatedStyle.test.tsx +118 -0
  79. package/src/__tests__/hooks/useDragGesture.test.tsx +169 -0
  80. package/src/__tests__/hooks/useDragShift.test.tsx +94 -0
  81. package/src/__tests__/hooks/useDropCompensation.test.tsx +182 -0
  82. package/src/__tests__/hooks/useListEntryAnimation.test.tsx +135 -0
  83. package/src/__tests__/hooks/useListExitAnimation.test.tsx +175 -0
  84. package/src/__tests__/utils/test-utils.tsx +159 -0
  85. package/src/constants/animations.ts +107 -0
  86. package/src/constants/drag.ts +51 -0
  87. package/src/constants/index.ts +2 -0
  88. package/src/contexts/DragStateContext.tsx +197 -0
  89. package/src/contexts/ListAnimationContext.tsx +302 -0
  90. package/src/contexts/index.ts +9 -0
  91. package/src/hooks/animations/index.ts +9 -0
  92. package/src/hooks/animations/useListEntryAnimation.ts +108 -0
  93. package/src/hooks/animations/useListExitAnimation.ts +197 -0
  94. package/src/hooks/drag/index.ts +20 -0
  95. package/src/hooks/drag/useDragAnimatedStyle.ts +80 -0
  96. package/src/hooks/drag/useDragGesture.ts +267 -0
  97. package/src/hooks/drag/useDragShift.ts +119 -0
  98. package/src/hooks/drag/useDropCompensation.ts +120 -0
  99. package/src/hooks/index.ts +16 -0
  100. package/src/index.ts +105 -0
  101. package/src/types/animations.ts +76 -0
  102. package/src/types/drag.ts +101 -0
  103. package/src/types/index.ts +3 -0
  104. package/src/types/list.ts +178 -0
@@ -0,0 +1,108 @@
1
+ import { useEffect, useRef } from 'react';
2
+ import {
3
+ useAnimatedStyle,
4
+ useSharedValue,
5
+ withTiming,
6
+ } from 'react-native-reanimated';
7
+ import {
8
+ DEFAULT_ENTRY_ANIMATION,
9
+ standardEasing,
10
+ } from '../../constants/animations';
11
+ import { useListAnimationOptional } from '../../contexts/ListAnimationContext';
12
+ import type { EntryAnimationConfig } from '../../types';
13
+
14
+ /**
15
+ * Hook for managing list item entry animations
16
+ *
17
+ * Provides coordinated slide and fade animations for items appearing
18
+ * in a new list (e.g., after being moved via subscription update).
19
+ *
20
+ * PERFORMANCE: Uses shared values with static defaults.
21
+ * Animation only triggers when an entry animation is claimed.
22
+ *
23
+ * IMPORTANT: This hook handles FlashList view recycling by accepting an itemId parameter.
24
+ * When the itemId changes (view recycled for a different item), animation state is reset.
25
+ *
26
+ * @param itemId - Unique identifier for the list item (used to detect view recycling)
27
+ * @param configOverrides - Optional animation configuration overrides
28
+ * @returns Animation styles for entry effect
29
+ *
30
+ * @example
31
+ * ```tsx
32
+ * const { entryAnimatedStyle } = useListEntryAnimation(item.id);
33
+ *
34
+ * <Animated.View style={[styles.container, entryAnimatedStyle]}>
35
+ * {content}
36
+ * </Animated.View>
37
+ * ```
38
+ */
39
+ export const useListEntryAnimation = (
40
+ itemId: string,
41
+ configOverrides?: Partial<EntryAnimationConfig>,
42
+ ) => {
43
+ const animationContext = useListAnimationOptional();
44
+
45
+ // Merge config with defaults
46
+ const config = {
47
+ fade: { ...DEFAULT_ENTRY_ANIMATION.fade, ...configOverrides?.fade },
48
+ slide: { ...DEFAULT_ENTRY_ANIMATION.slide, ...configOverrides?.slide },
49
+ };
50
+
51
+ // Shared values for entry animation - start at final position (no animation by default)
52
+ const translateX = useSharedValue(0);
53
+ const opacity = useSharedValue(1);
54
+
55
+ // Track if we've already checked for entry animation for this item
56
+ const hasCheckedRef = useRef(false);
57
+ const lastItemIdRef = useRef(itemId);
58
+
59
+ // Reset check flag when item ID changes (view recycled)
60
+ if (lastItemIdRef.current !== itemId) {
61
+ lastItemIdRef.current = itemId;
62
+ hasCheckedRef.current = false;
63
+ // Reset animation values for new item
64
+ translateX.value = 0;
65
+ opacity.value = 1;
66
+ }
67
+
68
+ // Check for pending entry animation on mount
69
+ useEffect(() => {
70
+ if (!animationContext || hasCheckedRef.current) return;
71
+
72
+ hasCheckedRef.current = true;
73
+
74
+ const entry = animationContext.claimEntryAnimation(itemId);
75
+ if (entry) {
76
+ // Start from offset position (slide in from direction)
77
+ translateX.value = entry.direction * config.slide.distance;
78
+ opacity.value = 0;
79
+
80
+ // Animate to final position
81
+ translateX.value = withTiming(0, {
82
+ duration: config.slide.duration,
83
+ easing: standardEasing,
84
+ });
85
+ opacity.value = withTiming(1, {
86
+ duration: config.fade.duration,
87
+ easing: standardEasing,
88
+ });
89
+ }
90
+ }, [itemId, animationContext, translateX, opacity, config.slide, config.fade]);
91
+
92
+ // Animated style for entry effect
93
+ const entryAnimatedStyle = useAnimatedStyle(() => {
94
+ // Fast path: no animation active (default state)
95
+ if (translateX.value === 0 && opacity.value === 1) {
96
+ return {};
97
+ }
98
+
99
+ return {
100
+ opacity: opacity.value,
101
+ transform: [{ translateX: translateX.value }],
102
+ };
103
+ });
104
+
105
+ return {
106
+ entryAnimatedStyle,
107
+ };
108
+ };
@@ -0,0 +1,197 @@
1
+ import { useCallback, useRef, useEffect } from 'react';
2
+ import {
3
+ useAnimatedStyle,
4
+ useSharedValue,
5
+ withTiming,
6
+ } from 'react-native-reanimated';
7
+ import { scheduleOnRN } from 'react-native-worklets';
8
+ import {
9
+ FAST_EXIT_ANIMATION,
10
+ DEFAULT_EXIT_ANIMATION,
11
+ standardEasing,
12
+ } from '../../constants/animations';
13
+ import type { AnimationDirection, ExitAnimationPreset } from '../../types';
14
+
15
+ /**
16
+ * Configuration for exit animation with layout compensation
17
+ */
18
+ interface UseListExitAnimationConfig {
19
+ /** Item index in the list (for layout compensation) */
20
+ index?: number;
21
+ /** Measured item height (for layout compensation) */
22
+ measuredHeight?: number;
23
+ /** Callback when exit animation starts (for registering with context) */
24
+ onExitStart?: (index: number, height: number) => void;
25
+ /** Callback when exit animation completes (for unregistering) */
26
+ onExitComplete?: () => void;
27
+ }
28
+
29
+ // Animation configs by preset
30
+ const animationConfigs = {
31
+ default: DEFAULT_EXIT_ANIMATION,
32
+ fast: FAST_EXIT_ANIMATION,
33
+ } as const;
34
+
35
+ /**
36
+ * Hook for managing list item exit animations
37
+ *
38
+ * Provides coordinated slide, fade, and scale animations for items
39
+ * exiting a list (e.g., moving between sections, being deleted).
40
+ *
41
+ * PERFORMANCE: Uses eager shared value creation but defers animation calculations.
42
+ * The animated style returns static values when exitDirection === 0 (no animation),
43
+ * avoiding expensive animation calculations until the user actually triggers one.
44
+ *
45
+ * IMPORTANT: This hook handles FlashList view recycling by accepting an itemId parameter.
46
+ * When the itemId changes (view recycled for a different item), all animation state is reset.
47
+ *
48
+ * @param itemId - Unique identifier for the list item (used to detect view recycling)
49
+ * @param config - Optional configuration for layout compensation callbacks
50
+ * @returns Animation styles and trigger functions
51
+ *
52
+ * @example
53
+ * ```tsx
54
+ * const { exitAnimatedStyle, triggerExit, resetAnimation } = useListExitAnimation(item.id, {
55
+ * index,
56
+ * measuredHeight: 80,
57
+ * onExitStart: (idx, height) => registerExitingItem(itemId, idx, height),
58
+ * onExitComplete: () => unregisterExitingItem(itemId),
59
+ * });
60
+ *
61
+ * // Trigger exit animation
62
+ * triggerExit(1, () => {
63
+ * onItemRemoved(item.id);
64
+ * });
65
+ *
66
+ * // Apply styles
67
+ * <Animated.View style={exitAnimatedStyle}>
68
+ * {content}
69
+ * </Animated.View>
70
+ * ```
71
+ */
72
+ export const useListExitAnimation = (
73
+ itemId: string,
74
+ config?: UseListExitAnimationConfig,
75
+ ) => {
76
+ // Shared value for exit direction - created eagerly but idle until animation triggers
77
+ // Value: 0 = no animation, 1 = animating right, -1 = animating left
78
+ const exitDirection = useSharedValue(0);
79
+
80
+ // Track current animation config on UI thread for smooth animation
81
+ const slideDistance = useSharedValue<number>(FAST_EXIT_ANIMATION.slide.distance);
82
+ const scaleToValue = useSharedValue<number>(FAST_EXIT_ANIMATION.scale.toValue);
83
+
84
+ // Use useRef instead of useSharedValue for isAnimating flag
85
+ const isAnimatingRef = useRef(false);
86
+
87
+ // Refs for callback management and unmount safety
88
+ const onCompleteRef = useRef<(() => void) | null>(null);
89
+ const isMountedRef = useRef(true);
90
+
91
+ // Cleanup on unmount
92
+ useEffect(() => {
93
+ return () => {
94
+ isMountedRef.current = false;
95
+ onCompleteRef.current = null;
96
+ };
97
+ }, []);
98
+
99
+ // Reset animation state when view is recycled (item ID changes)
100
+ useEffect(() => {
101
+ exitDirection.value = 0;
102
+ slideDistance.value = FAST_EXIT_ANIMATION.slide.distance;
103
+ scaleToValue.value = FAST_EXIT_ANIMATION.scale.toValue;
104
+ isAnimatingRef.current = false;
105
+ }, [itemId, exitDirection, slideDistance, scaleToValue]);
106
+
107
+ // Exit animated style (slide, fade, scale)
108
+ const exitAnimatedStyle = useAnimatedStyle(() => {
109
+ // Fast path: no animation active, return static values
110
+ if (exitDirection.value === 0) {
111
+ return {
112
+ opacity: 1,
113
+ transform: [{ translateX: 0 }, { scale: 1 }],
114
+ };
115
+ }
116
+
117
+ const progress = Math.abs(exitDirection.value);
118
+
119
+ return {
120
+ opacity: 1 - progress,
121
+ transform: [
122
+ { translateX: exitDirection.value * slideDistance.value },
123
+ { scale: 1 - progress * (1 - scaleToValue.value) },
124
+ ],
125
+ };
126
+ });
127
+
128
+ // Helper function to safely call the completion callback
129
+ const safeCallComplete = useCallback(() => {
130
+ if (isMountedRef.current && onCompleteRef.current) {
131
+ onCompleteRef.current();
132
+ onCompleteRef.current = null;
133
+ }
134
+ // Unregister exiting item (layout compensation cleanup)
135
+ config?.onExitComplete?.();
136
+ isAnimatingRef.current = false;
137
+ }, [config]);
138
+
139
+ /**
140
+ * Trigger exit animation with direction, completion callback, and optional preset
141
+ * @param direction - 1 for forward/right, -1 for backward/left
142
+ * @param onComplete - Callback fired when animation completes
143
+ * @param preset - Animation preset: 'default' (300ms) or 'fast' (200ms)
144
+ */
145
+ const triggerExit = useCallback(
146
+ (
147
+ direction: AnimationDirection,
148
+ onComplete: () => void,
149
+ preset: ExitAnimationPreset = 'fast',
150
+ ) => {
151
+ // Guard against rapid toggling
152
+ if (isAnimatingRef.current) return;
153
+
154
+ isAnimatingRef.current = true;
155
+ onCompleteRef.current = onComplete;
156
+
157
+ const animConfig = animationConfigs[preset];
158
+
159
+ // Register exiting item for layout compensation (if configured)
160
+ if (config?.onExitStart && config.index !== undefined) {
161
+ const height = config.measuredHeight ?? animConfig.slide.distance;
162
+ config.onExitStart(config.index, height);
163
+ }
164
+
165
+ // Set animation config SharedValues BEFORE starting animation
166
+ slideDistance.value = animConfig.slide.distance;
167
+ scaleToValue.value = animConfig.scale.toValue;
168
+
169
+ // Animate exitDirection.value from 0 to 1 (or -1)
170
+ exitDirection.value = withTiming(
171
+ direction,
172
+ { duration: animConfig.slide.duration, easing: standardEasing },
173
+ finished => {
174
+ 'worklet';
175
+ if (finished) {
176
+ scheduleOnRN(safeCallComplete);
177
+ }
178
+ },
179
+ );
180
+ },
181
+ [exitDirection, slideDistance, scaleToValue, safeCallComplete, config],
182
+ );
183
+
184
+ /**
185
+ * Reset animation state (for reuse or error recovery)
186
+ */
187
+ const resetAnimation = useCallback(() => {
188
+ isAnimatingRef.current = false;
189
+ exitDirection.value = 0;
190
+ }, [exitDirection]);
191
+
192
+ return {
193
+ exitAnimatedStyle,
194
+ triggerExit,
195
+ resetAnimation,
196
+ };
197
+ };
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Drag-to-reorder hooks
3
+ *
4
+ * These hooks encapsulate the complex logic for implementing drag-to-reorder
5
+ * functionality in a FlashList. They work together with DragStateContext
6
+ * to coordinate animations across all items.
7
+ *
8
+ * Usage:
9
+ * ```tsx
10
+ * const { panGesture, isDragging, translateY } = useDragGesture(config, callbacks);
11
+ * const { shiftY } = useDragShift({ itemId, index });
12
+ * useDropCompensation({ itemId, index, translateY, shiftY });
13
+ * const { dragAnimatedStyle } = useDragAnimatedStyle(itemId, isDragging, translateY, shiftY);
14
+ * ```
15
+ */
16
+
17
+ export { useDragGesture } from './useDragGesture';
18
+ export { useDragShift } from './useDragShift';
19
+ export { useDropCompensation } from './useDropCompensation';
20
+ export { useDragAnimatedStyle } from './useDragAnimatedStyle';
@@ -0,0 +1,80 @@
1
+ import { useAnimatedStyle, interpolate } from 'react-native-reanimated';
2
+ import type { SharedValue } from 'react-native-reanimated';
3
+ import type { ViewStyle } from 'react-native';
4
+ import { useDragState } from '../../contexts/DragStateContext';
5
+ import type { UseDragAnimatedStyleResult } from '../../types';
6
+
7
+ /**
8
+ * Hook that creates animated styles for drag operations.
9
+ *
10
+ * Handles both dragged item and non-dragged item styles:
11
+ * - Dragged item: Uses translateY for position + scale from context
12
+ * - Non-dragged items: Uses shiftY for displacement animation
13
+ *
14
+ * CRITICAL: This hook merges transforms into a single style because React Native
15
+ * doesn't merge transform arrays (when multiple styles have transforms, the last one wins).
16
+ *
17
+ * Also handles:
18
+ * - zIndex elevation for dragged item
19
+ * - Shadow opacity animation
20
+ * - Elevation for Android
21
+ *
22
+ * @example
23
+ * ```tsx
24
+ * const { dragAnimatedStyle } = useDragAnimatedStyle(
25
+ * item.id,
26
+ * isDragging,
27
+ * translateY,
28
+ * shiftY
29
+ * );
30
+ *
31
+ * <Animated.View style={[styles.container, isDragEnabled && dragAnimatedStyle]}>
32
+ * ...
33
+ * </Animated.View>
34
+ * ```
35
+ */
36
+ export function useDragAnimatedStyle(
37
+ itemId: string,
38
+ isDragging: SharedValue<boolean>,
39
+ translateY: SharedValue<number>,
40
+ shiftY: SharedValue<number>,
41
+ ): UseDragAnimatedStyleResult {
42
+ // Global drag state for scale, identity check, and scroll compensation
43
+ const { draggedItemId, draggedScale, config, scrollOffset, dragStartScrollOffset } =
44
+ useDragState();
45
+
46
+ // Animated style for drag offset with scale and shadow
47
+ const dragAnimatedStyle = useAnimatedStyle(() => {
48
+ const isThisItemDragged = draggedItemId.value === itemId;
49
+
50
+ // Keep elevated if: actively dragging OR has offset (animating back)
51
+ const shouldBeElevated =
52
+ isDragging.value || Math.abs(translateY.value) > 1;
53
+
54
+ const shadowOpacity = interpolate(
55
+ draggedScale.value,
56
+ [1, config.dragScale],
57
+ [0.1, config.dragShadowOpacity],
58
+ );
59
+
60
+ // Calculate scroll delta for position compensation during autoscroll
61
+ const scrollDelta = scrollOffset.value - dragStartScrollOffset.value;
62
+
63
+ // Use drag translateY + scroll compensation for dragged item, shiftY for others
64
+ const yOffset = isThisItemDragged
65
+ ? translateY.value + scrollDelta
66
+ : shiftY.value;
67
+
68
+ return {
69
+ transform: [
70
+ { translateY: yOffset },
71
+ { scale: isThisItemDragged ? draggedScale.value : 1 },
72
+ ],
73
+ zIndex: shouldBeElevated ? 999 : 0,
74
+ shadowOpacity: isThisItemDragged ? shadowOpacity : 0.1,
75
+ elevation: isDragging.value ? 12 : 4,
76
+ } as ViewStyle;
77
+ });
78
+
79
+ return { dragAnimatedStyle };
80
+ }
@@ -0,0 +1,267 @@
1
+ import { useCallback, useRef, useMemo } from 'react';
2
+ import {
3
+ useSharedValue,
4
+ measure,
5
+ withSpring,
6
+ withTiming,
7
+ Easing,
8
+ interpolate,
9
+ } from 'react-native-reanimated';
10
+ import { Gesture } from 'react-native-gesture-handler';
11
+ import { scheduleOnRN } from 'react-native-worklets';
12
+ import { useDragState } from '../../contexts/DragStateContext';
13
+ import type {
14
+ UseDragGestureConfig,
15
+ UseDragGestureCallbacks,
16
+ UseDragGestureResult,
17
+ } from '../../types';
18
+
19
+ /**
20
+ * Hook that encapsulates all drag gesture logic.
21
+ *
22
+ * Handles:
23
+ * - Pan gesture with long press activation
24
+ * - Autoscroll when dragging near viewport edges
25
+ * - Optional haptic feedback on drag start and drop
26
+ * - Scale animation for visual feedback
27
+ * - Drop position calculation based on drag offset
28
+ *
29
+ * Uses DragStateContext for global coordination across all items.
30
+ *
31
+ * @example
32
+ * ```tsx
33
+ * const { panGesture, isDragging, translateY } = useDragGesture(
34
+ * { itemId: item.id, index, totalItems, enabled: true, containerRef },
35
+ * { onReorderByDelta: handleReorder, onHapticFeedback: triggerHaptic }
36
+ * );
37
+ *
38
+ * // Attach to drag handle
39
+ * <GestureDetector gesture={panGesture}>
40
+ * <Animated.View>
41
+ * <DragHandleIcon />
42
+ * </Animated.View>
43
+ * </GestureDetector>
44
+ * ```
45
+ */
46
+ export function useDragGesture(
47
+ config: UseDragGestureConfig,
48
+ callbacks: UseDragGestureCallbacks,
49
+ ): UseDragGestureResult {
50
+ const { itemId, index, totalItems, enabled, containerRef } = config;
51
+ const { onReorderByDelta, onHapticFeedback } = callbacks;
52
+
53
+ // Local drag state for this item's animation
54
+ const isDragging = useSharedValue(false);
55
+ const translateY = useSharedValue(0);
56
+
57
+ // Global drag state for coordinating animations across all items
58
+ const {
59
+ isDragging: globalIsDragging,
60
+ draggedIndex,
61
+ draggedItemId,
62
+ currentTranslateY,
63
+ draggedScale,
64
+ scrollOffset,
65
+ dragStartScrollOffset,
66
+ contentHeight,
67
+ visibleHeight,
68
+ listTopY,
69
+ dragUpdateTrigger,
70
+ measuredItemHeight,
71
+ isDropping,
72
+ scrollToOffset,
73
+ config: dragConfig,
74
+ } = useDragState();
75
+
76
+ // Store current values in refs for stable gesture callbacks
77
+ const dragContextRef = useRef({
78
+ index,
79
+ totalItems,
80
+ itemId,
81
+ onReorderByDelta,
82
+ });
83
+
84
+ // Keep ref in sync with current values
85
+ dragContextRef.current = {
86
+ index,
87
+ totalItems,
88
+ itemId,
89
+ onReorderByDelta,
90
+ };
91
+
92
+ // Calculate new position and call reorder callback
93
+ const handleDragEnd = useCallback(
94
+ (finalTranslateY: number) => {
95
+ const {
96
+ index: currentIndex,
97
+ totalItems: total,
98
+ itemId: currentItemId,
99
+ onReorderByDelta: reorder,
100
+ } = dragContextRef.current;
101
+
102
+ // Use dynamically measured height + margins, or fall back to config
103
+ const itemHeight =
104
+ measuredItemHeight.value > 0
105
+ ? measuredItemHeight.value + dragConfig.itemVerticalMargin
106
+ : dragConfig.itemHeight;
107
+
108
+ // Calculate how many positions to move based on drag offset
109
+ const positionDelta = Math.round(finalTranslateY / itemHeight);
110
+
111
+ // Calculate if position actually changes
112
+ const newIndex = Math.max(
113
+ 0,
114
+ Math.min(total - 1, currentIndex + positionDelta),
115
+ );
116
+ const positionChanged =
117
+ reorder && positionDelta !== 0 && newIndex !== currentIndex;
118
+
119
+ if (positionChanged) {
120
+ // Position changes - call reorder, let useDropCompensation handle animation
121
+ isDropping.value = true;
122
+ onHapticFeedback?.('medium');
123
+ reorder(currentItemId, positionDelta);
124
+
125
+ // Reset translate values
126
+ currentTranslateY.value = 0;
127
+ dragStartScrollOffset.value = 0;
128
+ } else {
129
+ // Same position - animate back and reset state
130
+ translateY.value = withTiming(
131
+ 0,
132
+ { duration: 150, easing: Easing.out(Easing.ease) },
133
+ finished => {
134
+ 'worklet';
135
+ if (finished) {
136
+ globalIsDragging.value = false;
137
+ draggedIndex.value = -1;
138
+ draggedItemId.value = '';
139
+ currentTranslateY.value = 0;
140
+ dragStartScrollOffset.value = 0;
141
+ measuredItemHeight.value = 0;
142
+ dragUpdateTrigger.value = dragUpdateTrigger.value + 1;
143
+ }
144
+ },
145
+ );
146
+ }
147
+ },
148
+ [
149
+ measuredItemHeight,
150
+ dragConfig,
151
+ translateY,
152
+ globalIsDragging,
153
+ draggedIndex,
154
+ draggedItemId,
155
+ currentTranslateY,
156
+ dragStartScrollOffset,
157
+ dragUpdateTrigger,
158
+ isDropping,
159
+ onHapticFeedback,
160
+ ],
161
+ );
162
+
163
+ // Stable haptic callback for drag start
164
+ const triggerLightHaptic = useCallback(() => {
165
+ onHapticFeedback?.('light');
166
+ }, [onHapticFeedback]);
167
+
168
+ // Pan gesture for drag-to-reorder
169
+ const panGesture = useMemo(
170
+ () =>
171
+ Gesture.Pan()
172
+ .activateAfterLongPress(dragConfig.longPressDuration)
173
+ .enabled(enabled)
174
+ .onStart(() => {
175
+ 'worklet';
176
+ // Measure actual item height for accurate drag calculations
177
+ const measured = measure(containerRef);
178
+ if (measured) {
179
+ measuredItemHeight.value = measured.height;
180
+ }
181
+
182
+ // Local drag state
183
+ isDragging.value = true;
184
+ // Global drag state for shift animations
185
+ globalIsDragging.value = true;
186
+ draggedIndex.value = index;
187
+ draggedItemId.value = itemId;
188
+ dragStartScrollOffset.value = scrollOffset.value;
189
+ currentTranslateY.value = 0;
190
+ draggedScale.value = withSpring(dragConfig.dragScale, {
191
+ damping: 15,
192
+ stiffness: 400,
193
+ });
194
+ dragUpdateTrigger.value = dragUpdateTrigger.value + 1;
195
+ scheduleOnRN(triggerLightHaptic);
196
+ })
197
+ .onUpdate(event => {
198
+ 'worklet';
199
+ translateY.value = event.translationY;
200
+ currentTranslateY.value = event.translationY;
201
+ dragUpdateTrigger.value = dragUpdateTrigger.value + 1;
202
+
203
+ // Autoscroll when dragging near edges
204
+ const fingerInList = event.absoluteY - listTopY.value;
205
+ const topEdge = dragConfig.edgeThreshold;
206
+ const bottomEdge = visibleHeight.value - dragConfig.edgeThreshold;
207
+
208
+ if (fingerInList < topEdge && scrollOffset.value > 0) {
209
+ const speed = interpolate(
210
+ fingerInList,
211
+ [0, topEdge],
212
+ [dragConfig.maxScrollSpeed, 0],
213
+ 'clamp',
214
+ );
215
+ const newOffset = Math.max(0, scrollOffset.value - speed);
216
+ scrollOffset.value = newOffset;
217
+ scheduleOnRN(scrollToOffset, newOffset);
218
+ } else if (fingerInList > bottomEdge) {
219
+ const maxOffset = Math.max(
220
+ 0,
221
+ contentHeight.value - visibleHeight.value,
222
+ );
223
+ if (scrollOffset.value < maxOffset) {
224
+ const speed = interpolate(
225
+ fingerInList,
226
+ [bottomEdge, visibleHeight.value],
227
+ [0, dragConfig.maxScrollSpeed],
228
+ 'clamp',
229
+ );
230
+ const newOffset = Math.min(maxOffset, scrollOffset.value + speed);
231
+ scrollOffset.value = newOffset;
232
+ scheduleOnRN(scrollToOffset, newOffset);
233
+ }
234
+ }
235
+ })
236
+ .onEnd(event => {
237
+ 'worklet';
238
+ isDragging.value = false;
239
+
240
+ // Include scroll compensation in final position calculation
241
+ const scrollDelta = scrollOffset.value - dragStartScrollOffset.value;
242
+ const finalY = event.translationY + scrollDelta;
243
+
244
+ draggedScale.value = withSpring(1, { damping: 15, stiffness: 400 });
245
+ scheduleOnRN(handleDragEnd, finalY);
246
+ })
247
+ .onFinalize((_event, success) => {
248
+ 'worklet';
249
+ if (!success) {
250
+ isDragging.value = false;
251
+ translateY.value = withTiming(0, { duration: 150 });
252
+ isDropping.value = false;
253
+ globalIsDragging.value = false;
254
+ draggedIndex.value = -1;
255
+ draggedItemId.value = '';
256
+ currentTranslateY.value = 0;
257
+ draggedScale.value = 1;
258
+ dragStartScrollOffset.value = 0;
259
+ measuredItemHeight.value = 0;
260
+ }
261
+ }),
262
+ // eslint-disable-next-line react-hooks/exhaustive-deps
263
+ [enabled, containerRef, triggerLightHaptic, handleDragEnd, index, itemId],
264
+ );
265
+
266
+ return { panGesture, isDragging, translateY };
267
+ }