From 6b5b42c6701e454bbaa8b506871321e9b2ae8214 Mon Sep 17 00:00:00 2001 From: Alan Lee Date: Tue, 27 Aug 2024 00:48:22 -0700 Subject: [PATCH] bugfix Android 15 Modal behavior change Summary: When running on Android 15, content of the modal is cut-off at the bottom (content seems to overlap with bottom navigation bar and is cut-off) - Android 15, even without building with targetSdk 35, seems to introduce behavior change so add code to handle such case - also fix existing logic that seems like it just happened to work before but is no longer working with Android 15 - additional refactoring - renamed: hostView -> dialogRootViewGroup Changelog: [Android][Fixed] - Modal content cut-off at bottom on Android 15 Differential Revision: D61838294 --- .../react/views/modal/ModalHostHelper.kt | 50 ++++++++- .../react/views/modal/ModalHostShadowNode.kt | 2 +- .../views/modal/ReactModalHostManager.kt | 3 - .../react/views/modal/ReactModalHostView.kt | 106 ++++++++++-------- 4 files changed, 107 insertions(+), 54 deletions(-) diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/modal/ModalHostHelper.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/modal/ModalHostHelper.kt index f1698ae9b8333b..6d759c90233f1d 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/modal/ModalHostHelper.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/modal/ModalHostHelper.kt @@ -10,7 +10,11 @@ package com.facebook.react.views.modal import android.annotation.SuppressLint import android.content.Context import android.graphics.Point +import android.os.Build +import android.view.Window +import android.view.WindowInsets import android.view.WindowManager +import androidx.window.layout.WindowMetricsCalculator /** Helper class for Modals. */ internal object ModalHostHelper { @@ -18,6 +22,50 @@ internal object ModalHostHelper { private val MAX_POINT = Point() private val SIZE_POINT = Point() + private const val APPEARANCE_FORCE_LIGHT_NAVIGATION_BARS = 1 shl 9 + + /** + * Adding new function to handle Android 15 as behavior has changed (even building without + * targetSdk 35) but keeping legacy code to handle lower versions. + */ + @JvmStatic + fun getModalHostSize( + context: Context, + window: Window?, + ): Point { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) { + getModalHostSizeNew(context, window) + } else { + getModalHostSizeLegacy(context) + } + } + + @JvmStatic + fun getModalHostSizeNew(context: Context, window: Window?): Point { + val metrics = WindowMetricsCalculator.getOrCreate().computeCurrentWindowMetrics(context) + var verticalPadding = 0 + var horizontalPadding = 0 + window?.decorView?.rootWindowInsets?.let { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + val insets = it.getInsets(WindowInsets.Type.systemBars()) + val isForcedEdgeToEdge = isForcedEdgeToEdge(window) + verticalPadding = insets.bottom + if (isForcedEdgeToEdge.not()) insets.top else 0 + horizontalPadding = if (isForcedEdgeToEdge.not()) insets.left + insets.right else 0 + } + } + return Point( + metrics.bounds.width() - horizontalPadding, metrics.bounds.height() - verticalPadding) + } + + // Undocumented feature: APPEARANCE_FORCE_LIGHT_NAVIGATION_BARS seems to be set on forced + // edge-to-edge on targetSdk 35 + private fun isForcedEdgeToEdge(window: Window): Boolean { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) { + ((window.decorView.windowInsetsController?.systemBarsAppearance ?: 0) and + APPEARANCE_FORCE_LIGHT_NAVIGATION_BARS) > 0 + } else false + } + /** * To get the size of the screen, we use information from the WindowManager and default Display. * We don't use DisplayMetricsHolder, or Display#getSize() because they return values that include @@ -28,7 +76,7 @@ internal object ModalHostHelper { */ @Suppress("DEPRECATION") @JvmStatic - fun getModalHostSize(context: Context): Point { + fun getModalHostSizeLegacy(context: Context): Point { val wm = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager val display = wm.defaultDisplay // getCurrentSizeRange will return the min and max width and height that the window can be diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/modal/ModalHostShadowNode.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/modal/ModalHostShadowNode.kt index 4c52f3aa3d5517..2b9e5e295584c2 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/modal/ModalHostShadowNode.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/modal/ModalHostShadowNode.kt @@ -27,7 +27,7 @@ internal class ModalHostShadowNode : LayoutShadowNode() { */ override fun addChildAt(child: ReactShadowNodeImpl, i: Int) { super.addChildAt(child, i) - val modalSize = getModalHostSize(themedContext) + val modalSize = getModalHostSize(themedContext, themedContext.currentActivity?.window) child.setStyleWidth(modalSize.x.toFloat()) child.setStyleHeight(modalSize.y.toFloat()) } diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/modal/ReactModalHostManager.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/modal/ReactModalHostManager.kt index 465760968946ab..9bff79407ea1c3 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/modal/ReactModalHostManager.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/modal/ReactModalHostManager.kt @@ -20,7 +20,6 @@ import com.facebook.react.uimanager.ViewManagerDelegate import com.facebook.react.uimanager.annotations.ReactProp import com.facebook.react.viewmanagers.ModalHostViewManagerDelegate import com.facebook.react.viewmanagers.ModalHostViewManagerInterface -import com.facebook.react.views.modal.ModalHostHelper.getModalHostSize import com.facebook.react.views.modal.ReactModalHostView.OnRequestCloseListener /** View manager for [ReactModalHostView] components. */ @@ -128,8 +127,6 @@ public class ReactModalHostManager : stateWrapper: StateWrapper ): Any? { view.stateWrapper = stateWrapper - val modalSize = getModalHostSize(view.context) - view.updateState(modalSize.x, modalSize.y) return null } 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 64291e332baa6c..d7015f49e60eca 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 @@ -38,13 +38,15 @@ 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.PixelUtil.pxToDp 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.modal.ModalHostHelper.getModalHostSize +import com.facebook.react.views.modal.ReactModalHostView.DialogRootViewGroup import com.facebook.react.views.view.ReactViewGroup import java.util.Objects import kotlin.math.abs @@ -91,18 +93,18 @@ public class ReactModalHostView(context: ThemedReactContext) : } public var stateWrapper: StateWrapper? - get() = hostView.stateWrapper + get() = dialogRootViewGroup.stateWrapper public set(stateWrapper) { - hostView.stateWrapper = stateWrapper + dialogRootViewGroup.stateWrapper = stateWrapper } public var eventDispatcher: EventDispatcher? - get() = hostView.eventDispatcher + get() = dialogRootViewGroup.eventDispatcher public set(eventDispatcher) { - hostView.eventDispatcher = eventDispatcher + dialogRootViewGroup.eventDispatcher = eventDispatcher } - private val hostView: DialogRootViewGroup + private val dialogRootViewGroup: DialogRootViewGroup // Set this flag to true if changing a particular property on the view requires a new Dialog to // be created or Dialog was destroyed. For instance, animation does since it affects Dialog @@ -112,11 +114,11 @@ public class ReactModalHostView(context: ThemedReactContext) : init { context.addLifecycleEventListener(this) - hostView = DialogRootViewGroup(context) + dialogRootViewGroup = DialogRootViewGroup(context) } public override fun dispatchProvideStructure(structure: ViewStructure) { - hostView.dispatchProvideStructure(structure) + dialogRootViewGroup.dispatchProvideStructure(structure) } protected override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) { @@ -127,7 +129,7 @@ public class ReactModalHostView(context: ThemedReactContext) : super.setId(id) // Forward the ID to our content view, so event dispatching behaves correctly - hostView.id = id + dialogRootViewGroup.id = id } protected override fun onDetachedFromWindow() { @@ -137,25 +139,25 @@ public class ReactModalHostView(context: ThemedReactContext) : public override fun addView(child: View?, index: Int) { UiThreadUtil.assertOnUiThread() - hostView.addView(child, index) + dialogRootViewGroup.addView(child, index) } - public override fun getChildCount(): Int = hostView.childCount + public override fun getChildCount(): Int = dialogRootViewGroup.childCount - public override fun getChildAt(index: Int): View? = hostView.getChildAt(index) + public override fun getChildAt(index: Int): View? = dialogRootViewGroup.getChildAt(index) public override fun removeView(child: View?) { UiThreadUtil.assertOnUiThread() if (child != null) { - hostView.removeView(child) + dialogRootViewGroup.removeView(child) } } public override fun removeViewAt(index: Int) { UiThreadUtil.assertOnUiThread() val child = getChildAt(index) - hostView.removeView(child) + dialogRootViewGroup.removeView(child) } public override fun addChildrenForAccessibility(outChildren: ArrayList) { @@ -188,7 +190,7 @@ public class ReactModalHostView(context: ThemedReactContext) : // 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) + (dialogRootViewGroup.parent as? ViewGroup)?.removeViewAt(0) } } @@ -238,10 +240,14 @@ public class ReactModalHostView(context: ThemedReactContext) : val currentActivity = getCurrentActivity() val newDialog = Dialog(currentActivity ?: context, theme) dialog = newDialog - Objects.requireNonNull(newDialog.window) - .setFlags( - WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE, - WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE) + + Objects.requireNonNull(newDialog.window).apply { + setFlags( + WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE, + WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE) + addFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN) + setLayout(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT) + } newDialog.setContentView(contentView) updateProperties() @@ -296,14 +302,18 @@ public class ReactModalHostView(context: ThemedReactContext) : * 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 + return if (Build.VERSION.SDK_INT < Build.VERSION_CODES.VANILLA_ICE_CREAM) { + return FrameLayout(context).apply { + addView(dialogRootViewGroup) + if (statusBarTranslucent) { + systemUiVisibility = SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN + } else { + fitsSystemWindows = true + } + } } else { - frameLayout.fitsSystemWindows = true + dialogRootViewGroup } - return frameLayout } /** @@ -314,9 +324,9 @@ public class ReactModalHostView(context: ThemedReactContext) : private fun updateProperties() { val dialog = checkNotNull(dialog) { "dialog must exist when we call updateProperties" } val currentActivity = getCurrentActivity() - val window = + val dialogWindow = checkNotNull(dialog.window) { "dialog must have window when we call updateProperties" } - if (currentActivity == null || currentActivity.isFinishing || !window.isActive) { + if (currentActivity == null || currentActivity.isFinishing /*|| !window.isActive*/) { // If the activity has disappeared, then we shouldn't update the window associated to the // Dialog. return @@ -325,17 +335,17 @@ public class ReactModalHostView(context: ThemedReactContext) : if (activityWindow != null) { val activityWindowFlags = activityWindow.attributes.flags if ((activityWindowFlags and WindowManager.LayoutParams.FLAG_FULLSCREEN) != 0) { - window.addFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN) + dialogWindow.addFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN) } else { - window.clearFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN) + dialogWindow.clearFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN) } } if (transparent) { - window.clearFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND) + dialogWindow.clearFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND) } else { - window.setDimAmount(0.5f) - window.setFlags( + dialogWindow.setDimAmount(0.5f) + dialogWindow.setFlags( WindowManager.LayoutParams.FLAG_DIM_BEHIND, WindowManager.LayoutParams.FLAG_DIM_BEHIND) } } @@ -343,8 +353,9 @@ public class ReactModalHostView(context: ThemedReactContext) : private fun updateSystemAppearance() { val currentActivity = getCurrentActivity() ?: return val dialog = checkNotNull(dialog) { "dialog must exist when we call updateProperties" } - val window = + val dialogWindow = checkNotNull(dialog.window) { "dialog must have window when we call updateProperties" } + val activityWindow = currentActivity.window // Modeled after the version check in StatusBarModule.setStyle if (Build.VERSION.SDK_INT > Build.VERSION_CODES.R) { val currentActivityWindow = currentActivity.window @@ -355,19 +366,13 @@ public class ReactModalHostView(context: ThemedReactContext) : val activityLightStatusBars = activityAppearance and WindowInsetsController.APPEARANCE_LIGHT_STATUS_BARS - window.insetsController?.setSystemBarsAppearance( + dialogWindow.insetsController?.setSystemBarsAppearance( activityLightStatusBars, WindowInsetsController.APPEARANCE_LIGHT_STATUS_BARS) } else { - val currentActivityWindow = checkNotNull(currentActivity.window) - val decorView = currentActivityWindow.decorView - decorView.setSystemUiVisibility(decorView.systemUiVisibility) + dialogWindow.decorView.systemUiVisibility = activityWindow.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 @@ -414,17 +419,20 @@ public class ReactModalHostView(context: ThemedReactContext) : super.onSizeChanged(w, h, oldw, oldh) viewWidth = w viewHeight = h - updateFirstChildView() + + val size = getModalHostSize(context, reactContext.currentActivity?.window) + viewWidth = size.x + viewHeight = size.y + + updateState(viewWidth, viewHeight) } 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 { + // if non-fabric + if (stateWrapper == null) { // TODO: T44725185 remove after full migration to Fabric val reactContext: ReactContext = reactContext reactContext.runOnNativeModulesQueueThread( @@ -443,12 +451,12 @@ public class ReactModalHostView(context: ThemedReactContext) : @UiThread public fun updateState(width: Int, height: Int) { - val realWidth: Float = PixelUtil.toDIPFromPixel(width.toFloat()) - val realHeight: Float = PixelUtil.toDIPFromPixel(height.toFloat()) + val realWidth: Float = width.toFloat().pxToDp() + val realHeight: Float = height.toFloat().pxToDp() // Check incoming state values. If they're already the correct value, return early to prevent // infinite UpdateState/SetState loop. - val currentState: ReadableMap? = stateWrapper?.getStateData() + val currentState: ReadableMap? = stateWrapper?.stateData if (currentState != null) { val delta = 0.9f val stateScreenHeight =