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
This commit is contained in:
Nicola Corti
2024-04-05 11:12:02 -07:00
committed by Facebook GitHub Bot
parent 20c1cc5e65
commit ab44550f6e
3 changed files with 574 additions and 643 deletions
@@ -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 <init> (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 <init> (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 {
@@ -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.
*
* <p>It does a number of things:
*
* <ol>
* <li>It creates a Dialog. We use this Dialog to actually display the Modal in the window.
* <li>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.
* </ol>
*/
@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<View> 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.
*
* <p>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;
}
}
}
@@ -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<View>) {
// 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<Window>(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
}
}
}