Skip to content

Commit 44a4b1b

Browse files
zoontekmeta-codesync[bot]
authored andcommitted
Fix Dimensions window values on Android < 15 (#53254)
Summary: This PR (initially created for edge-to-edge opt-in support, rebased multiple times) fixes the `Dimensions` API `window` values on Android < 15, when edge-to-edge is enabled. Currently the window height doesn't include the status and navigation bar heights (but it does on Android >= 15): <img width="300" alt="Screenshot 2025-06-27 at 16 23 02" src="https://github.com/user-attachments/assets/c7d11334-9298-4f7f-a75c-590df8cc2d8a" /> Using `WindowMetricsCalculator` from AndroidX: <img width="300" alt="Screenshot 2025-06-27 at 16 34 01" src="https://github.com/user-attachments/assets/7a4e3dc7-a83b-421b-8f6d-fd1344f5fe81" /> Fixes #47080 ## Changelog: [Android] [Fixed] Fix `Dimensions` `window` values on Android < 15 when edge-to-edge is enabled Pull Request resolved: #53254 Test Plan: Run the example app on an Android < 15 device. Reviewed By: javache Differential Revision: D80237818 Pulled By: alanleedev fbshipit-source-id: f05e546cd38c7bd539b86d30fe7b663715a34a99
1 parent e0a453c commit 44a4b1b

5 files changed

Lines changed: 110 additions & 9 deletions

File tree

packages/react-native/ReactAndroid/build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -699,6 +699,7 @@ dependencies {
699699
api(libs.androidx.autofill)
700700
api(libs.androidx.swiperefreshlayout)
701701
api(libs.androidx.tracing)
702+
api(libs.androidx.window)
702703

703704
api(libs.fbjni)
704705
api(libs.fresco)

packages/react-native/ReactAndroid/src/main/java/com/facebook/react/modules/deviceinfo/DeviceInfoModule.kt

Lines changed: 60 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,20 @@
77

88
package com.facebook.react.modules.deviceinfo
99

10+
import android.util.DisplayMetrics
11+
import androidx.core.view.ViewCompat
12+
import androidx.core.view.WindowInsetsCompat
13+
import androidx.window.layout.WindowMetricsCalculator
1014
import com.facebook.fbreact.specs.NativeDeviceInfoSpec
1115
import com.facebook.react.bridge.LifecycleEventListener
1216
import com.facebook.react.bridge.ReactApplicationContext
1317
import com.facebook.react.bridge.ReactNoCrashSoftException
1418
import com.facebook.react.bridge.ReactSoftExceptionLogger
1519
import com.facebook.react.bridge.ReadableMap
20+
import com.facebook.react.bridge.WritableMap
21+
import com.facebook.react.bridge.WritableNativeMap
1622
import com.facebook.react.module.annotations.ReactModule
17-
import com.facebook.react.uimanager.DisplayMetricsHolder.getDisplayMetricsWritableMap
23+
import com.facebook.react.uimanager.DisplayMetricsHolder.getScreenDisplayMetrics
1824
import com.facebook.react.uimanager.DisplayMetricsHolder.initDisplayMetricsIfNotInitialized
1925
import com.facebook.react.views.view.isEdgeToEdgeFeatureFlagOn
2026

@@ -30,8 +36,59 @@ internal class DeviceInfoModule(reactContext: ReactApplicationContext) :
3036
reactContext.addLifecycleEventListener(this)
3137
}
3238

39+
private fun getWindowDisplayMetrics(): DisplayMetrics {
40+
val windowDisplayMetrics = DisplayMetrics()
41+
windowDisplayMetrics.setTo(reactApplicationContext.resources.displayMetrics)
42+
43+
val activity = reactApplicationContext.currentActivity ?: return windowDisplayMetrics
44+
val bounds = WindowMetricsCalculator.getOrCreate().computeCurrentWindowMetrics(activity).bounds
45+
46+
if (isEdgeToEdgeFeatureFlagOn) {
47+
windowDisplayMetrics.widthPixels = bounds.width()
48+
windowDisplayMetrics.heightPixels = bounds.height()
49+
} else {
50+
// WindowMetrics bounds include system bars. When edge-to-edge is not enabled, we subtract
51+
// them so that window dimensions reflect the usable content area. If insets aren't yet
52+
// available (e.g. before the first layout pass), fall back to resources.displayMetrics,
53+
// which already excludes system bars in non-edge-to-edge mode.
54+
ViewCompat.getRootWindowInsets(activity.window.decorView)?.let {
55+
val insets =
56+
it.getInsets(
57+
WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout()
58+
)
59+
windowDisplayMetrics.widthPixels = bounds.width() - (insets.left + insets.right)
60+
windowDisplayMetrics.heightPixels = bounds.height() - (insets.top + insets.bottom)
61+
}
62+
}
63+
64+
return windowDisplayMetrics
65+
}
66+
67+
fun getDisplayMetricsWritableMap(): WritableMap =
68+
WritableNativeMap().apply {
69+
putMap(
70+
"windowPhysicalPixels",
71+
getPhysicalPixelsWritableMap(getWindowDisplayMetrics()),
72+
)
73+
putMap(
74+
"screenPhysicalPixels",
75+
getPhysicalPixelsWritableMap(getScreenDisplayMetrics()),
76+
)
77+
}
78+
79+
private fun getPhysicalPixelsWritableMap(
80+
displayMetrics: DisplayMetrics,
81+
): WritableMap =
82+
WritableNativeMap().apply {
83+
putInt("width", displayMetrics.widthPixels)
84+
putInt("height", displayMetrics.heightPixels)
85+
putDouble("scale", displayMetrics.density.toDouble())
86+
putDouble("fontScale", fontScale.toDouble())
87+
putDouble("densityDpi", displayMetrics.densityDpi.toDouble())
88+
}
89+
3390
public override fun getTypedExportedConstants(): Map<String, Any> {
34-
val displayMetrics = getDisplayMetricsWritableMap(fontScale.toDouble())
91+
val displayMetrics = getDisplayMetricsWritableMap()
3592

3693
// Cache the initial dimensions for later comparison in emitUpdateDimensionsEvent
3794
previousDisplayMetrics = displayMetrics.copy()
@@ -58,7 +115,7 @@ internal class DeviceInfoModule(reactContext: ReactApplicationContext) :
58115
reactApplicationContext.let { context ->
59116
if (context.hasActiveReactInstance()) {
60117
// Don't emit an event to JS if the dimensions haven't changed
61-
val displayMetrics = getDisplayMetricsWritableMap(fontScale.toDouble())
118+
val displayMetrics = getDisplayMetricsWritableMap()
62119
if (previousDisplayMetrics == null) {
63120
previousDisplayMetrics = displayMetrics.copy()
64121
} else if (displayMetrics != previousDisplayMetrics) {

packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/DisplayMetricsHolder.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,13 +29,15 @@ public object DisplayMetricsHolder {
2929
@JvmStatic private var windowDisplayMetrics: DisplayMetrics? = null
3030
@JvmStatic private var screenDisplayMetrics: DisplayMetrics? = null
3131

32+
// TODO(0.87): Remove once we are out of the non-breaking window (see 8d21ffda60)
3233
/** The metrics of the window associated to the Context used to initialize ReactNative */
3334
@JvmStatic
3435
public fun getWindowDisplayMetrics(): DisplayMetrics {
3536
checkNotNull(windowDisplayMetrics) { INITIALIZATION_MISSING_MESSAGE }
3637
return windowDisplayMetrics as DisplayMetrics
3738
}
3839

40+
// TODO(0.87): Remove once we are out of the non-breaking window (see 8d21ffda60)
3941
@JvmStatic
4042
public fun setWindowDisplayMetrics(displayMetrics: DisplayMetrics?) {
4143
windowDisplayMetrics = displayMetrics
@@ -84,6 +86,7 @@ public object DisplayMetricsHolder {
8486
DisplayMetricsHolder.screenDisplayMetrics = screenDisplayMetrics
8587
}
8688

89+
// TODO(0.87): Remove once we are out of the non-breaking window (see 8d21ffda60)
8790
@JvmStatic
8891
public fun getDisplayMetricsWritableMap(fontScale: Double): WritableMap {
8992
checkNotNull(windowDisplayMetrics) { INITIALIZATION_MISSING_MESSAGE }

packages/react-native/ReactAndroid/src/test/java/com/facebook/react/modules/deviceinfo/DeviceInfoModuleTest.kt

Lines changed: 44 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,15 +9,21 @@
99

1010
package com.facebook.react.modules.deviceinfo
1111

12+
import android.util.DisplayMetrics
1213
import com.facebook.react.bridge.BridgeReactContext
1314
import com.facebook.react.bridge.JavaOnlyMap
1415
import com.facebook.react.bridge.ReactContext
1516
import com.facebook.react.bridge.ReactTestHelper
1617
import com.facebook.react.bridge.WritableMap
1718
import com.facebook.react.internal.featureflags.ReactNativeFeatureFlagsForTests
1819
import com.facebook.react.uimanager.DisplayMetricsHolder
20+
import com.facebook.testutils.shadows.ShadowNativeLoader
21+
import com.facebook.testutils.shadows.ShadowNativeMap
22+
import com.facebook.testutils.shadows.ShadowReadableNativeMap
23+
import com.facebook.testutils.shadows.ShadowSoLoader
24+
import com.facebook.testutils.shadows.ShadowWritableNativeMap
1925
import junit.framework.TestCase
20-
import org.assertj.core.api.Assertions
26+
import org.assertj.core.api.Assertions.assertThat
2127
import org.junit.After
2228
import org.junit.Before
2329
import org.junit.Test
@@ -26,13 +32,26 @@ import org.mockito.ArgumentCaptor
2632
import org.mockito.ArgumentMatchers
2733
import org.mockito.MockedStatic
2834
import org.mockito.Mockito.mockStatic
35+
import org.mockito.kotlin.doReturn
2936
import org.mockito.kotlin.spy
3037
import org.mockito.kotlin.times
3138
import org.mockito.kotlin.verify
39+
import org.mockito.kotlin.whenever
3240
import org.robolectric.RobolectricTestRunner
3341
import org.robolectric.RuntimeEnvironment
42+
import org.robolectric.annotation.Config
3443

3544
@RunWith(RobolectricTestRunner::class)
45+
@Config(
46+
shadows =
47+
[
48+
ShadowSoLoader::class,
49+
ShadowNativeLoader::class,
50+
ShadowNativeMap::class,
51+
ShadowWritableNativeMap::class,
52+
ShadowReadableNativeMap::class,
53+
]
54+
)
3655
class DeviceInfoModuleTest : TestCase() {
3756

3857
private lateinit var deviceInfoModule: DeviceInfoModule
@@ -55,7 +74,7 @@ class DeviceInfoModuleTest : TestCase() {
5574
reactContext = spy(BridgeReactContext(RuntimeEnvironment.getApplication()))
5675
val catalystInstanceMock = ReactTestHelper.createMockCatalystInstance()
5776
reactContext.initializeWithInstance(catalystInstanceMock)
58-
deviceInfoModule = DeviceInfoModule(reactContext)
77+
deviceInfoModule = spy(DeviceInfoModule(reactContext))
5978
}
6079

6180
@After
@@ -110,10 +129,29 @@ class DeviceInfoModuleTest : TestCase() {
110129
)
111130
}
112131

113-
private fun givenDisplayMetricsHolderContains(fakeDisplayMetrics: WritableMap?) {
132+
@Test
133+
fun getDisplayMetricsWritableMap_returnsCorrectMap() {
114134
displayMetricsHolder
115-
.`when`<WritableMap> { DisplayMetricsHolder.getDisplayMetricsWritableMap(1.0) }
116-
.thenAnswer { fakeDisplayMetrics }
135+
.`when`<DisplayMetrics> { DisplayMetricsHolder.getScreenDisplayMetrics() }
136+
.thenAnswer { reactContext.resources.displayMetrics }
137+
138+
// Use the official initialization method to ensure both metrics are set
139+
val map: WritableMap = deviceInfoModule.getDisplayMetricsWritableMap()
140+
assertThat(map.hasKey("windowPhysicalPixels")).isTrue()
141+
assertThat(map.hasKey("screenPhysicalPixels")).isTrue()
142+
val windowMap = map.getMap("windowPhysicalPixels")
143+
val screenMap = map.getMap("screenPhysicalPixels")
144+
checkNotNull(windowMap)
145+
checkNotNull(screenMap)
146+
assertThat(windowMap.hasKey("width")).isTrue()
147+
assertThat(windowMap.hasKey("height")).isTrue()
148+
assertThat(windowMap.hasKey("scale")).isTrue()
149+
assertThat(windowMap.hasKey("fontScale")).isTrue()
150+
assertThat(windowMap.hasKey("densityDpi")).isTrue()
151+
}
152+
153+
private fun givenDisplayMetricsHolderContains(fakeDisplayMetrics: WritableMap?) {
154+
doReturn(fakeDisplayMetrics).whenever(deviceInfoModule).getDisplayMetricsWritableMap()
117155
}
118156

119157
companion object {
@@ -126,7 +164,7 @@ class DeviceInfoModuleTest : TestCase() {
126164
verify(context, times(expectedEventList.size))
127165
?.emitDeviceEvent(ArgumentMatchers.eq("didUpdateDimensions"), captor.capture())
128166
val actualEvents = captor.allValues
129-
Assertions.assertThat(actualEvents).isEqualTo(expectedEventList)
167+
assertThat(actualEvents).isEqualTo(expectedEventList)
130168
}
131169
}
132170
}

packages/react-native/gradle/libs.versions.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ androidx-swiperefreshlayout = "1.1.0"
1616
androidx-test = "1.5.0"
1717
androidx-test-junit = "1.2.1"
1818
androidx-tracing = "1.1.0"
19+
androidx-window = "1.5.1"
1920
assertj = "3.21.0"
2021
binary-compatibility-validator = "0.13.2"
2122
download = "5.4.0"
@@ -64,6 +65,7 @@ androidx-test-rules = { module = "androidx.test:rules", version.ref = "androidx-
6465
androidx-test-runner = { module = "androidx.test:runner", version.ref = "androidx-test" }
6566
androidx-tracing = { module = "androidx.tracing:tracing", version.ref = "androidx-tracing" }
6667
androidx-uiautomator = { group = "androidx.test.uiautomator", name = "uiautomator", version.ref = "uiautomator" }
68+
androidx-window = { module = "androidx.window:window", version.ref = "androidx-window" }
6769

6870
fbjni = { module = "com.facebook.fbjni:fbjni", version.ref = "fbjni" }
6971
fresco = { module = "com.facebook.fresco:fresco", version.ref = "fresco" }

0 commit comments

Comments
 (0)