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) {