diff --git a/apps/common-app/src/new_api/index.tsx b/apps/common-app/src/new_api/index.tsx
index 953e2241c6..d61663f5ab 100644
--- a/apps/common-app/src/new_api/index.tsx
+++ b/apps/common-app/src/new_api/index.tsx
@@ -35,6 +35,7 @@ import PinchExample from './simple/pinch';
import RotationExample from './simple/rotation';
import TapExample from './simple/tap';
import KeyboardShouldPersistTapsExample from './tests/keyboardShouldPersistTaps';
+import MovedDetectorExample from './tests/movedDetector';
import NativeInterceptCancellationExample from './tests/nativeInterceptCancellation';
import NestedPressablesExample from './tests/nestedPressables';
import NestedRootViewExample from './tests/nestedRootView';
@@ -128,6 +129,7 @@ export const NEW_EXAMPLES: ExamplesSection[] = [
},
{ name: 'Pointer Type', component: PointerTypeExample },
{ name: 'Reattaching', component: ReattachingExample },
+ { name: 'Moved detector', component: MovedDetectorExample },
{ name: 'Modal with Nested Root View', component: NestedRootViewExample },
{ name: 'Nested pressables', component: NestedPressablesExample },
{ name: 'Nested touchables', component: NestedTouchablesExample },
diff --git a/apps/common-app/src/new_api/tests/movedDetector/index.tsx b/apps/common-app/src/new_api/tests/movedDetector/index.tsx
new file mode 100644
index 0000000000..c52f9e8139
--- /dev/null
+++ b/apps/common-app/src/new_api/tests/movedDetector/index.tsx
@@ -0,0 +1,115 @@
+import React, { useState } from 'react';
+import { StyleSheet, Text, View } from 'react-native';
+import { GestureDetector, usePanGesture } from 'react-native-gesture-handler';
+
+import { COLORS, useIndexedLogger } from '../../../common';
+
+// Grabbing a row moves it to the top while its pan is active, the way a
+// drag-to-reorder list moves a held row. Fabric moves the row's view by
+// removing it and adding it back, so the detector is off the window for a
+// moment. The pan must stay active through that and end only on release: the
+// status reads "ended", not "cancelled".
+
+type RowData = { id: string; color: string };
+
+const INITIAL_ROWS: RowData[] = [
+ { id: 'A', color: COLORS.NAVY },
+ { id: 'B', color: COLORS.RED },
+ { id: 'C', color: COLORS.GREEN },
+ { id: 'D', color: COLORS.PURPLE },
+];
+
+type RowProps = RowData & {
+ onGrab: (id: string) => void;
+ onRelease: (canceled: boolean) => void;
+ log: (message: string) => void;
+};
+
+function Row({ id, color, onGrab, onRelease, log }: RowProps) {
+ const pan = usePanGesture({
+ runOnJS: true,
+ onActivate: () => {
+ log(`${id}: onActivate`);
+ onGrab(id);
+ },
+ onDeactivate: (event) => {
+ log(`${id}: onDeactivate, canceled: ${event.canceled}`);
+ onRelease(event.canceled);
+ },
+ });
+
+ return (
+
+
+ Drag row {id}
+
+
+ );
+}
+
+export default function MovedDetectorExample() {
+ const [rows, setRows] = useState(INITIAL_ROWS);
+ const [status, setStatus] = useState('idle');
+ const log = useIndexedLogger();
+
+ const onGrab = (id: string) => {
+ setStatus('active');
+ setRows((current) => [
+ ...current.filter((row) => row.id === id),
+ ...current.filter((row) => row.id !== id),
+ ]);
+ };
+
+ const onRelease = (canceled: boolean) => {
+ setStatus(canceled ? 'cancelled' : 'ended');
+ };
+
+ return (
+
+
+ Drag any row but the top one and keep moving. It moves to the top as the
+ pan starts. When you let go, the status must read "ended", not
+ "cancelled".
+
+
+ Pan: {status}
+
+ {rows.map((row) => (
+
+ ))}
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ padding: 20,
+ },
+ hint: {
+ marginBottom: 10,
+ },
+ status: {
+ fontSize: 18,
+ marginBottom: 20,
+ },
+ row: {
+ height: 80,
+ borderRadius: 12,
+ marginBottom: 12,
+ justifyContent: 'center',
+ alignItems: 'center',
+ },
+ rowText: {
+ color: 'white',
+ fontSize: 16,
+ },
+});
diff --git a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerDetectorView.kt b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerDetectorView.kt
index 65440c5bec..38379db93c 100644
--- a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerDetectorView.kt
+++ b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerDetectorView.kt
@@ -1,6 +1,8 @@
package com.swmansion.gesturehandler.react
import android.content.Context
+import android.os.Handler
+import android.os.Looper
import android.view.MotionEvent
import android.view.View
import com.facebook.react.bridge.ReadableArray
@@ -31,6 +33,17 @@ class RNGestureHandlerDetectorView(context: Context) : ReactViewGroup(context) {
// intercepting ancestor reuses the intercepted event, so it matches that event's time.
private var lastNativeCancelTime = -1L
+ // Fabric moves a view (a reorder, or reparenting when an ancestor stops being flattened) by
+ // removing it and adding it back within one mount, so the view is off the window only for that
+ // moment. Detaching right away would cancel a gesture in progress, so the detach waits until
+ // after the mount and is skipped if the view is back on the window by then.
+ private val mainHandler = Handler(Looper.getMainLooper())
+ private val detachIfStillDetached = Runnable {
+ if (!isAttachedToWindow) {
+ detachAllHandlers()
+ }
+ }
+
data class VirtualChildren(val handlerTags: List, val viewTag: Int)
fun setHandlerTags(handlerTags: ReadableArray?) {
@@ -42,6 +55,7 @@ class RNGestureHandlerDetectorView(context: Context) : ReactViewGroup(context) {
override fun onAttachedToWindow() {
super.onAttachedToWindow()
+ mainHandler.removeCallbacks(detachIfStillDetached)
if (moduleId != -1) {
attachHandlers(handlersToAttach)
attachVirtualChildren(virtualChildrenToAttach)
@@ -49,7 +63,7 @@ class RNGestureHandlerDetectorView(context: Context) : ReactViewGroup(context) {
}
override fun onDetachedFromWindow() {
- detachAllHandlers()
+ mainHandler.post(detachIfStillDetached)
super.onDetachedFromWindow()
}
@@ -273,6 +287,8 @@ class RNGestureHandlerDetectorView(context: Context) : ReactViewGroup(context) {
}
fun detachAllHandlers() {
+ // A view dropped with a detach pending must not lose the handlers it gets when it's reused.
+ mainHandler.removeCallbacks(detachIfStillDetached)
RNGestureHandlerModule.registries[moduleId]?.let { registry ->
registry.cancelAllObservationsForOwner(this)
for (tag in attachedHandlers) {