diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/BaseViewManager.java b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/BaseViewManager.java index f4c8744f5ba4..b2e8fec78339 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/BaseViewManager.java +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/BaseViewManager.java @@ -7,6 +7,8 @@ package com.facebook.react.uimanager; +import static com.facebook.react.uimanager.internal.FloatPropertySanitizer.sanitizeFloatPropertyValue; + import android.graphics.Color; import android.graphics.Matrix; import android.graphics.Paint; @@ -298,7 +300,7 @@ public void setOpacity(@NonNull T view, float opacity) { @ReactProp(name = ViewProps.ELEVATION) public void setElevation(@NonNull T view, float elevation) { - float px = PixelUtil.toPixelFromDIP(elevation); + float px = sanitizeFloatPropertyValue(PixelUtil.toPixelFromDIP(elevation)); boolean wasElevated = view.getElevation() > 0f; ViewCompat.setElevation(view, px); if (wasElevated != (px > 0f)) { @@ -553,13 +555,13 @@ public void setRotation(@NonNull T view, float rotation) { @Deprecated @ReactProp(name = ViewProps.SCALE_X, defaultFloat = 1f) public void setScaleX(@NonNull T view, float scaleX) { - view.setScaleX(scaleX); + view.setScaleX(sanitizeFloatPropertyValue(scaleX)); } @Deprecated @ReactProp(name = ViewProps.SCALE_Y, defaultFloat = 1f) public void setScaleY(@NonNull T view, float scaleY) { - view.setScaleY(scaleY); + view.setScaleY(sanitizeFloatPropertyValue(scaleY)); } @Deprecated @@ -719,29 +721,6 @@ private static void clearSkewAnimationMatrixIfActive(@NonNull T view.setTag(R.id.skew_animation_matrix, null); } - /** - * Prior to Android P things like setScaleX() allowed passing float values that were bogus such as - * Float.NaN. If the app is targeting Android P or later then passing these values will result in - * an exception being thrown. Since JS might still send Float.NaN, we want to keep the code - * backward compatible and continue using the fallback value if an invalid float is passed. - */ - private static float sanitizeFloatPropertyValue(float value) { - if (value >= -Float.MAX_VALUE && value <= Float.MAX_VALUE) { - return value; - } - if (value < -Float.MAX_VALUE || value == Float.NEGATIVE_INFINITY) { - return -Float.MAX_VALUE; - } - if (value > Float.MAX_VALUE || value == Float.POSITIVE_INFINITY) { - return Float.MAX_VALUE; - } - if (Float.isNaN(value)) { - return 0; - } - // Shouldn't be possible to reach this point. - throw new IllegalStateException("Invalid float property value: " + value); - } - protected void updateViewAccessibility(@NonNull T view) { ReactAccessibilityDelegate.setDelegate( view, view.isFocusable(), view.getImportantForAccessibility()); diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/internal/FloatPropertySanitizer.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/internal/FloatPropertySanitizer.kt new file mode 100644 index 000000000000..5a6828729e59 --- /dev/null +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/internal/FloatPropertySanitizer.kt @@ -0,0 +1,21 @@ +/* + * 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.uimanager.internal + +/** + * Prior to Android P things like setScaleX() allowed passing float values that were bogus such as + * Float.NaN. If the app is targeting Android P or later then passing these values will result in an + * exception being thrown. Since JS might still send Float.NaN, we want to keep the code backward + * compatible and continue using the fallback value if an invalid float is passed. + */ +public object FloatPropertySanitizer { + + @JvmStatic + public fun sanitizeFloatPropertyValue(value: Float): Float = + if (value.isNaN()) 0f else value.coerceIn(-Float.MAX_VALUE, Float.MAX_VALUE) +} diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/drawer/ReactDrawerLayoutManager.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/drawer/ReactDrawerLayoutManager.kt index 87c679d3b544..b795078302c1 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/drawer/ReactDrawerLayoutManager.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/drawer/ReactDrawerLayoutManager.kt @@ -25,6 +25,7 @@ import com.facebook.react.uimanager.ViewGroupManager import com.facebook.react.uimanager.ViewManagerDelegate import com.facebook.react.uimanager.annotations.ReactProp import com.facebook.react.uimanager.events.EventDispatcher +import com.facebook.react.uimanager.internal.FloatPropertySanitizer import com.facebook.react.viewmanagers.AndroidDrawerLayoutManagerDelegate import com.facebook.react.viewmanagers.AndroidDrawerLayoutManagerInterface import com.facebook.react.views.drawer.events.DrawerClosedEvent @@ -142,7 +143,7 @@ public class ReactDrawerLayoutManager : public override fun setStatusBarBackgroundColor(view: ReactDrawerLayout, value: Int?): Unit = Unit public override fun setElevation(view: ReactDrawerLayout, elevation: Float) { - view.drawerElevation = elevation.dpToPx() + view.drawerElevation = FloatPropertySanitizer.sanitizeFloatPropertyValue(elevation.dpToPx()) } public override fun needsCustomLayoutForChildren(): Boolean { diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/BaseViewManagerTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/BaseViewManagerTest.kt index 00d1ea51ea47..8e089c9d3dec 100644 --- a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/BaseViewManagerTest.kt +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/BaseViewManagerTest.kt @@ -9,6 +9,7 @@ package com.facebook.react.uimanager +import android.util.DisplayMetrics import android.view.View import android.view.View.OnFocusChangeListener import android.view.accessibility.AccessibilityNodeInfo @@ -321,6 +322,78 @@ class BaseViewManagerTest { Assertions.assertThat(recycled.getTag(R.id.original_focusability)).isNull() } + @Test + fun testNaNElevationLeavesTheViewUnelevated() { + val unelevated = createView(JavaOnlyMap.of(ViewProps.ELEVATION, Double.NaN)) + + Assertions.assertThat(unelevated.elevation).isEqualTo(0f) + } + + @Test + fun testInfiniteElevationTakesTheLargestFiniteElevation() { + val raised = createView(JavaOnlyMap.of(ViewProps.ELEVATION, Double.POSITIVE_INFINITY)) + + Assertions.assertThat(raised.elevation).isEqualTo(Float.MAX_VALUE) + } + + @Test + fun testNegativeInfiniteElevationTakesTheLowestFiniteElevation() { + val lowered = createView(JavaOnlyMap.of(ViewProps.ELEVATION, Double.NEGATIVE_INFINITY)) + + Assertions.assertThat(lowered.elevation).isEqualTo(-Float.MAX_VALUE) + } + + @Test + fun testElevationPastTheLargestFloatTakesTheLargestFiniteElevation() { + val raised = createView(JavaOnlyMap.of(ViewProps.ELEVATION, 1e39)) + + Assertions.assertThat(raised.elevation).isEqualTo(Float.MAX_VALUE) + } + + @Test + fun testElevationPastTheLargestFloatInPixelsTakesTheLargestFiniteElevation() { + DisplayMetricsHolder.setScreenDisplayMetrics(DisplayMetrics().apply { density = 3f }) + + viewManager.setElevation(view, Float.MAX_VALUE) + + Assertions.assertThat(view.elevation).isEqualTo(Float.MAX_VALUE) + } + + @Test + fun testNaNScaleTakesTheFrameworksFallback() { + val scaled = + createView(JavaOnlyMap.of(ViewProps.SCALE_X, Double.NaN, ViewProps.SCALE_Y, Double.NaN)) + + Assertions.assertThat(scaled.scaleX).isEqualTo(0f) + Assertions.assertThat(scaled.scaleY).isEqualTo(0f) + } + + @Test + fun testInfiniteScaleTakesTheLargestFiniteScale() { + val scaled = + createView( + JavaOnlyMap.of( + ViewProps.SCALE_X, + Double.POSITIVE_INFINITY, + ViewProps.SCALE_Y, + Double.POSITIVE_INFINITY, + ) + ) + + Assertions.assertThat(scaled.scaleX).isEqualTo(Float.MAX_VALUE) + Assertions.assertThat(scaled.scaleY).isEqualTo(Float.MAX_VALUE) + } + + @Test + fun testNaNElevationAfterAFiniteOneLeavesTheViewUnelevated() { + viewManager.setElevation(view, 4f) + Assertions.assertThat(view.elevation).isEqualTo(PixelUtil.toPixelFromDIP(4f)) + + viewManager.setElevation(view, Float.NaN) + + Assertions.assertThat(view.elevation).isEqualTo(0f) + } + private fun recycleView( previousProps: JavaOnlyMap, beforeDrop: (ReactViewGroup) -> Unit = {}, diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/drawer/ReactDrawerLayoutManagerTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/drawer/ReactDrawerLayoutManagerTest.kt new file mode 100644 index 000000000000..c014d1a05f9b --- /dev/null +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/drawer/ReactDrawerLayoutManagerTest.kt @@ -0,0 +1,70 @@ +/* + * 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.drawer + +import android.util.DisplayMetrics +import android.view.Gravity +import android.view.View +import android.view.ViewGroup +import androidx.drawerlayout.widget.DrawerLayout +import com.facebook.react.bridge.BridgeReactContext +import com.facebook.react.uimanager.DisplayMetricsHolder +import com.facebook.react.uimanager.ThemedReactContext +import org.assertj.core.api.Assertions.assertThat +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment + +@RunWith(RobolectricTestRunner::class) +class ReactDrawerLayoutManagerTest { + private lateinit var drawerLayout: ReactDrawerLayout + + @Before + fun setUp() { + DisplayMetricsHolder.initDisplayMetricsIfNotInitialized(RuntimeEnvironment.getApplication()) + val context = BridgeReactContext(RuntimeEnvironment.getApplication()) + drawerLayout = ReactDrawerLayout(ThemedReactContext(context, context, null, -1)) + drawerLayout.addView( + View(drawerLayout.context), + DrawerLayout.LayoutParams(100, ViewGroup.LayoutParams.MATCH_PARENT, Gravity.START), + ) + } + + @After + fun tearDown() { + DisplayMetricsHolder.setScreenDisplayMetrics(null) + } + + @Test + fun testNaNElevationLeavesTheDrawerUnelevated() { + ReactDrawerLayoutManager().setElevation(drawerLayout, Float.NaN) + + assertThat(drawerLayout.drawerElevation).isEqualTo(0f) + } + + @Test + fun testInfiniteElevationTakesTheLargestFiniteElevation() { + ReactDrawerLayoutManager().setElevation(drawerLayout, Float.POSITIVE_INFINITY) + + assertThat(drawerLayout.drawerElevation).isEqualTo(Float.MAX_VALUE) + } + + @Test + fun testElevationPastTheLargestFloatInPixelsTakesTheLargestFiniteElevation() { + DisplayMetricsHolder.setScreenDisplayMetrics(DisplayMetrics().apply { density = 3f }) + + ReactDrawerLayoutManager().setElevation(drawerLayout, Float.MAX_VALUE) + + assertThat(drawerLayout.drawerElevation).isEqualTo(Float.MAX_VALUE) + } +}