diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/bridge/UIManager.java b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/bridge/UIManager.java index 9a9724b12cd9d4..28f9ffb3bf45a4 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/bridge/UIManager.java +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/bridge/UIManager.java @@ -159,4 +159,22 @@ void updateRootLayoutSpecs( /** Called before React Native instance is destroyed. */ void invalidate(); + + /** + * Mark a view as currently active for a touch event. This information could be used by the + * [UIManager] to decide if a view could be safely destroyed or not. + * + * @param surfaceId The surface ID where the view is rendered. + * @param reactTag The react tag for the specific view + */ + void markActiveTouchForTag(int surfaceId, int reactTag); + + /** + * Sweep a view as currently not active for a touch event. This tells the [UIManager] that the + * view is not being interacted by the user and can safely be destroyed. + * + * @param surfaceId The surface ID where the view is rendered. + * @param reactTag The react tag for the specific view + */ + void sweepActiveTouchForTag(int surfaceId, int reactTag); } diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/FabricUIManager.java b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/FabricUIManager.java index db5fa13e85fb82..fa79f0df268386 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/FabricUIManager.java +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/FabricUIManager.java @@ -452,6 +452,16 @@ public void invalidate() { } } + @Override + public void markActiveTouchForTag(int surfaceId, int reactTag) { + mMountingManager.getSurfaceManager(surfaceId).markActiveTouchForTag(reactTag); + } + + @Override + public void sweepActiveTouchForTag(int surfaceId, int reactTag) { + mMountingManager.getSurfaceManager(surfaceId).sweepActiveTouchForTag(reactTag); + } + /** * Method added to Fabric for backward compatibility reasons, as users on Paper could call * [addUiBlock] and [prependUiBlock] on UIManagerModule. diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/mounting/SurfaceMountingManager.java b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/mounting/SurfaceMountingManager.java index 9ac95ccf65e142..0127ae229cb3d9 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/mounting/SurfaceMountingManager.java +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/mounting/SurfaceMountingManager.java @@ -76,6 +76,17 @@ public class SurfaceMountingManager { @ThreadConfined(UI) private final Set mErroneouslyReaddedReactTags = new HashSet<>(); + // This set is used to keep track of views that are currently being interacted with (i.e. + // views that saw a ACTION_DOWN but not a ACTION_UP event yet). This is used to prevent + // views from being removed while they are being interacted with as their event emitter will + // also be removed, and `Pressables` will look "stuck". + @ThreadConfined(UI) + private final Set mViewsWithActiveTouches = new HashSet<>(); + + // This set contains the views that are scheduled to be removed after their touch finishes. + @ThreadConfined(UI) + private final Set mViewsToDeleteAfterTouchFinishes = new HashSet<>(); + // This is null *until* StopSurface is called. private SparseArrayCompat mTagSetForStoppedSurface; @@ -1031,12 +1042,21 @@ public void deleteView(int reactTag) { return; } - // To delete we simply remove the tag from the registry. - // We want to rely on the correct set of MountInstructions being sent to the platform, - // or StopSurface being called, so we do not handle deleting descendents of the View. - mTagToViewState.remove(reactTag); + if (mViewsWithActiveTouches.contains(reactTag)) { + // If the view that went offscreen is still being touched, we can't delete it yet. + // We have to delay the deletion till the touch is completed. + // This is causing bugs like those otherwise: + // - https://github.com/facebook/react-native/issues/44610 + // - https://github.com/facebook/react-native/issues/45126 + mViewsToDeleteAfterTouchFinishes.add(reactTag); + } else { + // To delete we simply remove the tag from the registry. + // We want to rely on the correct set of MountInstructions being sent to the platform, + // or StopSurface being called, so we do not handle deleting descendants of the View. + mTagToViewState.remove(reactTag); - onViewStateDeleted(viewState); + onViewStateDeleted(viewState); + } } @UiThread @@ -1160,6 +1180,18 @@ public void run() { }); } + public void markActiveTouchForTag(int reactTag) { + mViewsWithActiveTouches.add(reactTag); + } + + public void sweepActiveTouchForTag(int reactTag) { + mViewsWithActiveTouches.remove(reactTag); + if (mViewsToDeleteAfterTouchFinishes.contains(reactTag)) { + mViewsToDeleteAfterTouchFinishes.remove(reactTag); + deleteView(reactTag); + } + } + /** * This class holds view state for react tags. Objects of this class are stored into the {@link * #mTagToViewState}, and they should be updated in the same thread. diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/JSTouchDispatcher.java b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/JSTouchDispatcher.java index 77143c90e30f93..2fac6585c70a17 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/JSTouchDispatcher.java +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/JSTouchDispatcher.java @@ -9,10 +9,16 @@ import android.view.MotionEvent; import android.view.ViewGroup; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import com.facebook.common.logging.FLog; import com.facebook.infer.annotation.Assertions; import com.facebook.infer.annotation.Nullsafe; +import com.facebook.react.ReactRootView; +import com.facebook.react.bridge.ReactContext; +import com.facebook.react.bridge.UIManager; import com.facebook.react.common.ReactConstants; +import com.facebook.react.uimanager.common.UIManagerType; import com.facebook.react.uimanager.events.EventDispatcher; import com.facebook.react.uimanager.events.TouchEvent; import com.facebook.react.uimanager.events.TouchEventCoalescingKeyHelper; @@ -30,12 +36,24 @@ public class JSTouchDispatcher { private final float[] mTargetCoordinates = new float[2]; private boolean mChildIsHandlingNativeGesture = false; private long mGestureStartTime = TouchEvent.UNSET; - private final ViewGroup mRootViewGroup; + + @Nullable private final ReactRootView mReactRootView; + @NonNull private final ViewGroup mViewGroup; + @Nullable private final ReactContext mReactContext; + private final TouchEventCoalescingKeyHelper mTouchEventCoalescingKeyHelper = new TouchEventCoalescingKeyHelper(); - public JSTouchDispatcher(ViewGroup viewGroup) { - mRootViewGroup = viewGroup; + public JSTouchDispatcher(ReactRootView rootView) { + mReactRootView = rootView; + mViewGroup = rootView; + mReactContext = null; + } + + public JSTouchDispatcher(ViewGroup viewGroup, ReactContext reactContext) { + mReactRootView = null; + mViewGroup = viewGroup; + mReactContext = reactContext; } public void onChildStartedNativeGesture( @@ -78,11 +96,13 @@ public void handleTouchEvent(MotionEvent ev, EventDispatcher eventDispatcher) { // this gesture mChildIsHandlingNativeGesture = false; mGestureStartTime = ev.getEventTime(); - mTargetTag = findTargetTagAndSetCoordinates(ev); + int surfaceId = UIManagerHelper.getSurfaceId(mViewGroup); + markActiveTouchForTag(surfaceId, mTargetTag); + eventDispatcher.dispatchEvent( TouchEvent.obtain( - UIManagerHelper.getSurfaceId(mRootViewGroup), + UIManagerHelper.getSurfaceId(mViewGroup), mTargetTag, TouchEventType.START, ev, @@ -105,9 +125,10 @@ public void handleTouchEvent(MotionEvent ev, EventDispatcher eventDispatcher) { // End of the gesture. We reset target tag to -1 and expect no further event associated with // this gesture. findTargetTagAndSetCoordinates(ev); + int surfaceId = UIManagerHelper.getSurfaceId(mViewGroup); eventDispatcher.dispatchEvent( TouchEvent.obtain( - UIManagerHelper.getSurfaceId(mRootViewGroup), + surfaceId, mTargetTag, TouchEventType.END, ev, @@ -115,14 +136,18 @@ public void handleTouchEvent(MotionEvent ev, EventDispatcher eventDispatcher) { mTargetCoordinates[0], mTargetCoordinates[1], mTouchEventCoalescingKeyHelper)); + + sweepActiveTouchForTag(surfaceId, mTargetTag); + mTargetTag = -1; + mGestureStartTime = TouchEvent.UNSET; } else if (action == MotionEvent.ACTION_MOVE) { // Update pointer position for current gesture findTargetTagAndSetCoordinates(ev); eventDispatcher.dispatchEvent( TouchEvent.obtain( - UIManagerHelper.getSurfaceId(mRootViewGroup), + UIManagerHelper.getSurfaceId(mViewGroup), mTargetTag, TouchEventType.MOVE, ev, @@ -134,7 +159,7 @@ public void handleTouchEvent(MotionEvent ev, EventDispatcher eventDispatcher) { // New pointer goes down, this can only happen after ACTION_DOWN is sent for the first pointer eventDispatcher.dispatchEvent( TouchEvent.obtain( - UIManagerHelper.getSurfaceId(mRootViewGroup), + UIManagerHelper.getSurfaceId(mViewGroup), mTargetTag, TouchEventType.START, ev, @@ -146,7 +171,7 @@ public void handleTouchEvent(MotionEvent ev, EventDispatcher eventDispatcher) { // Exactly one of the pointers goes up eventDispatcher.dispatchEvent( TouchEvent.obtain( - UIManagerHelper.getSurfaceId(mRootViewGroup), + UIManagerHelper.getSurfaceId(mViewGroup), mTargetTag, TouchEventType.END, ev, @@ -162,6 +187,9 @@ public void handleTouchEvent(MotionEvent ev, EventDispatcher eventDispatcher) { ReactConstants.TAG, "Received an ACTION_CANCEL touch event for which we have no corresponding ACTION_DOWN"); } + int surfaceId = UIManagerHelper.getSurfaceId(mViewGroup); + sweepActiveTouchForTag(surfaceId, mTargetTag); + mTargetTag = -1; mGestureStartTime = TouchEvent.UNSET; } else { @@ -171,10 +199,40 @@ public void handleTouchEvent(MotionEvent ev, EventDispatcher eventDispatcher) { } } + private void markActiveTouchForTag(int surfaceId, int reactTag) { + ReactContext context = null; + if (mReactContext != null) { + context = mReactContext; + } else if (mReactRootView != null) { + context = mReactRootView.getCurrentReactContext(); + } + if (context != null) { + UIManager uiManager = UIManagerHelper.getUIManager(context, UIManagerType.FABRIC); + if (uiManager != null) { + uiManager.markActiveTouchForTag(surfaceId, reactTag); + } + } + } + + private void sweepActiveTouchForTag(int surfaceId, int reactTag) { + ReactContext context = null; + if (mReactContext != null) { + context = mReactContext; + } else if (mReactRootView != null) { + context = mReactRootView.getCurrentReactContext(); + } + if (context != null) { + UIManager uiManager = UIManagerHelper.getUIManager(context, UIManagerType.FABRIC); + if (uiManager != null) { + uiManager.sweepActiveTouchForTag(surfaceId, reactTag); + } + } + } + private int findTargetTagAndSetCoordinates(MotionEvent ev) { // This method updates `mTargetCoordinates` with coordinates for the motion event. return TouchTargetHelper.findTargetTagAndCoordinatesForTouch( - ev.getX(), ev.getY(), mRootViewGroup, mTargetCoordinates, null); + ev.getX(), ev.getY(), mReactRootView, mTargetCoordinates, null); } private void dispatchCancelEvent(MotionEvent androidEvent, EventDispatcher eventDispatcher) { @@ -195,7 +253,7 @@ private void dispatchCancelEvent(MotionEvent androidEvent, EventDispatcher event Assertions.assertNotNull(eventDispatcher) .dispatchEvent( TouchEvent.obtain( - UIManagerHelper.getSurfaceId(mRootViewGroup), + UIManagerHelper.getSurfaceId(mReactRootView), mTargetTag, TouchEventType.CANCEL, androidEvent, diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/UIManagerModule.java b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/UIManagerModule.java index 09862f765d9de6..ea72dc0ca175c5 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/UIManagerModule.java +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/UIManagerModule.java @@ -207,6 +207,16 @@ public void invalidate() { ViewManagerPropertyUpdater.clear(); } + @Override + public void markActiveTouchForTag(int surfaceId, int reactTag) { + // Not implemented for Paper. + } + + @Override + public void sweepActiveTouchForTag(int surfaceId, int reactTag) { + // Not implemented for Paper. + } + /** * This method is intended to reuse the {@link ViewManagerRegistry} with FabricUIManager. Do not * use this method as this will be removed in the near future. diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/modal/ReactModalHostView.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/modal/ReactModalHostView.kt index f6e0d82fc01a43..18d3b540724a33 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/modal/ReactModalHostView.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/modal/ReactModalHostView.kt @@ -389,7 +389,8 @@ public class ReactModalHostView(context: ThemedReactContext) : private var hasAdjustedSize = false private var viewWidth = 0 private var viewHeight = 0 - private val jSTouchDispatcher: JSTouchDispatcher = JSTouchDispatcher(this) + private val jSTouchDispatcher: JSTouchDispatcher = + JSTouchDispatcher(this, context as ReactContext) private var jSPointerDispatcher: JSPointerDispatcher? = null internal var eventDispatcher: EventDispatcher? = null diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/testutils/fakes/FakeUIManager.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/testutils/fakes/FakeUIManager.kt index 439c0a61947525..42e6fe5f8e11c6 100644 --- a/packages/react-native/ReactAndroid/src/test/java/com/facebook/testutils/fakes/FakeUIManager.kt +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/testutils/fakes/FakeUIManager.kt @@ -114,6 +114,14 @@ class FakeUIManager : UIManager, UIBlockViewResolver { TODO("Not yet implemented") } + override fun markActiveTouchForTag(surfaceId: Int, reactTag: Int) { + TODO("Not yet implemented") + } + + override fun sweepActiveTouchForTag(surfaceId: Int, reactTag: Int) { + TODO("Not yet implemented") + } + override val performanceCounters: Map? get() = null }