@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.
- package/README.md +282 -0
- package/lib/AnimatedFlashList.d.ts +6 -0
- package/lib/AnimatedFlashList.d.ts.map +1 -0
- package/lib/AnimatedFlashList.js +207 -0
- package/lib/AnimatedFlashListItem.d.ts +33 -0
- package/lib/AnimatedFlashListItem.d.ts.map +1 -0
- package/lib/AnimatedFlashListItem.js +155 -0
- package/lib/__tests__/utils/test-utils.d.ts +82 -0
- package/lib/__tests__/utils/test-utils.d.ts.map +1 -0
- package/lib/__tests__/utils/test-utils.js +115 -0
- package/lib/constants/animations.d.ts +39 -0
- package/lib/constants/animations.d.ts.map +1 -0
- package/lib/constants/animations.js +100 -0
- package/lib/constants/drag.d.ts +11 -0
- package/lib/constants/drag.d.ts.map +1 -0
- package/lib/constants/drag.js +47 -0
- package/lib/constants/index.d.ts +3 -0
- package/lib/constants/index.d.ts.map +1 -0
- package/lib/constants/index.js +18 -0
- package/lib/contexts/DragStateContext.d.ts +73 -0
- package/lib/contexts/DragStateContext.d.ts.map +1 -0
- package/lib/contexts/DragStateContext.js +148 -0
- package/lib/contexts/ListAnimationContext.d.ts +104 -0
- package/lib/contexts/ListAnimationContext.d.ts.map +1 -0
- package/lib/contexts/ListAnimationContext.js +184 -0
- package/lib/contexts/index.d.ts +5 -0
- package/lib/contexts/index.d.ts.map +1 -0
- package/lib/contexts/index.js +10 -0
- package/lib/hooks/animations/index.d.ts +9 -0
- package/lib/hooks/animations/index.d.ts.map +1 -0
- package/lib/hooks/animations/index.js +13 -0
- package/lib/hooks/animations/useListEntryAnimation.d.ts +38 -0
- package/lib/hooks/animations/useListEntryAnimation.d.ts.map +1 -0
- package/lib/hooks/animations/useListEntryAnimation.js +90 -0
- package/lib/hooks/animations/useListExitAnimation.d.ts +67 -0
- package/lib/hooks/animations/useListExitAnimation.d.ts.map +1 -0
- package/lib/hooks/animations/useListExitAnimation.js +146 -0
- package/lib/hooks/drag/index.d.ts +20 -0
- package/lib/hooks/drag/index.d.ts.map +1 -0
- package/lib/hooks/drag/index.js +26 -0
- package/lib/hooks/drag/useDragAnimatedStyle.d.ts +33 -0
- package/lib/hooks/drag/useDragAnimatedStyle.d.ts.map +1 -0
- package/lib/hooks/drag/useDragAnimatedStyle.js +61 -0
- package/lib/hooks/drag/useDragGesture.d.ts +30 -0
- package/lib/hooks/drag/useDragGesture.d.ts.map +1 -0
- package/lib/hooks/drag/useDragGesture.js +189 -0
- package/lib/hooks/drag/useDragShift.d.ts +21 -0
- package/lib/hooks/drag/useDragShift.d.ts.map +1 -0
- package/lib/hooks/drag/useDragShift.js +85 -0
- package/lib/hooks/drag/useDropCompensation.d.ts +27 -0
- package/lib/hooks/drag/useDropCompensation.d.ts.map +1 -0
- package/lib/hooks/drag/useDropCompensation.js +90 -0
- package/lib/hooks/index.d.ts +8 -0
- package/lib/hooks/index.d.ts.map +1 -0
- package/lib/hooks/index.js +18 -0
- package/lib/index.d.ts +42 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +69 -0
- package/lib/types/animations.d.ts +71 -0
- package/lib/types/animations.d.ts.map +1 -0
- package/lib/types/animations.js +2 -0
- package/lib/types/drag.d.ts +94 -0
- package/lib/types/drag.d.ts.map +1 -0
- package/lib/types/drag.js +2 -0
- package/lib/types/index.d.ts +4 -0
- package/lib/types/index.d.ts.map +1 -0
- package/lib/types/index.js +19 -0
- package/lib/types/list.d.ts +136 -0
- package/lib/types/list.d.ts.map +1 -0
- package/lib/types/list.js +2 -0
- package/package.json +73 -0
- package/src/AnimatedFlashList.tsx +411 -0
- package/src/AnimatedFlashListItem.tsx +212 -0
- package/src/__tests__/components/AnimatedFlashList.test.tsx +365 -0
- package/src/__tests__/components/AnimatedFlashListItem.test.tsx +371 -0
- package/src/__tests__/contexts/DragStateContext.test.tsx +169 -0
- package/src/__tests__/contexts/ListAnimationContext.test.tsx +324 -0
- package/src/__tests__/hooks/useDragAnimatedStyle.test.tsx +118 -0
- package/src/__tests__/hooks/useDragGesture.test.tsx +169 -0
- package/src/__tests__/hooks/useDragShift.test.tsx +94 -0
- package/src/__tests__/hooks/useDropCompensation.test.tsx +182 -0
- package/src/__tests__/hooks/useListEntryAnimation.test.tsx +135 -0
- package/src/__tests__/hooks/useListExitAnimation.test.tsx +175 -0
- package/src/__tests__/utils/test-utils.tsx +159 -0
- package/src/constants/animations.ts +107 -0
- package/src/constants/drag.ts +51 -0
- package/src/constants/index.ts +2 -0
- package/src/contexts/DragStateContext.tsx +197 -0
- package/src/contexts/ListAnimationContext.tsx +302 -0
- package/src/contexts/index.ts +9 -0
- package/src/hooks/animations/index.ts +9 -0
- package/src/hooks/animations/useListEntryAnimation.ts +108 -0
- package/src/hooks/animations/useListExitAnimation.ts +197 -0
- package/src/hooks/drag/index.ts +20 -0
- package/src/hooks/drag/useDragAnimatedStyle.ts +80 -0
- package/src/hooks/drag/useDragGesture.ts +267 -0
- package/src/hooks/drag/useDragShift.ts +119 -0
- package/src/hooks/drag/useDropCompensation.ts +120 -0
- package/src/hooks/index.ts +16 -0
- package/src/index.ts +105 -0
- package/src/types/animations.ts +76 -0
- package/src/types/drag.ts +101 -0
- package/src/types/index.ts +3 -0
- 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
|
+
}
|