From d343f7f3b1d8874c8fa137b7567c6ce60fbfba77 Mon Sep 17 00:00:00 2001 From: lumenwire <268715559+lumenwire@users.noreply.github.com> Date: Sun, 29 Mar 2026 16:17:26 +0200 Subject: [PATCH 1/4] fix: skip dataGen bump for reorder-only data changes When onReordered causes the parent to update the data prop with the same items in a new order, detect the pure-reorder case (same key set, same count) and skip bumping dataGenRef. This preserves cell keys so FlatList does not remount them mid-animation, eliminating the visual jump after a drag completes. Because FlatList now reuses row components across reorders, also resolve the active item's index by key lookup in onDragStart to avoid acting on a stale info.index from the prior render. --- src/index.tsx | 31 +++++++++++++++++++++++++------ 1 file changed, 25 insertions(+), 6 deletions(-) diff --git a/src/index.tsx b/src/index.tsx index e629df4..1314948 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -403,9 +403,25 @@ function DragListImpl( // Whenever new content arrives, we bump the generation number so stale animations don't continue // to apply. if (lastDataRef.current !== data) { + const prev = lastDataRef.current; + const prevKeys = + prev && prev.length + ? new Set(prev.map((it, i) => keyExtractorRef.current(it, i))) + : new Set(); + const sameSize = prevKeys.size === data.length; + const reorderOnly = + sameSize && + data.length > 0 && + data.every((it, i) => prevKeys.has(keyExtractorRef.current(it, i))); lastDataRef.current = data; - dataGenRef.current++; - reset(false); // Don't trigger re-render because we're already rendering. + if (reorderOnly) { + if (activeDataRef.current != null) { + reset(true); + } + } else { + dataGenRef.current++; + reset(false); // Don't trigger re-render because we're already rendering. + } } // For reasons unclear to me, you need this useLayoutEffect here -- _even if you have an empty @@ -421,11 +437,14 @@ function DragListImpl( const key = keyExtractorRef.current(info.item, info.index); const isActive = key === activeDataRef.current?.key; const onDragStart = () => { - // We don't allow dragging for lists less than 2 elements if (data.length > 1) { - activeDataRef.current = { index: info.index, key: key }; - panIndex.current = info.index; - setExtra({ activeKey: key, panIndex: info.index }); + const resolvedIndex = dataRef.current.findIndex( + (it, i) => keyExtractorRef.current(it, i) === key + ); + const index = resolvedIndex >= 0 ? resolvedIndex : info.index; + activeDataRef.current = { index, key }; + panIndex.current = index; + setExtra({ activeKey: key, panIndex: index }); } }; const onDragEnd = () => { From e740eec4e0f4b99d2cff46d770f3aee436cd08a2 Mon Sep 17 00:00:00 2001 From: lumenwire <268715559+lumenwire@users.noreply.github.com> Date: Sun, 29 Mar 2026 16:17:26 +0200 Subject: [PATCH 2/4] fix: prevent snap when drag released at original position When the user drags an item but drops it back at its starting position (no reorder), the active cell was still at translateY(pan) at the moment reset() switched it to translateY(anim). Zeroing pan before calling reset() ensures both values are 0 at the transition point, eliminating the visible snap. --- src/index.tsx | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/index.tsx b/src/index.tsx index 1314948..9a21c44 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -352,13 +352,11 @@ function DragListImpl( isReorderingRef.current = false; } } else { - // #76 - Only reset here if we're not going to reorder the list. If we are instead - // reordering the list, we reset once the parent updates data. Otherwise things will jump - // around visually. + pan.setValue(0); reset(); } }, - [] + [pan] ); const panResponder = useRef( From 688e349047033d0c1a700a7f4368ceb782a556b7 Mon Sep 17 00:00:00 2001 From: lumenwire <268715559+lumenwire@users.noreply.github.com> Date: Sun, 29 Mar 2026 16:17:26 +0200 Subject: [PATCH 3/4] fix: prevent item jump on long-press without actual drag movement Introduce isPanGranted through DragListContext so CellRendererComponent can distinguish a long-press touch (pan responder not yet granted) from a real drag. Guard the slide animation useEffect to return early when pan is not granted, preventing all cells from resetting their translateY to 0 on press-only interactions and causing a visible jump. --- src/DragListContext.tsx | 5 ++++- src/index.tsx | 11 ++++++++--- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/src/DragListContext.tsx b/src/DragListContext.tsx index 8ee2881..02f24b1 100644 --- a/src/DragListContext.tsx +++ b/src/DragListContext.tsx @@ -31,6 +31,7 @@ type ContextProps = { horizontal: boolean | null | undefined; children: React.ReactNode; dataGen: number; + isPanGranted: boolean; }; type DragListContextValue = Omit, "children">; @@ -48,6 +49,7 @@ export function DragListProvider({ horizontal, children, dataGen, + isPanGranted, }: ContextProps) { const value = useMemo( () => ({ @@ -58,8 +60,9 @@ export function DragListProvider({ layouts, horizontal, dataGen, + isPanGranted, }), - [activeData, keyExtractor, pan, panIndex, layouts, horizontal, dataGen] + [activeData, keyExtractor, pan, panIndex, layouts, horizontal, dataGen, isPanGranted] ); return ( diff --git a/src/index.tsx b/src/index.tsx index 9a21c44..1d2e42e 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -510,6 +510,7 @@ function DragListImpl( layouts={layouts} horizontal={props.horizontal} dataGen={dataGenRef.current} + isPanGranted={panGrantedRef.current} > (props: CellRendererProps) { layouts, horizontal, dataGen, + isPanGranted, } = useDragListContext(); const cellRef = useRef(null); const key = keyExtractor(item, index); @@ -610,7 +612,10 @@ function CellRendererComponent(props: CellRendererProps) { ); useEffect(() => { - if (activeData != null) { + if (activeData != null && !isPanGranted) { + return; + } + if (activeData != null && isPanGranted) { const activeKey = activeData.key; const activeIndex = activeData.index; @@ -633,12 +638,12 @@ function CellRendererComponent(props: CellRendererProps) { } } return Animated.timing(anim, { - duration: activeData?.key ? SLIDE_MILLIS : 0, + duration: activeData?.key && isPanGranted ? SLIDE_MILLIS : 0, easing: Easing.inOut(Easing.linear), toValue: 0, useNativeDriver: true, }).start(); - }, [index, panIndex, activeData]); + }, [index, panIndex, activeData, isPanGranted]); // This resets our anim whenever a next generation of data arrives, so things are never translated // to non-zero positions by the time we render new content. From 9a9682734586f64411ce026277a4a75be1ca9c21 Mon Sep 17 00:00:00 2001 From: lumenwire <268715559+lumenwire@users.noreply.github.com> Date: Sun, 29 Mar 2026 17:47:16 +0200 Subject: [PATCH 4/4] fix: prevent item jump on long-press and after reorder Two related jump bugs are fixed: 1. Long-press without drag movement caused other items to slide unexpectedly. The guard introduced in the previous commit used panGrantedRef (a ref) to pass isPanGranted through context. Refs don't trigger re-renders, so CellRendererComponent never reacted to the grant status change and the slide animation useEffect guard never fired. Fix: track isPanGranted as React state so context consumers re-render and the guard works correctly. 2. After a successful reorder, reset() was never called in the finally block, leaving activeDataRef non-null and pan in a stale state. A quick subsequent press could then inherit leftover translation and jump. Fix: call pan.setValue(0) and reset() synchronously in the finally block, and also in onDragStart to clear any leftover pan before a new drag begins. --- src/index.tsx | 49 +++++++++++++++++++++++++++++++------------------ 1 file changed, 31 insertions(+), 18 deletions(-) diff --git a/src/index.tsx b/src/index.tsx index 1d2e42e..dda2ba5 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -111,6 +111,7 @@ function DragListImpl( }); const layouts = useRef({}).current; const panGrantedRef = useRef(false); + const [isPanGranted, setIsPanGranted] = useState(false); const grantScrollPosRef = useRef(0); // Scroll pos when granted // The amount you need to add to the touched position to get to the active // item's center. @@ -179,6 +180,7 @@ function DragListImpl( grantScrollPosRef.current = scrollPos.current; setPan(0); panGrantedRef.current = true; + setIsPanGranted(true); flatWrapRefPosUpdatedRef.current = false; flatWrapRef.current?.measure((_x, _y, _width, _height, pageX, pageY) => { // Capture the latest y position upon starting a drag, because the @@ -350,6 +352,8 @@ function DragListImpl( await reorderRef.current?.(activeIndex, panIndex.current); } finally { isReorderingRef.current = false; + pan.setValue(0); + reset(); } } else { pan.setValue(0); @@ -381,22 +385,30 @@ function DragListImpl( /** * When you don't want to trigger a re-render, pass false so we don't setExtra. */ - const reset = useCallback((shouldSetExtra = true) => { - activeDataRef.current = null; - panIndex.current = -1; - // setPan(0); Deliberately not handled here in render path, but in useLayoutEffect - if (shouldSetExtra) { - setExtra({ - // Trigger re-render - activeKey: null, - panIndex: -1, - detritus: Math.random().toString(), - }); - } - panGrantedRef.current = false; - grantActiveCenterOffsetRef.current = 0; - clearAutoScrollTimer(); - }, []); + const reset = useCallback( + (shouldSetExtra = true) => { + activeDataRef.current = null; + panIndex.current = -1; + // Synchronously zero pan so the next item that becomes isActive + // never inherits a leftover translate from the previous drag + // (the async setPan in useLayoutEffect is not fast enough when the + // user presses a new item right after a reorder completes). + pan.setValue(0); + if (shouldSetExtra) { + setExtra({ + // Trigger re-render + activeKey: null, + panIndex: -1, + detritus: Math.random().toString(), + }); + } + panGrantedRef.current = false; + setIsPanGranted(false); + grantActiveCenterOffsetRef.current = 0; + clearAutoScrollTimer(); + }, + [pan] + ); // Whenever new content arrives, we bump the generation number so stale animations don't continue // to apply. @@ -440,6 +452,7 @@ function DragListImpl( (it, i) => keyExtractorRef.current(it, i) === key ); const index = resolvedIndex >= 0 ? resolvedIndex : info.index; + pan.setValue(0); activeDataRef.current = { index, key }; panIndex.current = index; setExtra({ activeKey: key, panIndex: index }); @@ -470,7 +483,7 @@ function DragListImpl( isActive, }); }, - [props.renderItem, data.length] + [props.renderItem, data.length, pan] ); const onDragScroll = useCallback( @@ -510,7 +523,7 @@ function DragListImpl( layouts={layouts} horizontal={props.horizontal} dataGen={dataGenRef.current} - isPanGranted={panGrantedRef.current} + isPanGranted={isPanGranted} >