Skip to content

Commit

Permalink
bugfix Android 15 Modal behavior change
Browse files Browse the repository at this point in the history
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
  • Loading branch information
alanleedev authored and facebook-github-bot committed Aug 27, 2024
1 parent a7364f8 commit 6b5b42c
Show file tree
Hide file tree
Showing 4 changed files with 107 additions and 54 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,62 @@ 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 {
private val MIN_POINT = Point()
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
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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())
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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) {
Expand All @@ -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() {
Expand All @@ -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<View>) {
Expand Down Expand Up @@ -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)
}
}

Expand Down Expand Up @@ -238,10 +240,14 @@ public class ReactModalHostView(context: ThemedReactContext) :
val currentActivity = getCurrentActivity()
val newDialog = Dialog(currentActivity ?: context, theme)
dialog = newDialog
Objects.requireNonNull<Window>(newDialog.window)
.setFlags(
WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE,
WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE)

Objects.requireNonNull<Window>(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()
Expand Down Expand Up @@ -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
}

/**
Expand All @@ -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
Expand All @@ -325,26 +335,27 @@ 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)
}
}

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
Expand All @@ -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
Expand Down Expand Up @@ -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(
Expand All @@ -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 =
Expand Down

0 comments on commit 6b5b42c

Please sign in to comment.