fix(app): release iOS sidebar rows after an interrupted drag (#7159)

An iOS pan waiting for manual activation can deliver finger-up without finalizing, leaving the row active and the outer scroller locked. Fail a pending pan when its touch stream ends so the existing finalization path cancels the drag. Track activation immediately so an active drag keeps its normal completion path.

Based on the touch-release diagnosis in #5586. The native regression fails before the fix and passes on the Namespace iOS simulator.

Co-authored-by: Kassam Khoja <kassam@shopwrk.com>
This commit is contained in:
Mohamed Boudra
2026-10-16 22:30:27 +07:00
committed by GitHub
co-authored by Kassam Khoja
parent 78c7a9ce3a
commit 98559bf729
2 changed files with 45 additions and 14 deletions
@@ -0,0 +1,13 @@
context platform=ios timeout=60000 retries=0
env APP_ID=sh.paseo.debug
# Start with the sidebar closed. WORKSPACE_ROW is the testID of a visible,
# unselected workspace row. Hold past drag arming (180ms), below menu opening (450ms).
open "${APP_ID}"
press "label=\"Open menu\""
wait "id=\"${WORKSPACE_ROW}\"" 10000
longpress "id=\"${WORKSPACE_ROW}\"" 250
press "id=\"${WORKSPACE_ROW}\""
# A released drag must not swallow the next tap: navigation closes the sidebar.
wait "label=\"Open menu\"" 5000
close
@@ -58,10 +58,14 @@ index 2abb4a2..8ca967f 100644
onAnimValInit?: (animVals: ReturnType<typeof useAnimatedValues>) => void;
itemEnteringAnimation?: AnimateProps<Animated.View>["entering"];
diff --git a/node_modules/react-native-draggable-flatlist/src/components/DraggableFlatList.tsx b/node_modules/react-native-draggable-flatlist/src/components/DraggableFlatList.tsx
index 7c88afc..6d5719c 100644
index 7c88afc..a74140f 100644
--- a/node_modules/react-native-draggable-flatlist/src/components/DraggableFlatList.tsx
+++ b/node_modules/react-native-draggable-flatlist/src/components/DraggableFlatList.tsx
@@ -19,6 +19,7 @@ import {
@@ -16,9 +16,11 @@ import {
FlatList,
Gesture,
GestureDetector,
+ State,
} from "react-native-gesture-handler";
import Animated, {
runOnJS,
@@ -69,7 +73,7 @@ index 7c88afc..6d5719c 100644
useAnimatedReaction,
useAnimatedScrollHandler,
useSharedValue,
@@ -92,6 +93,17 @@ function DraggableFlatListInner<T>(props: DraggableFlatListProps<T>) {
@@ -92,6 +94,17 @@ function DraggableFlatListInner<T>(props: DraggableFlatListProps<T>) {
activeCellOffset.value = -1;
setActiveKey(null);
});
@@ -87,7 +91,7 @@ index 7c88afc..6d5719c 100644
const {
dragHitSlop = DEFAULT_PROPS.dragHitSlop,
@@ -144,19 +156,88 @@ function DraggableFlatListInner<T>(props: DraggableFlatListProps<T>) {
@@ -144,19 +157,88 @@ function DraggableFlatListInner<T>(props: DraggableFlatListProps<T>) {
});
}, [props.data, keyExtractor, keyToIndexRef]);
@@ -183,7 +187,7 @@ index 7c88afc..6d5719c 100644
setActiveKey(activeKey);
onDragBegin?.(index);
}
@@ -218,8 +299,25 @@ function DraggableFlatListInner<T>(props: DraggableFlatListProps<T>) {
@@ -218,8 +300,25 @@ function DraggableFlatListInner<T>(props: DraggableFlatListProps<T>) {
props.onRelease?.(index);
});
@@ -210,7 +214,7 @@ index 7c88afc..6d5719c 100644
const { onDragEnd, data } = props;
const newData = [...data];
@@ -238,26 +336,6 @@ function DraggableFlatListInner<T>(props: DraggableFlatListProps<T>) {
@@ -238,26 +337,6 @@ function DraggableFlatListInner<T>(props: DraggableFlatListProps<T>) {
props.onPlaceholderIndexChange?.(index);
});
@@ -237,7 +241,7 @@ index 7c88afc..6d5719c 100644
useAnimatedReaction(
() => {
return spacerIndexAnim.value;
@@ -271,11 +349,21 @@ function DraggableFlatListInner<T>(props: DraggableFlatListProps<T>) {
@@ -271,11 +350,24 @@ function DraggableFlatListInner<T>(props: DraggableFlatListProps<T>) {
);
const gestureDisabled = useSharedValue(false);
@@ -257,10 +261,13 @@ index 7c88afc..6d5719c 100644
+ gestureSessionActive.value = true;
+ terminalHandled.value = false;
+ runOnJS(onGestureBegin)(sessionId);
+ panGestureState.value = evt.state;
+ })
+ .onStart((evt) => {
panGestureState.value = evt.state;
})
.onUpdate((evt) => {
@@ -286,8 +374,15 @@ function DraggableFlatListInner<T>(props: DraggableFlatListProps<T>) {
@@ -286,8 +378,15 @@ function DraggableFlatListInner<T>(props: DraggableFlatListProps<T>) {
: evt.translationY;
touchTranslate.value = translation;
})
@@ -277,7 +284,7 @@ index 7c88afc..6d5719c 100644
// Set touch val to current translate val
isTouchActiveNative.value = false;
const translation = horizontalAnim.value
@@ -299,6 +394,7 @@ function DraggableFlatListInner<T>(props: DraggableFlatListProps<T>) {
@@ -299,6 +398,7 @@ function DraggableFlatListInner<T>(props: DraggableFlatListProps<T>) {
// Only call onDragEnd if actually dragging a cell
if (activeIndexAnim.value === -1 || disabled.value) return;
@@ -285,7 +292,7 @@ index 7c88afc..6d5719c 100644
disabled.value = true;
runOnJS(onRelease)(activeIndexAnim.value);
const springTo = placeholderOffset.value - activeCellOffset.value;
@@ -309,14 +405,64 @@ function DraggableFlatListInner<T>(props: DraggableFlatListProps<T>) {
@@ -309,22 +409,84 @@ function DraggableFlatListInner<T>(props: DraggableFlatListProps<T>) {
runOnJS(onDragEnd)({
from: activeIndexAnim.value,
to: spacerIndexAnim.value,
@@ -316,6 +323,7 @@ index 7c88afc..6d5719c 100644
+ }
runOnJS(onContainerTouchStart)();
})
- .onTouchesUp(() => {
+ .onTouchesMove((event, stateManager) => {
+ if (!coordinatesDragWithExternalGesture) return;
+
@@ -348,10 +356,20 @@ index 7c88afc..6d5719c 100644
+ stateManager.activate();
+ }
+ })
.onTouchesUp(() => {
+ .onTouchesUp((event, stateManager) => {
// Turning this into a worklet causes timing issues. We want it to run
// just after the finger lifts.
@@ -324,7 +470,9 @@ function DraggableFlatListInner<T>(props: DraggableFlatListProps<T>) {
runOnJS(onContainerTouchEnd)();
+ // A manually activated iOS pan can remain BEGAN after the last finger
+ // lifts. Fail it explicitly so onFinalize cancels the armed row.
+ if (event.numberOfTouches === 0 && panGestureState.value === State.BEGAN) {
+ stateManager.fail();
+ }
+ })
+ .onTouchesCancelled((_event, stateManager) => {
+ if (panGestureState.value === State.BEGAN) {
+ stateManager.fail();
+ }
});
if (dragHitSlop) panGesture.hitSlop(dragHitSlop);
@@ -362,7 +380,7 @@ index 7c88afc..6d5719c 100644
const activeOffset = [-activationDistanceProp, activationDistanceProp];
if (props.horizontal) {
panGesture.activeOffsetX(activeOffset);
@@ -332,6 +480,21 @@ function DraggableFlatListInner<T>(props: DraggableFlatListProps<T>) {
@@ -332,6 +494,21 @@ function DraggableFlatListInner<T>(props: DraggableFlatListProps<T>) {
panGesture.activeOffsetY(activeOffset);
}
}
@@ -384,7 +402,7 @@ index 7c88afc..6d5719c 100644
const onScroll = useStableCallback((scrollOffset: number) => {
props.onScrollOffsetChange?.(scrollOffset);
@@ -373,8 +536,9 @@ function DraggableFlatListInner<T>(props: DraggableFlatListProps<T>) {
@@ -373,8 +550,9 @@ function DraggableFlatListInner<T>(props: DraggableFlatListProps<T>) {
horizontal={!!props.horizontal}
layoutAnimationDisabled={layoutAnimationDisabled}
>