import React, { useCallback, useRef, useMemo, useState, forwardRef, useImperativeHandle, useEffect, useLayoutEffect, } from "react"; import { View, RefreshControl, type NativeSyntheticEvent, type NativeScrollEvent, type LayoutChangeEvent, type ViewStyle, } from "react-native"; import Animated, { useAnimatedStyle, useAnimatedReaction, useSharedValue, } from "react-native-reanimated"; import { Gesture } from "react-native-gesture-handler"; import { scheduleOnRN } from "react-native-worklets"; import { FlashList, type ListRenderItemInfo, type FlashListRef, type FlashListProps, } from "@shopify/flash-list"; import { DragStateProvider, useDragState } from "./contexts/DragStateContext"; import { useOverlayCrossfade } from "./hooks/drag/useOverlayCrossfade"; import { ListAnimationProvider } from "./contexts/ListAnimationContext"; import { AnimatedFlashListItem } from "./AnimatedFlashListItem"; import { DEFAULT_DRAG_CONFIG } from "./constants/drag"; import type { AnimatedListItem, AnimatedFlashListProps, AnimatedFlashListRef, HapticFeedbackType, } from "./types"; /** * Error boundary that catches JS errors in animation hooks and falls back * to rendering children without animation wrappers. * @internal */ interface AnimationErrorBoundaryState { hasError: boolean; } class AnimationErrorBoundary extends React.Component< { children: React.ReactNode; fallback: React.ReactNode }, AnimationErrorBoundaryState > { state: AnimationErrorBoundaryState = { hasError: false }; static getDerivedStateFromError(): AnimationErrorBoundaryState { return { hasError: true }; } componentDidCatch(error: Error) { if (__DEV__) { console.warn( "[AnimatedFlashList] Animation error caught by error boundary. " + "Falling back to non-animated list.", error, ); } } render() { if (this.state.hasError) { return this.props.fallback; } return this.props.children; } } interface DragOverlayProps { data: T[]; totalItemsRef: React.RefObject; renderItem: AnimatedFlashListProps["renderItem"]; } const DragOverlay = React.memo(function DragOverlay< T extends AnimatedListItem, >({ data, totalItemsRef, renderItem }: DragOverlayProps) { const { overlayActive, overlayItemId, overlayBaseX, overlayBaseY, overlayWidth, overlayHeight, overlayTranslateY, overlayReady, overlayVisible, scrollOffset, dragStartScrollOffset, draggedScale, isDragging, isDropping, overlayTouchOffset, overlayAbsoluteY, overlayContainerY, dragOverlayOpacity, overlayFrozenItemId, overlayRenderedOnUI, } = useDragState(); // Drive overlay crossfade animation on UI thread // This ensures smooth visibility transitions without JS scheduling races useOverlayCrossfade(); const [overlayId, setOverlayId] = useState(null); const [frozenId, setFrozenId] = useState(null); // O(1) lookup map for items by ID const itemMap = useMemo(() => { return new Map(data.map(item => [item.id, item])); }, [data]); const setOverlayIdSafe = useCallback((nextId: string | null) => { setOverlayId(nextId); }, []); const setFrozenIdSafe = useCallback((nextId: string | null) => { setFrozenId(nextId); }, []); useAnimatedReaction( () => overlayItemId.value, (current, prev) => { "worklet"; if (current === prev) return; scheduleOnRN(setOverlayIdSafe, current || null); }, [setOverlayIdSafe], ); // Track frozen ID for rendering during fade-out useAnimatedReaction( () => overlayFrozenItemId.value, (current, prev) => { "worklet"; if (current === prev) return; scheduleOnRN(setFrozenIdSafe, current || null); }, [setFrozenIdSafe], ); useEffect(() => { overlayVisible.value = Boolean(overlayId); }, [overlayId, overlayVisible]); // Use frozenId to keep overlay rendered during fade-out // Uses O(1) Map lookup instead of O(n) find() const overlayItem = useMemo(() => { const id = frozenId || overlayId; if (!id) return null; return itemMap.get(id) ?? null; }, [itemMap, frozenId, overlayId]); // Create disabled gesture and false isDragging for overlay rendering // This allows renderItem to show drag handle UI without functional gesture const disabledGesture = useMemo(() => Gesture.Pan().enabled(false), []); const falseIsDragging = useSharedValue(false); const overlayStyle = useAnimatedStyle(() => { // Use frozenItemId so overlay stays renderable during fade-out const itemId = overlayFrozenItemId.value || overlayItemId.value; // Keep rendering if frozenItemId is set (crossfade in progress) even if overlayReady is false. // This prevents the overlay from becoming invisible before the crossfade completes, // which would cause a brief moment where both overlay AND original are invisible. // FIXED: Also require overlayVisible to ensure JS-side overlayItem exists before fading. // overlayVisible is only cleared AFTER fade-out completes (in useOverlayCrossfade), // so this doesn't break fade-out. if (!itemId || (!overlayFrozenItemId.value && !overlayReady.value) || !overlayVisible.value) { // Mark overlay as NOT rendered when returning invisible style overlayRenderedOnUI.value = false; return { opacity: 0, position: "absolute" }; } // Mark overlay as rendered on UI thread - this is the KEY fix for the vanish bug. // Setting this here (in useAnimatedStyle, which runs on UI thread) instead of // in useEffect (JS thread) ensures the original item knows the overlay has // actually rendered BEFORE the crossfade starts. This prevents the race condition // where the original starts fading out before the overlay appears. overlayRenderedOnUI.value = true; // Include isDropping so scale animates smoothly during drop phase // Without this, scale jumps from 1.03 to 1.0 instantly when isDragging becomes false, // causing a ~1-2px visual jump due to the scale transform's center pivot point. const scale = (isDragging.value || isDropping.value) ? draggedScale.value : 1; // During active drag, use absolute finger position for reliable overlay tracking. // This avoids lag from JS-thread scroll offset updates during simultaneous scroll+drag. // Formula: top = absoluteY - touchOffset - containerY // Where: // - absoluteY = finger's current screen Y position // - touchOffset = how far down in the item the finger started // - containerY = overlay container's screen Y position // // During drop animation (isDropping), switch back to slot-based positioning // since the finger is no longer tracking and we need to animate to final slot. let top: number; if (isDragging.value) { // Active drag: follow finger precisely top = overlayAbsoluteY.value - overlayTouchOffset.value - overlayContainerY.value; } else { // Drop animation: use slot-based positioning with scroll compensation // Note: scrollDelta is SUBTRACTED because overlayBaseY was measured at drag start // (when scroll was dragStartScrollOffset). As user scrolls down (scrollDelta > 0), // the target slot moves UP visually, so we subtract. const scrollDelta = scrollOffset.value - dragStartScrollOffset.value; top = overlayBaseY.value + overlayTranslateY.value - scrollDelta; } return { position: "absolute", left: overlayBaseX.value, top, width: overlayWidth.value, height: overlayHeight.value, zIndex: 1000, transform: [{ scale }], opacity: dragOverlayOpacity.value, // Uses shared crossfade value }; }); // Only return null when BOTH overlayItem is null AND frozenId is empty // This keeps the Animated.View mounted during fade-out (while frozenId is set) if (!overlayItem && !frozenId) return null; const overlayIndex = overlayItem ? data.findIndex((item) => item.id === overlayItem.id) : -1; const totalItems = totalItemsRef.current ?? data.length; return ( {overlayItem && renderItem({ item: overlayItem, index: overlayIndex, totalItems, animatedStyle: {}, dragHandleProps: { gesture: disabledGesture, isDragging: falseIsDragging, }, isDragging: false, isDragEnabled: false, triggerExitAnimation: () => {}, resetExitAnimation: () => {}, })} ); }) as ( props: DragOverlayProps, ) => React.ReactElement | null; /** * v40.1: Shifted item overlays - renders copies of shifted items at their visual positions * during the drop transition to mask the FlashList repositioning glitch. * Uses viewport-relative Y positions captured at drop start. */ interface ShiftedItemOverlaysProps { data: T[]; totalItemsRef: React.RefObject; renderItem: AnimatedFlashListProps["renderItem"]; } const ShiftedItemOverlays = React.memo(function ShiftedItemOverlays< T extends AnimatedListItem, >({ data, totalItemsRef, renderItem }: ShiftedItemOverlaysProps) { const { shiftedOverlays, shiftedOverlaysActive, shiftedOverlaysRendered } = useDragState(); // State to hold overlay items on JS thread const [overlayItems, setOverlayItems] = useState< Array<{ item: T; position: { y: number; height: number }; }> >([]); // O(1) lookup map for items by ID - stored in ref to break cascade dependency // This avoids O(n) data.find() for each overlay item during drop rendering const itemMapRef = useRef>(new Map()); useEffect(() => { itemMapRef.current = new Map(data.map(item => [item.id, item])); }, [data]); // Callback to update overlay items from JS thread // v40.1: Y position is now viewport-relative (captured with scroll offset subtracted) // Uses itemMapRef for O(1) lookups and empty deps to break cascade dependency const updateOverlayItems = useCallback( ( overlays: Record< string, { baseY: number; height: number; shift: number; scrollAtCapture: number; } >, active: boolean, ) => { if (active && Object.keys(overlays).length > 0) { const itemMap = itemMapRef.current; const items = Object.entries(overlays) .map(([id, pos]) => ({ item: itemMap.get(id), // O(1) lookup instead of O(n) find() position: { y: pos.baseY, height: pos.height }, })) .filter( (x): x is { item: T; position: { y: number; height: number } } => x.item !== undefined, ); setOverlayItems(items); } else { setOverlayItems([]); } }, [], // Empty deps - uses ref to break cascade ); // Watch for overlay changes useAnimatedReaction( () => ({ active: shiftedOverlaysActive.value, overlays: shiftedOverlays.value, }), (state, prev) => { "worklet"; // Only update when state changes if ( prev !== null && state.active === prev.active && Object.keys(state.overlays).length === Object.keys(prev.overlays).length ) { return; } scheduleOnRN(updateOverlayItems, state.overlays, state.active); }, [updateOverlayItems], ); // v41.6: Use useLayoutEffect for earlier signal (runs after commit, before paint) // This reduces the timing gap between overlay render and originals being hidden useLayoutEffect(() => { shiftedOverlaysRendered.value = overlayItems.length > 0; }, [overlayItems.length, shiftedOverlaysRendered]); // Create disabled gesture and false isDragging for overlay rendering const disabledGesture = useMemo(() => Gesture.Pan().enabled(false), []); const falseIsDragging = useSharedValue(false); if (overlayItems.length === 0) return null; const totalItems = totalItemsRef.current ?? data.length; return ( <> {overlayItems.map(({ item, position }) => ( {renderItem({ item, index: data.findIndex((d) => d.id === item.id), totalItems, animatedStyle: {}, dragHandleProps: { gesture: disabledGesture, isDragging: falseIsDragging, }, isDragging: false, isDragEnabled: false, triggerExitAnimation: () => {}, resetExitAnimation: () => {}, })} ))} ); }) as ( props: ShiftedItemOverlaysProps, ) => React.ReactElement | null; const DragContainer = React.memo(function DragContainer({ children, }: { children: React.ReactNode; }) { const { overlayContainerX, overlayContainerY, overlayContainerYLayout, overlayContainerReady, dragContainerRef, } = useDragState(); const handleLayout = useCallback(() => { const node = dragContainerRef.current; if (!node) return; (node as unknown as View).measureInWindow((x: number, y: number) => { overlayContainerX.value = x; overlayContainerY.value = y; overlayContainerYLayout.value = y; // Keep measureInWindow value for shifted overlay calculations overlayContainerReady.value = true; }); }, [ overlayContainerX, overlayContainerY, overlayContainerYLayout, overlayContainerReady, dragContainerRef, ]); return ( {children} ); }); /** * Item wrapper component for FlashList * Uses ref for totalItems to avoid renderItem callback recreation */ interface ItemWrapperProps { item: T; index: number; totalItemsRef: React.RefObject; renderItem: AnimatedFlashListProps["renderItem"]; canDrag?: (item: T, index: number) => boolean; dragEnabled: boolean; onReorderByDelta?: (itemId: string, delta: number) => void; onHapticFeedback?: (type: HapticFeedbackType) => void; } const ItemWrapper = React.memo(function ItemWrapper< T extends AnimatedListItem, >({ item, index, totalItemsRef, renderItem, canDrag, dragEnabled, onReorderByDelta, onHapticFeedback, }: ItemWrapperProps) { const isDragEnabled = dragEnabled && (canDrag ? canDrag(item, index) : true); return ( ); }) as ( props: ItemWrapperProps, ) => React.ReactElement; /** * Inner FlashList component that uses DragStateContext for scroll tracking */ interface InnerFlashListProps { data: T[]; totalItemsRef: React.RefObject; flashListRef: React.RefObject | null>; renderItem: AnimatedFlashListProps["renderItem"]; keyExtractor: (item: T, index: number) => string; canDrag?: (item: T, index: number) => boolean; dragEnabled: boolean; onReorderByDelta?: (itemId: string, delta: number) => void; onHapticFeedback?: (type: HapticFeedbackType) => void; itemHeight: number; ListFooterComponent?: AnimatedFlashListProps["ListFooterComponent"]; ListEmptyComponent?: FlashListProps["ListEmptyComponent"]; onEndReached?: () => void; onEndReachedThreshold?: number; onRefresh?: () => void | Promise; refreshing?: boolean; refreshTintColor?: string; contentContainerStyle?: AnimatedFlashListProps["contentContainerStyle"]; drawDistance?: number; showsVerticalScrollIndicator?: boolean; extraData?: FlashListProps["extraData"]; onCommitLayoutEffect?: FlashListProps["onCommitLayoutEffect"]; } function InnerFlashList({ data, totalItemsRef, flashListRef, renderItem, keyExtractor, canDrag, dragEnabled, onReorderByDelta, onHapticFeedback, itemHeight, ListFooterComponent, ListEmptyComponent, onEndReached, onEndReachedThreshold, onRefresh, refreshing, refreshTintColor, contentContainerStyle, drawDistance = 500, showsVerticalScrollIndicator = true, extraData, onCommitLayoutEffect, }: InnerFlashListProps): React.ReactElement { // Get drag state context for scroll tracking const { scrollOffset, contentHeight, visibleHeight, listTopY, listLeftX, setListRef, totalItems, notifyLayoutCommit, isDragging, isDropping, itemIndexRegistry, } = useDragState(); // v40.1: Disable scroll while dropping to prevent overlay drift // FlashList needs scrollEnabled as a regular boolean, not a SharedValue const [scrollEnabled, setScrollEnabled] = useState(true); const setScrollEnabledSafe = useCallback((enabled: boolean) => { setScrollEnabled(enabled); }, []); useAnimatedReaction( () => isDragging.value || isDropping.value, (draggingOrDropping, prevDraggingOrDropping) => { "worklet"; if (draggingOrDropping !== prevDraggingOrDropping) { scheduleOnRN(setScrollEnabledSafe, !draggingOrDropping); } }, [setScrollEnabledSafe], ); // Callback ref that notifies DragStateContext when FlashList is available. // This fires immediately when FlashList sets the ref, unlike useEffect which // would run after render with flashListRef.current potentially still null. const handleFlashListRef = useCallback( (instance: FlashListRef | null) => { // Update the ref object for other uses (scrollToOffset, prepareForLayoutAnimationRender, etc.) (flashListRef as React.MutableRefObject | null>).current = instance; // Notify DragStateContext - this triggers native layout observation setup setListRef(instance as FlashListRef | null); }, [flashListRef, setListRef], ); // Sync totalItems SharedValue with data.length for worklet access useEffect(() => { totalItems.value = data.length; }, [data.length, totalItems]); // Prune stale entries from itemIndexRegistry when data changes. // Skip during active drag/drop to avoid corrupting indices mid-operation. useEffect(() => { if (isDragging.value || isDropping.value) return; const currentIds = new Set(data.map((item) => item.id)); const registry = itemIndexRegistry.value; const staleIds = Object.keys(registry).filter((id) => !currentIds.has(id)); if (staleIds.length > 0) { const pruned = { ...registry }; for (const id of staleIds) { delete pruned[id]; } itemIndexRegistry.value = pruned; } }, [data, itemIndexRegistry, isDragging, isDropping]); // Update scroll offset on scroll const handleScroll = useCallback( (event: NativeSyntheticEvent) => { scrollOffset.value = event.nativeEvent.contentOffset.y; }, [scrollOffset], ); // Update content height when list content changes const handleContentSizeChange = useCallback( (_width: number, height: number) => { contentHeight.value = height; }, [contentHeight], ); // Update visible height and list position when layout changes const handleLayout = useCallback( (event: LayoutChangeEvent) => { visibleHeight.value = event.nativeEvent.layout.height; // Get native scroll ref and validate it has measureInWindow before using const nativeRef = flashListRef.current?.getNativeScrollRef?.(); if ( nativeRef && typeof nativeRef === "object" && "measureInWindow" in nativeRef && typeof nativeRef.measureInWindow === "function" ) { nativeRef.measureInWindow((x: number, y: number) => { listTopY.value = y; listLeftX.value = x; }); } }, [visibleHeight, listTopY, listLeftX, flashListRef], ); const handleCommitLayoutEffect = useCallback(() => { notifyLayoutCommit(); onCommitLayoutEffect?.(); }, [notifyLayoutCommit, onCommitLayoutEffect]); // Render item for FlashList const flashListRenderItem = useCallback( ({ item, index }: ListRenderItemInfo) => ( ), [ totalItemsRef, renderItem, canDrag, dragEnabled, onReorderByDelta, onHapticFeedback, ], ); // getItemType for FlashList recycling optimization const getItemType = useCallback(() => "animated-item", []); // Override item layout for consistent drag calculations // Note: We cast the layout to include size for drag calculations const overrideItemLayout = useCallback( (layout: { span?: number }, _item: T, _index: number) => { // FlashList v2 uses this for span, but we extend for size in drag calculations (layout as { size?: number }).size = itemHeight; }, [itemHeight], ); return ( ) : undefined } /> ); } /** * AnimatedFlashList - High-performance animated list with drag-to-reorder * * A wrapper around @shopify/flash-list that provides: * - Smooth drag-to-reorder with autoscroll * - Entry animations for new items * - Exit animations for removed items * - Full TypeScript generics support * * @example * ```tsx * * data={items} * keyExtractor={(item) => item.id} * renderItem={({ item, animatedStyle, dragHandleProps }) => ( * * * {dragHandleProps && ( * * * * )} * * )} * dragEnabled * onReorder={(itemId, from, to) => reorderItems(itemId, from, to)} * /> * ``` */ function AnimatedFlashListInner( props: AnimatedFlashListProps, ref: React.ForwardedRef, ): React.ReactElement | null { const { data, keyExtractor, renderItem, dragEnabled = false, onReorder, onReorderByNeighbors, canDrag, onHapticFeedback, config, onPrepareLayoutAnimation, ListFooterComponent, ListEmptyComponent, onRefresh, refreshing = false, onEndReached, onEndReachedThreshold = 0.5, contentContainerStyle, ...flashListProps } = props; const { extraData: userExtraData, onCommitLayoutEffect: userOnCommitLayoutEffect, ...restFlashListProps } = flashListProps; // Merge config with defaults // Note: estimatedItemSize was removed in FlashList v2 // Users should configure itemHeight via config.drag.itemHeight const dragConfig = useMemo( () => ({ ...DEFAULT_DRAG_CONFIG, ...config?.drag, }), [config?.drag], ); // Ref to FlashList const flashListRef = useRef>(null); // Expose methods to parent via ref useImperativeHandle(ref, () => ({ prepareForLayoutAnimation: () => { flashListRef.current?.prepareForLayoutAnimationRender(); onPrepareLayoutAnimation?.(); }, scrollToOffset: (offset: number, animated = true) => { flashListRef.current?.scrollToOffset({ offset, animated }); }, scrollToIndex: (index: number, animated = true) => { flashListRef.current?.scrollToIndex({ index, animated }); }, })); // Keep valid items in ref for reorder callback const dataRef = useRef([]); dataRef.current = data; // Force FlashList to re-render visible cells after reorder to avoid // 1-frame gaps when internal recycling repositions without a React render. const [layoutVersion, setLayoutVersion] = useState(0); const bumpLayoutVersion = useCallback(() => { setLayoutVersion((version) => version + 1); }, []); // Handle reorder by index delta - converts to various callback formats // NOTE: No LayoutAnimation needed - layout commit compensation handles visual transitions const handleReorderByDelta = useCallback( (itemId: string, indexDelta: number) => { if (indexDelta === 0) return; const currentItems = dataRef.current; const currentIndex = currentItems.findIndex((item) => item.id === itemId); if (currentIndex === -1) return; const newIndex = Math.max( 0, Math.min(currentItems.length - 1, currentIndex + indexDelta), ); if (newIndex === currentIndex) return; // Call onReorder if provided if (onReorder) { onReorder(itemId, currentIndex, newIndex); } // Call onReorderByNeighbors if provided (for fractional indexing) if (onReorderByNeighbors) { let afterItemId: string | null = null; let beforeItemId: string | null = null; if (indexDelta > 0) { afterItemId = currentItems[newIndex]?.id ?? null; beforeItemId = newIndex < currentItems.length - 1 ? (currentItems[newIndex + 1]?.id ?? null) : null; } else { afterItemId = newIndex > 0 ? (currentItems[newIndex - 1]?.id ?? null) : null; beforeItemId = currentItems[newIndex]?.id ?? null; } onReorderByNeighbors(itemId, afterItemId, beforeItemId); } bumpLayoutVersion(); }, [onReorder, onReorderByNeighbors, bumpLayoutVersion], ); const combinedExtraData = useMemo( () => [userExtraData, layoutVersion], [userExtraData, layoutVersion], ); // Use ref for totalItems to avoid renderItem callback recreation const totalItemsRef = useRef(data.length); totalItemsRef.current = data.length; // Early return for empty data if (!data || !Array.isArray(data) || data.length === 0) { const emptyContent = ListEmptyComponent ? React.isValidElement(ListEmptyComponent) ? ListEmptyComponent : React.createElement(ListEmptyComponent as React.ComponentType) : null; const footerContent = ListFooterComponent ? React.isValidElement(ListFooterComponent) ? ListFooterComponent : React.createElement(ListFooterComponent as React.ComponentType) : null; if (emptyContent || footerContent) { return ( {emptyContent} {footerContent} ); } return null; } const plainFlashListFallback = ( >} data={data} renderItem={({ item, index }) => renderItem({ item, index, totalItems: data.length, animatedStyle: {}, dragHandleProps: null, isDragging: false, isDragEnabled: false, triggerExitAnimation: () => {}, resetExitAnimation: () => {}, }) } keyExtractor={keyExtractor} contentContainerStyle={contentContainerStyle} ListFooterComponent={ListFooterComponent ?? undefined} onEndReached={onEndReached} onEndReachedThreshold={onEndReachedThreshold} /> ); return ( ); } const containerStyle: ViewStyle = { flex: 1, overflow: 'hidden', }; // Forward ref with generic support export const AnimatedFlashList = forwardRef(AnimatedFlashListInner) as < T extends AnimatedListItem, >( props: AnimatedFlashListProps & { ref?: React.ForwardedRef; }, ) => React.ReactElement | null;