@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,107 @@
1
+ import { Easing } from 'react-native-reanimated';
2
+ import type { ExitAnimationConfig, EntryAnimationConfig } from '../types';
3
+
4
+ /**
5
+ * Standard cubic bezier easing function for smooth animations
6
+ * Equivalent to CSS ease-in-out with custom curve
7
+ */
8
+ export const standardEasing = Easing.bezier(0.25, 0.1, 0.25, 1);
9
+
10
+ /**
11
+ * Default exit animation configuration
12
+ * Used when items are removed/toggled from the list
13
+ *
14
+ * Timeline (300ms total):
15
+ * - 0-300ms: Slide (300px in direction)
16
+ * - 50-300ms: Scale to 0.95
17
+ * - 100-300ms: Fade out
18
+ */
19
+ export const DEFAULT_EXIT_ANIMATION: ExitAnimationConfig = {
20
+ slide: {
21
+ duration: 300,
22
+ distance: 300,
23
+ },
24
+ fade: {
25
+ delay: 100,
26
+ duration: 200,
27
+ },
28
+ scale: {
29
+ delay: 50,
30
+ duration: 250,
31
+ toValue: 0.95,
32
+ },
33
+ removalDelay: 300,
34
+ layoutAnimation: {
35
+ duration: 200,
36
+ },
37
+ };
38
+
39
+ /**
40
+ * Fast exit animation for quick actions (checkbox toggles)
41
+ *
42
+ * Timeline (200ms total):
43
+ * - 0-200ms: Slide (200px in direction)
44
+ * - 0-200ms: Fade out (starts immediately)
45
+ * - 0-200ms: Scale to 0.97
46
+ */
47
+ export const FAST_EXIT_ANIMATION: ExitAnimationConfig = {
48
+ slide: {
49
+ duration: 200,
50
+ distance: 200,
51
+ },
52
+ fade: {
53
+ delay: 0,
54
+ duration: 200,
55
+ },
56
+ scale: {
57
+ delay: 0,
58
+ duration: 200,
59
+ toValue: 0.97,
60
+ },
61
+ removalDelay: 200,
62
+ layoutAnimation: {
63
+ duration: 150,
64
+ },
65
+ };
66
+
67
+ /**
68
+ * Default entry animation configuration
69
+ * Used when items appear in the list
70
+ */
71
+ export const DEFAULT_ENTRY_ANIMATION: EntryAnimationConfig = {
72
+ fade: { duration: 250 },
73
+ slide: { distance: 50, duration: 300 },
74
+ };
75
+
76
+ /**
77
+ * Get exit animation config by preset
78
+ */
79
+ export function getExitAnimationConfig(
80
+ preset: 'default' | 'fast' = 'fast',
81
+ overrides?: Partial<ExitAnimationConfig>,
82
+ ): ExitAnimationConfig {
83
+ const base = preset === 'fast' ? FAST_EXIT_ANIMATION : DEFAULT_EXIT_ANIMATION;
84
+ if (!overrides) return base;
85
+
86
+ return {
87
+ slide: { ...base.slide, ...overrides.slide },
88
+ fade: { ...base.fade, ...overrides.fade },
89
+ scale: { ...base.scale, ...overrides.scale },
90
+ removalDelay: overrides.removalDelay ?? base.removalDelay,
91
+ layoutAnimation: { ...base.layoutAnimation, ...overrides.layoutAnimation },
92
+ };
93
+ }
94
+
95
+ /**
96
+ * Create a merged entry animation config from defaults and overrides
97
+ */
98
+ export function createEntryAnimationConfig(
99
+ overrides?: Partial<EntryAnimationConfig>,
100
+ ): EntryAnimationConfig {
101
+ if (!overrides) return DEFAULT_ENTRY_ANIMATION;
102
+
103
+ return {
104
+ fade: { ...DEFAULT_ENTRY_ANIMATION.fade, ...overrides.fade },
105
+ slide: { ...DEFAULT_ENTRY_ANIMATION.slide, ...overrides.slide },
106
+ };
107
+ }
@@ -0,0 +1,51 @@
1
+ import type { DragConfig } from '../types';
2
+
3
+ /**
4
+ * Default drag configuration
5
+ * All values can be overridden via AnimatedFlashList config prop
6
+ */
7
+ export const DEFAULT_DRAG_CONFIG: DragConfig = {
8
+ /**
9
+ * Fixed height for list items used in drag calculations.
10
+ * Override this to match your item height + margins
11
+ */
12
+ itemHeight: 95,
13
+
14
+ /**
15
+ * Scale factor applied to dragged item for visual feedback
16
+ */
17
+ dragScale: 1.03,
18
+
19
+ /**
20
+ * Shadow opacity for dragged item (increases from 0.1 to this value)
21
+ */
22
+ dragShadowOpacity: 0.25,
23
+
24
+ /**
25
+ * Vertical margin per item (total margin = 2 * itemVerticalMargin)
26
+ */
27
+ itemVerticalMargin: 8,
28
+
29
+ /**
30
+ * Duration (ms) to hold drag handle before drag activates
31
+ */
32
+ longPressDuration: 200,
33
+
34
+ /**
35
+ * Pixels from viewport edge to trigger autoscroll
36
+ */
37
+ edgeThreshold: 80,
38
+
39
+ /**
40
+ * Maximum scroll speed in pixels per frame at edge
41
+ */
42
+ maxScrollSpeed: 10,
43
+ };
44
+
45
+ /**
46
+ * Create a merged drag config from defaults and overrides
47
+ */
48
+ export function createDragConfig(overrides?: Partial<DragConfig>): DragConfig {
49
+ if (!overrides) return DEFAULT_DRAG_CONFIG;
50
+ return { ...DEFAULT_DRAG_CONFIG, ...overrides };
51
+ }
@@ -0,0 +1,2 @@
1
+ export * from './drag';
2
+ export * from './animations';
@@ -0,0 +1,197 @@
1
+ import React, {
2
+ createContext,
3
+ useContext,
4
+ useCallback,
5
+ useRef,
6
+ useMemo,
7
+ type ReactNode,
8
+ } from 'react';
9
+ import { useSharedValue, type SharedValue } from 'react-native-reanimated';
10
+ import type { FlashListRef } from '@shopify/flash-list';
11
+ import type { DragConfig } from '../types';
12
+ import { DEFAULT_DRAG_CONFIG } from '../constants';
13
+
14
+ /**
15
+ * Centralized drag state for coordinating animations across list items.
16
+ * All animation values are Reanimated SharedValues for 60fps UI thread performance.
17
+ *
18
+ * Architecture:
19
+ * - Single source of truth for all drag state (no per-item local state)
20
+ * - Dragged item identified by draggedIndex, reads/writes centralized values
21
+ * - Non-dragged items read draggedIndex/currentTranslateY to calculate shift
22
+ * - Scroll state enables viewport-aware hover calculations and autoscroll
23
+ */
24
+ export interface DragStateContextValue {
25
+ /** Is any item currently being dragged? */
26
+ isDragging: SharedValue<boolean>;
27
+ /** Original index of the item being dragged (-1 if not dragging) */
28
+ draggedIndex: SharedValue<number>;
29
+ /** ID of the item being dragged (for stable identity across FlashList recycling) */
30
+ draggedItemId: SharedValue<string>;
31
+ /** Current Y translation of the dragged item (for calculating hover position) */
32
+ currentTranslateY: SharedValue<number>;
33
+ /** Scale of the dragged item (1.0 = normal, configured = dragging) */
34
+ draggedScale: SharedValue<number>;
35
+ /** Current scroll offset of the list (updated via onScroll) */
36
+ scrollOffset: SharedValue<number>;
37
+ /** Scroll offset when drag started (for scroll delta calculation during autoscroll) */
38
+ dragStartScrollOffset: SharedValue<number>;
39
+ /** Total content height of the list (updated via onContentSizeChange) */
40
+ contentHeight: SharedValue<number>;
41
+ /** Visible viewport height (updated via onLayout) */
42
+ visibleHeight: SharedValue<number>;
43
+ /** Y position of FlashList top on screen (for autoscroll coordinate conversion) */
44
+ listTopY: SharedValue<number>;
45
+ /** Counter incremented on every drag state change to force useDerivedValue re-evaluation */
46
+ dragUpdateTrigger: SharedValue<number>;
47
+ /** Measured height of the dragged item (for dynamic height calculations) */
48
+ measuredItemHeight: SharedValue<number>;
49
+ /** Flag to freeze shift values during drop transition */
50
+ isDropping: SharedValue<boolean>;
51
+ /** Register the FlashList ref for autoscroll operations */
52
+ setListRef: (ref: FlashListRef<unknown> | null) => void;
53
+ /** Scroll the list to a specific offset (for autoscroll during drag) */
54
+ scrollToOffset: (offset: number, animated?: boolean) => void;
55
+ /** Reset drag state after drop animation completes */
56
+ resetDragState: () => void;
57
+ /** Current drag configuration */
58
+ config: DragConfig;
59
+ }
60
+
61
+ const DragStateContext = createContext<DragStateContextValue | null>(null);
62
+
63
+ /**
64
+ * Hook to access shared drag state from context.
65
+ * Must be used within DragStateProvider.
66
+ */
67
+ export const useDragState = (): DragStateContextValue => {
68
+ const context = useContext(DragStateContext);
69
+ if (!context) {
70
+ throw new Error('useDragState must be used within DragStateProvider');
71
+ }
72
+ return context;
73
+ };
74
+
75
+ interface DragStateProviderProps {
76
+ children: ReactNode;
77
+ /** Optional drag configuration overrides */
78
+ config?: Partial<DragConfig>;
79
+ }
80
+
81
+ /**
82
+ * Provider that creates shared Reanimated values for drag state.
83
+ *
84
+ * These values are shared across all list items:
85
+ * - The dragged item writes to them during drag gestures
86
+ * - Non-dragged items read them to calculate their shift offset
87
+ * - Scroll state enables viewport-aware hover calculations
88
+ *
89
+ * Using SharedValues ensures animations run on the UI thread at 60fps.
90
+ */
91
+ export const DragStateProvider: React.FC<DragStateProviderProps> = ({
92
+ children,
93
+ config: configOverrides,
94
+ }) => {
95
+ // Merge config with defaults
96
+ const config = useMemo<DragConfig>(
97
+ () => ({ ...DEFAULT_DRAG_CONFIG, ...configOverrides }),
98
+ [configOverrides],
99
+ );
100
+
101
+ // Shared values are created once and persist for the lifetime of the provider
102
+ const isDragging = useSharedValue(false);
103
+ const draggedIndex = useSharedValue(-1);
104
+ const draggedItemId = useSharedValue('');
105
+ const currentTranslateY = useSharedValue(0);
106
+ const draggedScale = useSharedValue(1);
107
+
108
+ // Scroll state for viewport-aware calculations and autoscroll
109
+ const scrollOffset = useSharedValue(0);
110
+ const dragStartScrollOffset = useSharedValue(0);
111
+ const contentHeight = useSharedValue(0);
112
+ const visibleHeight = useSharedValue(0);
113
+ const listTopY = useSharedValue(0);
114
+
115
+ // Counter to force useDerivedValue re-evaluation on every drag state change
116
+ const dragUpdateTrigger = useSharedValue(0);
117
+
118
+ // Measured height of dragged item (0 = use fallback from config)
119
+ const measuredItemHeight = useSharedValue(0);
120
+
121
+ // Flag to freeze shift values during drop transition
122
+ const isDropping = useSharedValue(false);
123
+
124
+ // Ref to FlashList for autoscroll operations
125
+ const listRef = useRef<FlashListRef<unknown> | null>(null);
126
+
127
+ // Register the FlashList ref
128
+ const setListRef = useCallback((ref: FlashListRef<unknown> | null) => {
129
+ listRef.current = ref;
130
+ }, []);
131
+
132
+ // Scroll to offset (called via scheduleOnRN from worklet for autoscroll)
133
+ const scrollToOffset = useCallback((offset: number, animated = false) => {
134
+ listRef.current?.scrollToOffset({ offset, animated });
135
+ }, []);
136
+
137
+ // Reset drag state after drop
138
+ const resetDragState = useCallback(() => {
139
+ isDragging.value = false;
140
+ draggedIndex.value = -1;
141
+ draggedItemId.value = '';
142
+ currentTranslateY.value = 0;
143
+ draggedScale.value = 1;
144
+ dragStartScrollOffset.value = 0;
145
+ measuredItemHeight.value = 0;
146
+ isDropping.value = false;
147
+ // eslint-disable-next-line react-hooks/exhaustive-deps
148
+ }, []);
149
+
150
+ // Context value is stable since SharedValue references don't change
151
+ const value = useMemo<DragStateContextValue>(
152
+ () => ({
153
+ isDragging,
154
+ draggedIndex,
155
+ draggedItemId,
156
+ currentTranslateY,
157
+ draggedScale,
158
+ scrollOffset,
159
+ dragStartScrollOffset,
160
+ contentHeight,
161
+ visibleHeight,
162
+ listTopY,
163
+ dragUpdateTrigger,
164
+ measuredItemHeight,
165
+ isDropping,
166
+ setListRef,
167
+ scrollToOffset,
168
+ resetDragState,
169
+ config,
170
+ }),
171
+ [
172
+ isDragging,
173
+ draggedIndex,
174
+ draggedItemId,
175
+ currentTranslateY,
176
+ draggedScale,
177
+ scrollOffset,
178
+ dragStartScrollOffset,
179
+ contentHeight,
180
+ visibleHeight,
181
+ listTopY,
182
+ dragUpdateTrigger,
183
+ measuredItemHeight,
184
+ isDropping,
185
+ setListRef,
186
+ scrollToOffset,
187
+ resetDragState,
188
+ config,
189
+ ],
190
+ );
191
+
192
+ return (
193
+ <DragStateContext.Provider value={value}>
194
+ {children}
195
+ </DragStateContext.Provider>
196
+ );
197
+ };
@@ -0,0 +1,302 @@
1
+ import React, {
2
+ createContext,
3
+ useContext,
4
+ useCallback,
5
+ useRef,
6
+ useMemo,
7
+ type ReactNode,
8
+ } from 'react';
9
+ import { LayoutAnimation, Platform } from 'react-native';
10
+ import { useSharedValue, type SharedValue } from 'react-native-reanimated';
11
+ import type {
12
+ AnimationDirection,
13
+ ExitAnimationTrigger,
14
+ PendingEntryAnimation,
15
+ ExitingItemInfo,
16
+ } from '../types';
17
+
18
+ /**
19
+ * Context value for coordinating entry/exit animations across list items.
20
+ *
21
+ * This enables:
22
+ * 1. Direct O(1) animation triggers from subscription handlers
23
+ * 2. Entry animation coordination when items appear in new locations
24
+ * 3. Layout animation coordination when items are removed
25
+ * 4. Decoupled animation state from React render cycle
26
+ */
27
+ export interface ListAnimationContextValue {
28
+ /**
29
+ * Register an exit animation trigger for an item.
30
+ * Called by useListExitAnimation on mount.
31
+ */
32
+ registerAnimationTrigger: (itemId: string, trigger: ExitAnimationTrigger) => void;
33
+
34
+ /**
35
+ * Unregister an exit animation trigger.
36
+ * Called by useListExitAnimation on unmount.
37
+ */
38
+ unregisterAnimationTrigger: (itemId: string) => void;
39
+
40
+ /**
41
+ * Trigger exit animation for a specific item.
42
+ * Returns true if animation was triggered, false if item not found.
43
+ */
44
+ triggerExitAnimation: (
45
+ itemId: string,
46
+ direction: AnimationDirection,
47
+ onComplete: () => void,
48
+ ) => boolean;
49
+
50
+ /**
51
+ * Queue an entry animation for an item that's about to appear.
52
+ * Call this before the item is added to the list.
53
+ */
54
+ queueEntryAnimation: (itemId: string, direction: AnimationDirection) => void;
55
+
56
+ /**
57
+ * Check and claim a pending entry animation for an item.
58
+ * Returns the animation info if found, null otherwise.
59
+ * Calling this consumes the pending animation.
60
+ */
61
+ claimEntryAnimation: (itemId: string) => PendingEntryAnimation | null;
62
+
63
+ /**
64
+ * Register an item that is currently exiting (for layout compensation).
65
+ * Called when exit animation starts.
66
+ */
67
+ registerExitingItem: (itemId: string, index: number, height: number) => void;
68
+
69
+ /**
70
+ * Unregister an exiting item (called when removal is complete).
71
+ */
72
+ unregisterExitingItem: (itemId: string) => void;
73
+
74
+ /**
75
+ * Get info about items currently exiting above a given index.
76
+ * Returns the total height of exiting items above.
77
+ */
78
+ getExitingHeightAbove: (index: number) => number;
79
+
80
+ /**
81
+ * SharedValue that increments when exiting items change.
82
+ * Used to trigger useDerivedValue re-evaluation.
83
+ */
84
+ exitingItemsVersion: SharedValue<number>;
85
+
86
+ /**
87
+ * Duration for layout animations (ms).
88
+ */
89
+ layoutAnimationDuration: number;
90
+ }
91
+
92
+ const ListAnimationContext = createContext<ListAnimationContextValue | null>(null);
93
+
94
+ /**
95
+ * Hook to access list animation context.
96
+ * Throws if used outside ListAnimationProvider.
97
+ */
98
+ export const useListAnimation = (): ListAnimationContextValue => {
99
+ const context = useContext(ListAnimationContext);
100
+ if (!context) {
101
+ throw new Error('useListAnimation must be used within ListAnimationProvider');
102
+ }
103
+ return context;
104
+ };
105
+
106
+ /**
107
+ * Hook to optionally access list animation context.
108
+ * Returns null if used outside ListAnimationProvider.
109
+ * Useful for components that can work with or without animations.
110
+ */
111
+ export const useListAnimationOptional = (): ListAnimationContextValue | null => {
112
+ return useContext(ListAnimationContext);
113
+ };
114
+
115
+ interface ListAnimationProviderProps {
116
+ children: ReactNode;
117
+ /**
118
+ * How long pending entry animations are valid (ms)
119
+ * @default 5000
120
+ */
121
+ entryAnimationTimeout?: number;
122
+ /**
123
+ * Duration for layout animations when items are removed (ms)
124
+ * @default 200
125
+ */
126
+ layoutAnimationDuration?: number;
127
+ /**
128
+ * Whether to enable native LayoutAnimation for remaining items
129
+ * @default true
130
+ */
131
+ enableLayoutAnimation?: boolean;
132
+ }
133
+
134
+ /**
135
+ * Provider for coordinating entry/exit animations across list items.
136
+ *
137
+ * Features:
138
+ * - O(1) exit animation triggers via Map lookup
139
+ * - Entry animation queuing with automatic expiration
140
+ * - Layout animation coordination when items are removed
141
+ * - Decoupled from React render cycle for performance
142
+ */
143
+ export const ListAnimationProvider: React.FC<ListAnimationProviderProps> = ({
144
+ children,
145
+ entryAnimationTimeout = 5000,
146
+ layoutAnimationDuration = 200,
147
+ enableLayoutAnimation = true,
148
+ }) => {
149
+ // Map of itemId -> exit animation trigger function
150
+ const animationTriggersRef = useRef<Map<string, ExitAnimationTrigger>>(new Map());
151
+
152
+ // Map of itemId -> pending entry animation
153
+ const pendingEntriesRef = useRef<Map<string, PendingEntryAnimation>>(new Map());
154
+
155
+ // Map of itemId -> exiting item info (for layout compensation)
156
+ const exitingItemsRef = useRef<Map<string, ExitingItemInfo>>(new Map());
157
+
158
+ // SharedValue to trigger re-evaluation when exiting items change
159
+ const exitingItemsVersion = useSharedValue(0);
160
+
161
+ const registerAnimationTrigger = useCallback(
162
+ (itemId: string, trigger: ExitAnimationTrigger) => {
163
+ animationTriggersRef.current.set(itemId, trigger);
164
+ },
165
+ [],
166
+ );
167
+
168
+ const unregisterAnimationTrigger = useCallback((itemId: string) => {
169
+ animationTriggersRef.current.delete(itemId);
170
+ }, []);
171
+
172
+ const triggerExitAnimation = useCallback(
173
+ (
174
+ itemId: string,
175
+ direction: AnimationDirection,
176
+ onComplete: () => void,
177
+ ): boolean => {
178
+ const trigger = animationTriggersRef.current.get(itemId);
179
+ if (trigger) {
180
+ trigger(direction, onComplete);
181
+ return true;
182
+ }
183
+ return false;
184
+ },
185
+ [],
186
+ );
187
+
188
+ const queueEntryAnimation = useCallback(
189
+ (itemId: string, direction: AnimationDirection) => {
190
+ const entry: PendingEntryAnimation = {
191
+ itemId,
192
+ direction,
193
+ timestamp: Date.now(),
194
+ };
195
+ pendingEntriesRef.current.set(itemId, entry);
196
+
197
+ // Auto-expire after timeout
198
+ setTimeout(() => {
199
+ const current = pendingEntriesRef.current.get(itemId);
200
+ if (current && current.timestamp === entry.timestamp) {
201
+ pendingEntriesRef.current.delete(itemId);
202
+ }
203
+ }, entryAnimationTimeout);
204
+ },
205
+ [entryAnimationTimeout],
206
+ );
207
+
208
+ const claimEntryAnimation = useCallback(
209
+ (itemId: string): PendingEntryAnimation | null => {
210
+ const entry = pendingEntriesRef.current.get(itemId);
211
+ if (entry) {
212
+ pendingEntriesRef.current.delete(itemId);
213
+ // Check if still valid (not expired)
214
+ if (Date.now() - entry.timestamp < entryAnimationTimeout) {
215
+ return entry;
216
+ }
217
+ }
218
+ return null;
219
+ },
220
+ [entryAnimationTimeout],
221
+ );
222
+
223
+ // Register an exiting item for layout compensation
224
+ const registerExitingItem = useCallback(
225
+ (itemId: string, index: number, height: number) => {
226
+ const info: ExitingItemInfo = {
227
+ itemId,
228
+ index,
229
+ height,
230
+ timestamp: Date.now(),
231
+ };
232
+ exitingItemsRef.current.set(itemId, info);
233
+ exitingItemsVersion.value = exitingItemsVersion.value + 1;
234
+
235
+ // Configure native LayoutAnimation for remaining items
236
+ if (enableLayoutAnimation) {
237
+ // Use custom config for smoother animation
238
+ LayoutAnimation.configureNext({
239
+ duration: layoutAnimationDuration,
240
+ update: {
241
+ type: LayoutAnimation.Types.easeInEaseOut,
242
+ property: LayoutAnimation.Properties.scaleY,
243
+ },
244
+ });
245
+ }
246
+ },
247
+ [enableLayoutAnimation, layoutAnimationDuration, exitingItemsVersion],
248
+ );
249
+
250
+ // Unregister an exiting item
251
+ const unregisterExitingItem = useCallback(
252
+ (itemId: string) => {
253
+ exitingItemsRef.current.delete(itemId);
254
+ exitingItemsVersion.value = exitingItemsVersion.value + 1;
255
+ },
256
+ [exitingItemsVersion],
257
+ );
258
+
259
+ // Get total height of exiting items above a given index
260
+ const getExitingHeightAbove = useCallback((index: number): number => {
261
+ let totalHeight = 0;
262
+ exitingItemsRef.current.forEach(info => {
263
+ if (info.index < index) {
264
+ totalHeight += info.height;
265
+ }
266
+ });
267
+ return totalHeight;
268
+ }, []);
269
+
270
+ const value = useMemo<ListAnimationContextValue>(
271
+ () => ({
272
+ registerAnimationTrigger,
273
+ unregisterAnimationTrigger,
274
+ triggerExitAnimation,
275
+ queueEntryAnimation,
276
+ claimEntryAnimation,
277
+ registerExitingItem,
278
+ unregisterExitingItem,
279
+ getExitingHeightAbove,
280
+ exitingItemsVersion,
281
+ layoutAnimationDuration,
282
+ }),
283
+ [
284
+ registerAnimationTrigger,
285
+ unregisterAnimationTrigger,
286
+ triggerExitAnimation,
287
+ queueEntryAnimation,
288
+ claimEntryAnimation,
289
+ registerExitingItem,
290
+ unregisterExitingItem,
291
+ getExitingHeightAbove,
292
+ exitingItemsVersion,
293
+ layoutAnimationDuration,
294
+ ],
295
+ );
296
+
297
+ return (
298
+ <ListAnimationContext.Provider value={value}>
299
+ {children}
300
+ </ListAnimationContext.Provider>
301
+ );
302
+ };
@@ -0,0 +1,9 @@
1
+ export { DragStateProvider, useDragState } from './DragStateContext';
2
+ export type { DragStateContextValue } from './DragStateContext';
3
+
4
+ export {
5
+ ListAnimationProvider,
6
+ useListAnimation,
7
+ useListAnimationOptional,
8
+ } from './ListAnimationContext';
9
+ export type { ListAnimationContextValue } from './ListAnimationContext';
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Animation hooks for list items
3
+ *
4
+ * These hooks provide entry and exit animations for items
5
+ * being added to or removed from the list.
6
+ */
7
+
8
+ export { useListExitAnimation } from './useListExitAnimation';
9
+ export { useListEntryAnimation } from './useListEntryAnimation';