From ab44550f6e7dec2938cfa41b9275924aee500924 Mon Sep 17 00:00:00 2001 From: Nicola Corti Date: Fri, 5 Apr 2024 11:12:02 -0700 Subject: [PATCH] Convert ReactModalHostView to Kotlin (#43845) Summary: Pull Request resolved: https://github.com/facebook/react-native/pull/43845 This class is quite legacy and requires a couple of eyes before merging it. I've tested that modals are working fine on RN-Tester both Old & New Arch. Changelog: [Internal] [Changed] - ReactModalHostView to Kotlin Reviewed By: javache Differential Revision: D55739128 fbshipit-source-id: 740d24df39ceb7b6a8120ae5315cf0684d1e6e27 --- .../ReactAndroid/api/ReactAndroid.api | 48 +- .../react/views/modal/ReactModalHostView.java | 629 ------------------ .../react/views/modal/ReactModalHostView.kt | 540 +++++++++++++++ 3 files changed, 574 insertions(+), 643 deletions(-) delete mode 100644 packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/modal/ReactModalHostView.java create mode 100644 packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/modal/ReactModalHostView.kt diff --git a/packages/react-native/ReactAndroid/api/ReactAndroid.api b/packages/react-native/ReactAndroid/api/ReactAndroid.api index 3d69c3bd0c8..7278a6d56f4 100644 --- a/packages/react-native/ReactAndroid/api/ReactAndroid.api +++ b/packages/react-native/ReactAndroid/api/ReactAndroid.api @@ -6255,32 +6255,52 @@ public class com/facebook/react/views/modal/ReactModalHostManager$$PropsSetter : public fun setProperty (Lcom/facebook/react/views/modal/ReactModalHostManager;Lcom/facebook/react/views/modal/ReactModalHostView;Ljava/lang/String;Ljava/lang/Object;)V } -public class com/facebook/react/views/modal/ReactModalHostView : android/view/ViewGroup, com/facebook/react/bridge/LifecycleEventListener { +public final class com/facebook/react/views/modal/ReactModalHostView : android/view/ViewGroup, com/facebook/react/bridge/LifecycleEventListener { public fun (Lcom/facebook/react/uimanager/ThemedReactContext;)V public fun addChildrenForAccessibility (Ljava/util/ArrayList;)V public fun addView (Landroid/view/View;I)V public fun dispatchPopulateAccessibilityEvent (Landroid/view/accessibility/AccessibilityEvent;)Z public fun dispatchProvideStructure (Landroid/view/ViewStructure;)V + public final fun getAnimationType ()Ljava/lang/String; public fun getChildAt (I)Landroid/view/View; public fun getChildCount ()I - public fun getStateWrapper ()Lcom/facebook/react/uimanager/StateWrapper; - protected fun onDetachedFromWindow ()V - public fun onDropInstance ()V + public final fun getEventDispatcher ()Lcom/facebook/react/uimanager/events/EventDispatcher; + public final fun getHardwareAccelerated ()Z + public final fun getOnRequestCloseListener ()Lcom/facebook/react/views/modal/ReactModalHostView$OnRequestCloseListener; + public final fun getOnShowListener ()Landroid/content/DialogInterface$OnShowListener; + public final fun getStateWrapper ()Lcom/facebook/react/uimanager/StateWrapper; + public final fun getStatusBarTranslucent ()Z + public final fun getTransparent ()Z + public final fun onDropInstance ()V public fun onHostDestroy ()V public fun onHostPause ()V public fun onHostResume ()V - protected fun onLayout (ZIIII)V public fun removeView (Landroid/view/View;)V public fun removeViewAt (I)V - protected fun setAnimationType (Ljava/lang/String;)V - protected fun setHardwareAccelerated (Z)V - protected fun setOnRequestCloseListener (Lcom/facebook/react/views/modal/ReactModalHostView$OnRequestCloseListener;)V - protected fun setOnShowListener (Landroid/content/DialogInterface$OnShowListener;)V - public fun setStateWrapper (Lcom/facebook/react/uimanager/StateWrapper;)V - protected fun setStatusBarTranslucent (Z)V - protected fun setTransparent (Z)V - protected fun showOrUpdate ()V - public fun updateState (II)V + public final fun setAnimationType (Ljava/lang/String;)V + public final fun setEventDispatcher (Lcom/facebook/react/uimanager/events/EventDispatcher;)V + public final fun setHardwareAccelerated (Z)V + public final fun setOnRequestCloseListener (Lcom/facebook/react/views/modal/ReactModalHostView$OnRequestCloseListener;)V + public final fun setOnShowListener (Landroid/content/DialogInterface$OnShowListener;)V + public final fun setStateWrapper (Lcom/facebook/react/uimanager/StateWrapper;)V + public final fun setStatusBarTranslucent (Z)V + public final fun setTransparent (Z)V + public final fun showOrUpdate ()V + public final fun updateState (II)V +} + +public final class com/facebook/react/views/modal/ReactModalHostView$DialogRootViewGroup : com/facebook/react/views/view/ReactViewGroup, com/facebook/react/uimanager/RootView { + public fun (Lcom/facebook/react/views/modal/ReactModalHostView;Landroid/content/Context;)V + public fun addView (Landroid/view/View;ILandroid/view/ViewGroup$LayoutParams;)V + public fun handleException (Ljava/lang/Throwable;)V + public fun onChildEndedNativeGesture (Landroid/view/View;Landroid/view/MotionEvent;)V + public fun onChildStartedNativeGesture (Landroid/view/View;Landroid/view/MotionEvent;)V + public fun onHoverEvent (Landroid/view/MotionEvent;)Z + public fun onInterceptHoverEvent (Landroid/view/MotionEvent;)Z + public fun onInterceptTouchEvent (Landroid/view/MotionEvent;)Z + public fun onTouchEvent (Landroid/view/MotionEvent;)Z + public fun requestDisallowInterceptTouchEvent (Z)V + public final fun updateState (II)V } public abstract interface class com/facebook/react/views/modal/ReactModalHostView$OnRequestCloseListener { diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/modal/ReactModalHostView.java b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/modal/ReactModalHostView.java deleted file mode 100644 index c52bc90d898..00000000000 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/modal/ReactModalHostView.java +++ /dev/null @@ -1,629 +0,0 @@ -/* - * Copyright (c) Meta Platforms, Inc. and affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */ - -package com.facebook.react.views.modal; - -import android.annotation.TargetApi; -import android.app.Activity; -import android.app.Dialog; -import android.content.Context; -import android.content.DialogInterface; -import android.os.Build; -import android.view.KeyEvent; -import android.view.MotionEvent; -import android.view.View; -import android.view.ViewGroup; -import android.view.ViewStructure; -import android.view.Window; -import android.view.WindowInsetsController; -import android.view.WindowManager; -import android.view.accessibility.AccessibilityEvent; -import android.widget.FrameLayout; -import androidx.annotation.Nullable; -import androidx.annotation.UiThread; -import com.facebook.common.logging.FLog; -import com.facebook.infer.annotation.Assertions; -import com.facebook.infer.annotation.Nullsafe; -import com.facebook.react.R; -import com.facebook.react.bridge.GuardedRunnable; -import com.facebook.react.bridge.LifecycleEventListener; -import com.facebook.react.bridge.ReactContext; -import com.facebook.react.bridge.ReadableMap; -import com.facebook.react.bridge.UiThreadUtil; -import com.facebook.react.bridge.WritableMap; -import com.facebook.react.bridge.WritableNativeMap; -import com.facebook.react.common.annotations.VisibleForTesting; -import com.facebook.react.config.ReactFeatureFlags; -import com.facebook.react.uimanager.JSPointerDispatcher; -import com.facebook.react.uimanager.JSTouchDispatcher; -import com.facebook.react.uimanager.PixelUtil; -import com.facebook.react.uimanager.RootView; -import com.facebook.react.uimanager.StateWrapper; -import com.facebook.react.uimanager.ThemedReactContext; -import com.facebook.react.uimanager.UIManagerModule; -import com.facebook.react.uimanager.events.EventDispatcher; -import com.facebook.react.views.common.ContextUtils; -import com.facebook.react.views.view.ReactViewGroup; -import java.util.ArrayList; -import java.util.Objects; - -/** - * ReactModalHostView is a view that sits in the view hierarchy representing a Modal view. - * - *

It does a number of things: - * - *

    - *
  1. It creates a Dialog. We use this Dialog to actually display the Modal in the window. - *
  2. It creates a DialogRootViewGroup. This view is the view that is displayed by the Dialog. To - * display a view within a Dialog, that view must have its parent set to the window the Dialog - * creates. Because of this, we can not use the ReactModalHostView since it sits in the normal - * React view hierarchy. We do however want all of the layout magic to happen as if the - * DialogRootViewGroup were part of the hierarchy. Therefore, we forward all view changes - * around addition and removal of views to the DialogRootViewGroup. - *
- */ -@Nullsafe(Nullsafe.Mode.LOCAL) -public class ReactModalHostView extends ViewGroup implements LifecycleEventListener { - - private static final String TAG = "ReactModalHost"; - - // This listener is called when the user presses KeyEvent.KEYCODE_BACK - // An event is then passed to JS which can either close or not close the Modal by setting the - // visible property - public interface OnRequestCloseListener { - - void onRequestClose(DialogInterface dialog); - } - - private DialogRootViewGroup mHostView; - private @Nullable Dialog mDialog; - private boolean mTransparent; - private boolean mStatusBarTranslucent; - private @Nullable String mAnimationType; - private boolean mHardwareAccelerated; - // Set this flag to true if changing a particular property on the view requires a new Dialog to - // be created. For instance, animation does since it affects Dialog creation through the theme - // but transparency does not since we can access the window to update the property. - private boolean mPropertyRequiresNewDialog; - private @Nullable DialogInterface.OnShowListener mOnShowListener; - private @Nullable OnRequestCloseListener mOnRequestCloseListener; - - public ReactModalHostView(ThemedReactContext context) { - super(context); - context.addLifecycleEventListener(this); - - mHostView = new DialogRootViewGroup(context); - } - - @TargetApi(23) - @Override - public void dispatchProvideStructure(ViewStructure structure) { - mHostView.dispatchProvideStructure(structure); - } - - @Override - protected void onLayout(boolean changed, int l, int t, int r, int b) { - // Do nothing as we are laid out by UIManager - } - - @Override - protected void onDetachedFromWindow() { - super.onDetachedFromWindow(); - dismiss(); - } - - @Override - public void addView(@Nullable View child, int index) { - UiThreadUtil.assertOnUiThread(); - - mHostView.addView(child, index); - } - - @Override - public int getChildCount() { - // This method may be called by the parent constructor - // before mHostView is initialized. - return mHostView == null ? 0 : mHostView.getChildCount(); - } - - @Override - public View getChildAt(int index) { - return mHostView.getChildAt(index); - } - - @Override - public void removeView(@Nullable View child) { - UiThreadUtil.assertOnUiThread(); - - if (child != null) { - mHostView.removeView(child); - } - } - - @Override - public void removeViewAt(int index) { - UiThreadUtil.assertOnUiThread(); - - View child = getChildAt(index); - mHostView.removeView(child); - } - - @Override - public void addChildrenForAccessibility(ArrayList outChildren) { - // Explicitly override this to prevent accessibility events being passed down to children - // Those will be handled by the mHostView which lives in the dialog - } - - @Override - public boolean dispatchPopulateAccessibilityEvent(AccessibilityEvent event) { - // Explicitly override this to prevent accessibility events being passed down to children - // Those will be handled by the mHostView which lives in the dialog - return false; - } - - public void onDropInstance() { - ((ThemedReactContext) getContext()).removeLifecycleEventListener(this); - dismiss(); - } - - private void dismiss() { - UiThreadUtil.assertOnUiThread(); - - if (mDialog != null) { - if (mDialog.isShowing()) { - Activity dialogContext = - ContextUtils.findContextOfType(mDialog.getContext(), Activity.class); - if (dialogContext == null || !dialogContext.isFinishing()) { - mDialog.dismiss(); - } - } - mDialog = null; - - // We need to remove the mHostView from the parent - // It is possible we are dismissing this dialog and reattaching the hostView to another - ViewGroup parent = (ViewGroup) mHostView.getParent(); - if (parent != null) { - parent.removeViewAt(0); - } - } - } - - protected void setOnRequestCloseListener(OnRequestCloseListener listener) { - mOnRequestCloseListener = listener; - } - - protected void setOnShowListener(DialogInterface.OnShowListener listener) { - mOnShowListener = listener; - } - - protected void setTransparent(boolean transparent) { - mTransparent = transparent; - } - - protected void setStatusBarTranslucent(boolean statusBarTranslucent) { - mStatusBarTranslucent = statusBarTranslucent; - mPropertyRequiresNewDialog = true; - } - - protected void setAnimationType(String animationType) { - mAnimationType = animationType; - mPropertyRequiresNewDialog = true; - } - - protected void setHardwareAccelerated(boolean hardwareAccelerated) { - mHardwareAccelerated = hardwareAccelerated; - mPropertyRequiresNewDialog = true; - } - - void setEventDispatcher(EventDispatcher eventDispatcher) { - mHostView.setEventDispatcher(eventDispatcher); - } - - @Override - public void onHostResume() { - // We show the dialog again when the host resumes - showOrUpdate(); - } - - @Override - public void onHostPause() { - // do nothing - } - - @Override - public void onHostDestroy() { - // Drop the instance if the host is destroyed which will dismiss the dialog - onDropInstance(); - } - - @VisibleForTesting - public @Nullable Dialog getDialog() { - return mDialog; - } - - private @Nullable Activity getCurrentActivity() { - return ((ThemedReactContext) getContext()).getCurrentActivity(); - } - - /** - * showOrUpdate will display the Dialog. It is called by the manager once all properties are set - * because we need to know all of them before creating the Dialog. It is also smart during updates - * if the changed properties can be applied directly to the Dialog or require the recreation of a - * new Dialog. - */ - protected void showOrUpdate() { - UiThreadUtil.assertOnUiThread(); - - // If the existing Dialog is currently up, we may need to redraw it or we may be able to update - // the property without having to recreate the dialog - if (mDialog != null) { - Context dialogContext = ContextUtils.findContextOfType(mDialog.getContext(), Activity.class); - // TODO(T85755791): remove after investigation - FLog.e(TAG, "Updating existing dialog with context: " + dialogContext); - - if (mPropertyRequiresNewDialog) { - dismiss(); - } else { - updateProperties(); - return; - } - } - - // Reset the flag since we are going to create a new dialog - mPropertyRequiresNewDialog = false; - int theme = R.style.Theme_FullScreenDialog; - if (mAnimationType != null && mAnimationType.equals("fade")) { - theme = R.style.Theme_FullScreenDialogAnimatedFade; - } else if (mAnimationType != null && mAnimationType.equals("slide")) { - theme = R.style.Theme_FullScreenDialogAnimatedSlide; - } - Activity currentActivity = getCurrentActivity(); - Context context = currentActivity == null ? getContext() : currentActivity; - mDialog = new Dialog(context, theme); - Objects.requireNonNull(mDialog.getWindow()) - .setFlags( - WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE, - WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE); - - // TODO(T85755791): remove after investigation - FLog.e(TAG, "Creating new dialog from context: " + context + "@" + context.hashCode()); - - mDialog.setContentView(getContentView()); - updateProperties(); - - mDialog.setOnShowListener(mOnShowListener); - mDialog.setOnKeyListener( - new DialogInterface.OnKeyListener() { - @Override - public boolean onKey(DialogInterface dialog, int keyCode, KeyEvent event) { - if (event.getAction() == KeyEvent.ACTION_UP) { - // We need to stop the BACK button and ESCAPE key from closing the dialog by default - // so we capture that event and instead inform JS so that it can make the decision as - // to whether or not to allow the back/escape key to close the dialog. If it chooses - // to, it can just set visible to false on the Modal and the Modal will go away - if (keyCode == KeyEvent.KEYCODE_BACK || keyCode == KeyEvent.KEYCODE_ESCAPE) { - Assertions.assertNotNull( - mOnRequestCloseListener, - "setOnRequestCloseListener must be called by the manager"); - mOnRequestCloseListener.onRequestClose(dialog); - return true; - } else { - // We redirect the rest of the key events to the current activity, since the - // activity expects to receive those events and react to them, ie. in the case of - // the dev menu - Activity currentActivity = ((ReactContext) getContext()).getCurrentActivity(); - if (currentActivity != null) { - return currentActivity.onKeyUp(keyCode, event); - } - } - } - return false; - } - }); - - mDialog.getWindow().setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE); - if (mHardwareAccelerated) { - mDialog.getWindow().addFlags(WindowManager.LayoutParams.FLAG_HARDWARE_ACCELERATED); - } - if (currentActivity != null && !currentActivity.isFinishing()) { - mDialog.show(); - updateSystemAppearance(); - mDialog.getWindow().clearFlags(WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE); - } - } - - /** - * Returns the view that will be the root view of the dialog. We are wrapping this in a - * FrameLayout because this is the system's way of notifying us that the dialog size has changed. - * This has the pleasant side-effect of us not having to preface all Modals with "top: - * statusBarHeight", since that margin will be included in the FrameLayout. - */ - private View getContentView() { - FrameLayout frameLayout = new FrameLayout(getContext()); - frameLayout.addView(mHostView); - if (mStatusBarTranslucent) { - frameLayout.setSystemUiVisibility(View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN); - } else { - frameLayout.setFitsSystemWindows(true); - } - return frameLayout; - } - - /** - * updateProperties will update the properties that do not require us to recreate the dialog - * Properties that do require us to recreate the dialog should set mPropertyRequiresNewDialog to - * true when the property changes - */ - private void updateProperties() { - Assertions.assertNotNull(mDialog, "mDialog must exist when we call updateProperties"); - - Activity currentActivity = getCurrentActivity(); - - Window window = Assertions.assertNotNull(mDialog.getWindow()); - if (currentActivity == null || currentActivity.isFinishing() || !window.isActive()) { - // If the activity has disappeared, then we shouldn't update the window associated to the - // Dialog. - return; - } - Window activityWindow = currentActivity.getWindow(); - if (activityWindow != null) { - int activityWindowFlags = activityWindow.getAttributes().flags; - if ((activityWindowFlags & WindowManager.LayoutParams.FLAG_FULLSCREEN) != 0) { - window.addFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN); - } else { - window.clearFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN); - } - } - - if (mTransparent) { - window.clearFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND); - } else { - window.setDimAmount(0.5f); - window.setFlags( - WindowManager.LayoutParams.FLAG_DIM_BEHIND, WindowManager.LayoutParams.FLAG_DIM_BEHIND); - } - } - - private void updateSystemAppearance() { - Activity currentActivity = getCurrentActivity(); - if (currentActivity == null) { - return; - } - Assertions.assertNotNull(mDialog, "mDialog must exist when we call updateSystemAppearance"); - Window window = Assertions.assertNotNull(mDialog.getWindow(), "mDialog.getWindow() must exist"); - // Modeled after the version check in StatusBarModule.setStyle - if (Build.VERSION.SDK_INT > Build.VERSION_CODES.R) { - Window currentActivityWindow = Assertions.assertNotNull(currentActivity.getWindow()); - WindowInsetsController insetsController = - Assertions.assertNotNull(currentActivityWindow.getInsetsController()); - int activityAppearance = insetsController.getSystemBarsAppearance(); - - int activityLightStatusBars = - activityAppearance & WindowInsetsController.APPEARANCE_LIGHT_STATUS_BARS; - - Objects.requireNonNull(window.getInsetsController()) - .setSystemBarsAppearance( - activityLightStatusBars, WindowInsetsController.APPEARANCE_LIGHT_STATUS_BARS); - } else { - Window currentActivityWindow = Assertions.assertNotNull(currentActivity.getWindow()); - View decorView = currentActivityWindow.getDecorView(); - decorView.setSystemUiVisibility(decorView.getSystemUiVisibility()); - } - } - - @Nullable - public StateWrapper getStateWrapper() { - return mHostView.getStateWrapper(); - } - - public void setStateWrapper(StateWrapper stateWrapper) { - mHostView.setStateWrapper(stateWrapper); - } - - public void updateState(final int width, final int height) { - mHostView.updateState(width, height); - } - - /** - * DialogRootViewGroup is the ViewGroup which contains all the children of a Modal. It gets all - * child information forwarded from ReactModalHostView and uses that to create children. It is - * also responsible for acting as a RootView and handling touch events. It does this the same way - * as ReactRootView. - * - *

To get layout to work properly, we need to layout all the elements within the Modal as if - * they can fill the entire window. To do that, we need to explicitly set the styleWidth and - * styleHeight on the LayoutShadowNode to be the window size. This is done through the - * UIManagerModule, and will then cause the children to layout as if they can fill the window. - */ - static class DialogRootViewGroup extends ReactViewGroup implements RootView { - - private boolean hasAdjustedSize = false; - private int viewWidth; - private int viewHeight; - private @Nullable EventDispatcher mEventDispatcher; - - private @Nullable StateWrapper mStateWrapper = null; - - private final JSTouchDispatcher mJSTouchDispatcher = new JSTouchDispatcher(this); - @Nullable private JSPointerDispatcher mJSPointerDispatcher; - - public DialogRootViewGroup(Context context) { - super(context); - if (ReactFeatureFlags.dispatchPointerEvents) { - mJSPointerDispatcher = new JSPointerDispatcher(this); - } - } - - private void setEventDispatcher(EventDispatcher eventDispatcher) { - mEventDispatcher = eventDispatcher; - } - - @Override - protected void onSizeChanged(final int w, final int h, int oldw, int oldh) { - super.onSizeChanged(w, h, oldw, oldh); - viewWidth = w; - viewHeight = h; - updateFirstChildView(); - } - - private void updateFirstChildView() { - if (getChildCount() > 0) { - hasAdjustedSize = false; - final int viewTag = getChildAt(0).getId(); - if (mStateWrapper != null) { - // This will only be called under Fabric - updateState(viewWidth, viewHeight); - } else { - // TODO: T44725185 remove after full migration to Fabric - ReactContext reactContext = getReactContext(); - reactContext.runOnNativeModulesQueueThread( - new GuardedRunnable(reactContext) { - @Override - public void runGuarded() { - UIManagerModule uiManager = - getReactContext() - .getReactApplicationContext() - .getNativeModule(UIManagerModule.class); - - if (uiManager == null) { - return; - } - - uiManager.updateNodeSize(viewTag, viewWidth, viewHeight); - } - }); - } - } else { - hasAdjustedSize = true; - } - } - - @UiThread - public void updateState(final int width, final int height) { - final float realWidth = PixelUtil.toDIPFromPixel(width); - final float realHeight = PixelUtil.toDIPFromPixel(height); - - // Check incoming state values. If they're already the correct value, return early to prevent - // infinite UpdateState/SetState loop. - ReadableMap currentState = mStateWrapper != null ? mStateWrapper.getStateData() : null; - if (currentState != null) { - float delta = (float) 0.9; - float stateScreenHeight = - currentState.hasKey("screenHeight") - ? (float) currentState.getDouble("screenHeight") - : 0; - float stateScreenWidth = - currentState.hasKey("screenWidth") ? (float) currentState.getDouble("screenWidth") : 0; - - if (Math.abs(stateScreenWidth - realWidth) < delta - && Math.abs(stateScreenHeight - realHeight) < delta) { - return; - } - } - - if (mStateWrapper != null) { - WritableMap newStateData = new WritableNativeMap(); - newStateData.putDouble("screenWidth", realWidth); - newStateData.putDouble("screenHeight", realHeight); - mStateWrapper.updateState(newStateData); - } - } - - @Override - public void addView(View child, int index, LayoutParams params) { - super.addView(child, index, params); - if (hasAdjustedSize) { - updateFirstChildView(); - } - } - - @Override - public void handleException(Throwable t) { - getReactContext().getReactApplicationContext().handleException(new RuntimeException(t)); - } - - private ThemedReactContext getReactContext() { - return (ThemedReactContext) getContext(); - } - - @Override - public boolean onInterceptTouchEvent(MotionEvent event) { - if (mEventDispatcher != null) { - mJSTouchDispatcher.handleTouchEvent(event, mEventDispatcher); - } - if (mJSPointerDispatcher != null && mEventDispatcher != null) { - mJSPointerDispatcher.handleMotionEvent(event, mEventDispatcher, true); - } - return super.onInterceptTouchEvent(event); - } - - @Override - public boolean onTouchEvent(MotionEvent event) { - if (mEventDispatcher != null) { - mJSTouchDispatcher.handleTouchEvent(event, mEventDispatcher); - } - if (mJSPointerDispatcher != null && mEventDispatcher != null) { - mJSPointerDispatcher.handleMotionEvent(event, mEventDispatcher, false); - } - super.onTouchEvent(event); - // In case when there is no children interested in handling touch event, we return true from - // the root view in order to receive subsequent events related to that gesture - return true; - } - - @Override - public boolean onInterceptHoverEvent(MotionEvent event) { - if (mJSPointerDispatcher != null && mEventDispatcher != null) { - mJSPointerDispatcher.handleMotionEvent(event, mEventDispatcher, true); - } - return super.onHoverEvent(event); - } - - @Override - public boolean onHoverEvent(MotionEvent event) { - if (mJSPointerDispatcher != null && mEventDispatcher != null) { - mJSPointerDispatcher.handleMotionEvent(event, mEventDispatcher, false); - } - return super.onHoverEvent(event); - } - - @Override - public void onChildStartedNativeGesture(View childView, MotionEvent ev) { - if (mEventDispatcher != null) { - mJSTouchDispatcher.onChildStartedNativeGesture(ev, mEventDispatcher); - } - if (mJSPointerDispatcher != null && mEventDispatcher != null) { - mJSPointerDispatcher.onChildStartedNativeGesture(childView, ev, mEventDispatcher); - } - } - - @Override - public void onChildEndedNativeGesture(View childView, MotionEvent ev) { - if (mEventDispatcher != null) { - mJSTouchDispatcher.onChildEndedNativeGesture(ev, mEventDispatcher); - } - if (mJSPointerDispatcher != null) { - mJSPointerDispatcher.onChildEndedNativeGesture(); - } - } - - @Override - public void requestDisallowInterceptTouchEvent(boolean disallowIntercept) { - // No-op - override in order to still receive events to onInterceptTouchEvent - // even when some other view disallow that - } - - @Nullable - public StateWrapper getStateWrapper() { - return mStateWrapper; - } - - public void setStateWrapper(StateWrapper stateWrapper) { - mStateWrapper = stateWrapper; - } - } -} 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 new file mode 100644 index 00000000000..0596deb209a --- /dev/null +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/modal/ReactModalHostView.kt @@ -0,0 +1,540 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +@file:Suppress("DEPRECATION") + +package com.facebook.react.views.modal + +import android.annotation.SuppressLint +import android.app.Activity +import android.app.Dialog +import android.content.Context +import android.content.DialogInterface +import android.os.Build +import android.view.KeyEvent +import android.view.MotionEvent +import android.view.View +import android.view.ViewGroup +import android.view.ViewStructure +import android.view.Window +import android.view.WindowInsetsController +import android.view.WindowManager +import android.view.accessibility.AccessibilityEvent +import android.widget.FrameLayout +import androidx.annotation.UiThread +import com.facebook.common.logging.FLog +import com.facebook.react.R +import com.facebook.react.bridge.GuardedRunnable +import com.facebook.react.bridge.LifecycleEventListener +import com.facebook.react.bridge.ReactContext +import com.facebook.react.bridge.ReadableMap +import com.facebook.react.bridge.UiThreadUtil +import com.facebook.react.bridge.WritableMap +import com.facebook.react.bridge.WritableNativeMap +import com.facebook.react.common.annotations.VisibleForTesting +import com.facebook.react.config.ReactFeatureFlags +import com.facebook.react.uimanager.JSPointerDispatcher +import com.facebook.react.uimanager.JSTouchDispatcher +import com.facebook.react.uimanager.PixelUtil +import com.facebook.react.uimanager.RootView +import com.facebook.react.uimanager.StateWrapper +import com.facebook.react.uimanager.ThemedReactContext +import com.facebook.react.uimanager.UIManagerModule +import com.facebook.react.uimanager.events.EventDispatcher +import com.facebook.react.views.common.ContextUtils +import com.facebook.react.views.view.ReactViewGroup +import java.util.Objects +import kotlin.math.abs + +/** + * ReactModalHostView is a view that sits in the view hierarchy representing a Modal view. + * + * It does a number of things: + * 1. It creates a [Dialog]. We use this Dialog to actually display the Modal in the window. + * 2. It creates a [DialogRootViewGroup]. This view is the view that is displayed by the Dialog. To + * display a view within a Dialog, that view must have its parent set to the window the Dialog + * creates. Because of this, we can not use the ReactModalHostView since it sits in the normal + * React view hierarchy. We do however want all of the layout magic to happen as if the + * DialogRootViewGroup were part of the hierarchy. Therefore, we forward all view changes around + * addition and removal of views to the DialogRootViewGroup. + */ +@SuppressLint("ViewConstructor") +public class ReactModalHostView(context: ThemedReactContext) : + ViewGroup(context), LifecycleEventListener { + + @get:VisibleForTesting + public var dialog: Dialog? = null + private set + + public var transparent: Boolean = false + public var onShowListener: DialogInterface.OnShowListener? = null + public var onRequestCloseListener: OnRequestCloseListener? = null + public var statusBarTranslucent: Boolean = false + set(value) { + field = value + propertyRequiresNewDialog = true + } + + public var animationType: String? = null + set(value) { + field = value + propertyRequiresNewDialog = true + } + + public var hardwareAccelerated: Boolean = false + set(value) { + field = value + propertyRequiresNewDialog = true + } + + public var stateWrapper: StateWrapper? + get() = hostView.stateWrapper + public set(stateWrapper) { + hostView.stateWrapper = stateWrapper + } + + public var eventDispatcher: EventDispatcher? + get() = hostView.eventDispatcher + public set(eventDispatcher) { + hostView.eventDispatcher = eventDispatcher + } + + private var hostView: DialogRootViewGroup + + // Set this flag to true if changing a particular property on the view requires a new Dialog to + // be created. For instance, animation does since it affects Dialog creation through the theme + // but transparency does not since we can access the window to update the property. + private var propertyRequiresNewDialog = false + + init { + context.addLifecycleEventListener(this) + hostView = DialogRootViewGroup(context) + } + + public override fun dispatchProvideStructure(structure: ViewStructure) { + hostView.dispatchProvideStructure(structure) + } + + protected override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) { + // Do nothing as we are laid out by UIManager + } + + protected override fun onDetachedFromWindow() { + super.onDetachedFromWindow() + dismiss() + } + + public override fun addView(child: View?, index: Int) { + UiThreadUtil.assertOnUiThread() + hostView.addView(child, index) + } + + public override fun getChildCount(): Int = hostView.childCount + + public override fun getChildAt(index: Int): View? = hostView.getChildAt(index) + + public override fun removeView(child: View?) { + UiThreadUtil.assertOnUiThread() + + if (child != null) { + hostView.removeView(child) + } + } + + public override fun removeViewAt(index: Int) { + UiThreadUtil.assertOnUiThread() + val child = getChildAt(index) + hostView.removeView(child) + } + + public override fun addChildrenForAccessibility(outChildren: ArrayList) { + // Explicitly override this to prevent accessibility events being passed down to children + // Those will be handled by the mHostView which lives in the dialog + } + + // Explicitly override this to prevent accessibility events being passed down to children + // Those will be handled by the mHostView which lives in the dialog + public override fun dispatchPopulateAccessibilityEvent(event: AccessibilityEvent): Boolean = false + + public fun onDropInstance() { + (context as ThemedReactContext).removeLifecycleEventListener(this) + dismiss() + } + + private fun dismiss() { + UiThreadUtil.assertOnUiThread() + + dialog?.let { nonNullDialog -> + if (nonNullDialog.isShowing) { + val dialogContext = + ContextUtils.findContextOfType(nonNullDialog.context, Activity::class.java) + if (dialogContext == null || !dialogContext.isFinishing) { + nonNullDialog.dismiss() + } + } + dialog = null + + // We need to remove the mHostView from the parent + // It is possible we are dismissing this dialog and reattaching the hostView to another + (hostView.parent as? ViewGroup)?.removeViewAt(0) + } + } + + public override fun onHostResume() { + // We show the dialog again when the host resumes + showOrUpdate() + } + + public override fun onHostPause() { + // do nothing + } + + public override fun onHostDestroy() { + // Drop the instance if the host is destroyed which will dismiss the dialog + onDropInstance() + } + + private fun getCurrentActivity(): Activity? = (context as ThemedReactContext).currentActivity + + /** + * showOrUpdate will display the Dialog. It is called by the manager once all properties are set + * because we need to know all of them before creating the Dialog. It is also smart during updates + * if the changed properties can be applied directly to the Dialog or require the recreation of a + * new Dialog. + */ + public fun showOrUpdate() { + UiThreadUtil.assertOnUiThread() + + // If the existing Dialog is currently up, we may need to redraw it or we may be able to update + // the property without having to recreate the dialog + dialog?.let { nonNullDialog -> + val dialogContext: Context? = + ContextUtils.findContextOfType(nonNullDialog.context, Activity::class.java) + // TODO(T85755791): remove after investigation + FLog.e(TAG, "Updating existing dialog with context: $dialogContext") + + if (propertyRequiresNewDialog) { + dismiss() + } else { + updateProperties() + return + } + } + + // Reset the flag since we are going to create a new dialog + propertyRequiresNewDialog = false + val theme: Int = + when (animationType) { + "fade" -> R.style.Theme_FullScreenDialogAnimatedFade + "slide" -> R.style.Theme_FullScreenDialogAnimatedSlide + else -> R.style.Theme_FullScreenDialog + } + + val currentActivity = getCurrentActivity() + val context = currentActivity ?: context + val newDialog = Dialog(context, theme) + dialog = newDialog + Objects.requireNonNull(newDialog.window) + .setFlags( + WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE, + WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE) + + // TODO(T85755791): remove after investigation + FLog.e(TAG, "Creating new dialog from context: " + context + "@" + context.hashCode()) + + newDialog.setContentView(contentView) + updateProperties() + + newDialog.setOnShowListener(onShowListener) + newDialog.setOnKeyListener( + object : DialogInterface.OnKeyListener { + override fun onKey(dialog: DialogInterface, keyCode: Int, event: KeyEvent): Boolean { + if (event.action == KeyEvent.ACTION_UP) { + // We need to stop the BACK button and ESCAPE key from closing the dialog by default + // so we capture that event and instead inform JS so that it can make the decision as + // to whether or not to allow the back/escape key to close the dialog. If it chooses + // to, it can just set visible to false on the Modal and the Modal will go away + if (keyCode == KeyEvent.KEYCODE_BACK || keyCode == KeyEvent.KEYCODE_ESCAPE) { + val listener = + checkNotNull(onRequestCloseListener) { + "onRequestClose callback must be set if back key is expected to close the modal" + } + listener.onRequestClose(dialog) + return true + } else { + // We redirect the rest of the key events to the current activity, since the + // activity expects to receive those events and react to them, ie. in the case of + // the dev menu + val innerCurrentActivity = (context as ReactContext).currentActivity + if (innerCurrentActivity != null) { + return innerCurrentActivity.onKeyUp(keyCode, event) + } + } + } + return false + } + }) + + newDialog.window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE) + if (hardwareAccelerated) { + newDialog.window?.addFlags(WindowManager.LayoutParams.FLAG_HARDWARE_ACCELERATED) + } + if (currentActivity?.isFinishing == false) { + newDialog.show() + updateSystemAppearance() + newDialog.window?.clearFlags(WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE) + } + } + + private val contentView: View + /** + * Returns the view that will be the root view of the dialog. We are wrapping this in a + * FrameLayout because this is the system's way of notifying us that the dialog size has + * changed. This has the pleasant side-effect of us not having to preface all Modals with "top: + * statusBarHeight", since that margin will be included in the FrameLayout. + */ + get() { + val frameLayout = FrameLayout(context) + frameLayout.addView(hostView) + if (statusBarTranslucent) { + frameLayout.systemUiVisibility = SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN + } else { + frameLayout.fitsSystemWindows = true + } + return frameLayout + } + + /** + * updateProperties will update the properties that do not require us to recreate the dialog + * Properties that do require us to recreate the dialog should set mPropertyRequiresNewDialog to + * true when the property changes + */ + private fun updateProperties() { + val dialog = checkNotNull(dialog) { "dialog must exist when we call updateProperties" } + val currentActivity = getCurrentActivity() + val window = + checkNotNull(dialog.window) { "dialog must have window when we call updateProperties" } + if (currentActivity == null || currentActivity.isFinishing || !window.isActive) { + // If the activity has disappeared, then we shouldn't update the window associated to the + // Dialog. + return + } + val activityWindow = currentActivity.window + if (activityWindow != null) { + val activityWindowFlags = activityWindow.attributes.flags + if ((activityWindowFlags and WindowManager.LayoutParams.FLAG_FULLSCREEN) != 0) { + window.addFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN) + } else { + window.clearFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN) + } + } + + if (transparent) { + window.clearFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND) + } else { + window.setDimAmount(0.5f) + window.setFlags( + WindowManager.LayoutParams.FLAG_DIM_BEHIND, WindowManager.LayoutParams.FLAG_DIM_BEHIND) + } + } + + private fun updateSystemAppearance() { + val currentActivity = getCurrentActivity() ?: return + val dialog = checkNotNull(dialog) { "dialog must exist when we call updateProperties" } + val window = + checkNotNull(dialog.window) { "dialog must have window when we call updateProperties" } + // Modeled after the version check in StatusBarModule.setStyle + if (Build.VERSION.SDK_INT > Build.VERSION_CODES.R) { + val currentActivityWindow = currentActivity.window + val insetsController: WindowInsetsController = + checkNotNull(currentActivityWindow.insetsController) + val activityAppearance: Int = insetsController.systemBarsAppearance + + val activityLightStatusBars = + activityAppearance and WindowInsetsController.APPEARANCE_LIGHT_STATUS_BARS + + window.insetsController?.setSystemBarsAppearance( + activityLightStatusBars, WindowInsetsController.APPEARANCE_LIGHT_STATUS_BARS) + } else { + val currentActivityWindow = checkNotNull(currentActivity.window) + val decorView = currentActivityWindow.decorView + decorView.setSystemUiVisibility(decorView.systemUiVisibility) + } + } + + public fun updateState(width: Int, height: Int) { + hostView.updateState(width, height) + } + + // This listener is called when the user presses KeyEvent.KEYCODE_BACK + // An event is then passed to JS which can either close or not close the Modal by setting the + // visible property + public interface OnRequestCloseListener { + public fun onRequestClose(dialog: DialogInterface?) + } + + private companion object { + private const val TAG = "ReactModalHost" + } + + /** + * DialogRootViewGroup is the ViewGroup which contains all the children of a Modal. It gets all + * child information forwarded from [ReactModalHostView] and uses that to create children. It is + * also responsible for acting as a RootView and handling touch events. It does this the same way + * as ReactRootView. + * + * To get layout to work properly, we need to layout all the elements within the Modal as if they + * can fill the entire window. To do that, we need to explicitly set the styleWidth and + * styleHeight on the LayoutShadowNode to be the window size. This is done through the + * UIManagerModule, and will then cause the children to layout as if they can fill the window. + */ + public inner class DialogRootViewGroup(context: Context?) : ReactViewGroup(context), RootView { + internal var stateWrapper: StateWrapper? = null + + private var hasAdjustedSize = false + private var viewWidth = 0 + private var viewHeight = 0 + private val jSTouchDispatcher: JSTouchDispatcher = JSTouchDispatcher(this) + private var jSPointerDispatcher: JSPointerDispatcher? = null + internal var eventDispatcher: EventDispatcher? = null + + private val reactContext: ThemedReactContext + get() = context as ThemedReactContext + + init { + if (ReactFeatureFlags.dispatchPointerEvents) { + jSPointerDispatcher = JSPointerDispatcher(this) + } + } + + override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) { + super.onSizeChanged(w, h, oldw, oldh) + viewWidth = w + viewHeight = h + updateFirstChildView() + } + + private fun updateFirstChildView() { + if (childCount > 0) { + hasAdjustedSize = false + val viewTag: Int = getChildAt(0).id + if (stateWrapper != null) { + // This will only be called under Fabric + updateState(viewWidth, viewHeight) + } else { + // TODO: T44725185 remove after full migration to Fabric + val reactContext: ReactContext = reactContext + reactContext.runOnNativeModulesQueueThread( + object : GuardedRunnable(reactContext) { + override fun runGuarded() { + this@DialogRootViewGroup.reactContext.reactApplicationContext + .getNativeModule(UIManagerModule::class.java) + ?.updateNodeSize(viewTag, viewWidth, viewHeight) + } + }) + } + } else { + hasAdjustedSize = true + } + } + + @UiThread + public fun updateState(width: Int, height: Int) { + val realWidth: Float = PixelUtil.toDIPFromPixel(width.toFloat()) + val realHeight: Float = PixelUtil.toDIPFromPixel(height.toFloat()) + + // Check incoming state values. If they're already the correct value, return early to prevent + // infinite UpdateState/SetState loop. + val currentState: ReadableMap? = stateWrapper?.getStateData() + if (currentState != null) { + val delta = 0.9f + val stateScreenHeight = + if (currentState.hasKey("screenHeight")) { + currentState.getDouble("screenHeight").toFloat() + } else { + 0f + } + val stateScreenWidth = + if (currentState.hasKey("screenWidth")) { + currentState.getDouble("screenWidth").toFloat() + } else { + 0f + } + + if (abs((stateScreenWidth - realWidth).toDouble()) < delta && + abs((stateScreenHeight - realHeight).toDouble()) < delta) { + return + } + } + + stateWrapper?.let { sw -> + val newStateData: WritableMap = WritableNativeMap() + newStateData.putDouble("screenWidth", realWidth.toDouble()) + newStateData.putDouble("screenHeight", realHeight.toDouble()) + sw.updateState(newStateData) + } + } + + override fun addView(child: View, index: Int, params: LayoutParams) { + super.addView(child, index, params) + if (hasAdjustedSize) { + updateFirstChildView() + } + } + + override fun handleException(t: Throwable) { + reactContext.reactApplicationContext.handleException(RuntimeException(t)) + } + + override fun onInterceptTouchEvent(event: MotionEvent): Boolean { + eventDispatcher?.let { eventDispatcher -> + jSTouchDispatcher.handleTouchEvent(event, eventDispatcher) + jSPointerDispatcher?.handleMotionEvent(event, eventDispatcher, true) + } + return super.onInterceptTouchEvent(event) + } + + @SuppressLint("ClickableViewAccessibility") + override fun onTouchEvent(event: MotionEvent): Boolean { + eventDispatcher?.let { eventDispatcher -> + jSTouchDispatcher.handleTouchEvent(event, eventDispatcher) + jSPointerDispatcher?.handleMotionEvent(event, eventDispatcher, false) + } + super.onTouchEvent(event) + // In case when there is no children interested in handling touch event, we return true from + // the root view in order to receive subsequent events related to that gesture + return true + } + + override fun onInterceptHoverEvent(event: MotionEvent): Boolean { + eventDispatcher?.let { jSPointerDispatcher?.handleMotionEvent(event, it, true) } + return super.onHoverEvent(event) + } + + override fun onHoverEvent(event: MotionEvent): Boolean { + eventDispatcher?.let { jSPointerDispatcher?.handleMotionEvent(event, it, false) } + return super.onHoverEvent(event) + } + + override fun onChildStartedNativeGesture(childView: View, ev: MotionEvent) { + eventDispatcher?.let { eventDispatcher -> + jSTouchDispatcher.onChildStartedNativeGesture(ev, eventDispatcher) + jSPointerDispatcher?.onChildStartedNativeGesture(childView, ev, eventDispatcher) + } + } + + override fun onChildEndedNativeGesture(childView: View, ev: MotionEvent) { + eventDispatcher?.let { jSTouchDispatcher.onChildEndedNativeGesture(ev, it) } + jSPointerDispatcher?.onChildEndedNativeGesture() + } + + override fun requestDisallowInterceptTouchEvent(disallowIntercept: Boolean) { + // No-op - override in order to still receive events to onInterceptTouchEvent + // even when some other view disallow that + } + } +}