@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,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,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';
|