From cdf7b6b90de71a859ac45f2ca6a12d469e6a00dd Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Sun, 18 Oct 2026 23:09:47 +0300 Subject: [PATCH 1/4] Stop rebuilding box-shadow geometry on every draw InsetBoxShadowDrawable and OutsetBoxShadowDrawable resolved the border radii and built new RectF, Path and radii arrays inside draw(), so every redraw of a view's background, every frame of an animation that touches it, allocated them again and handed the canvas a new Path. Both drawables now keep that geometry in fields and rebuild it only when its inputs change: on a bounds or layout direction change, and when borderRadius or borderInsets is assigned. An alpha or color filter change only redraws. --- .../drawable/InsetBoxShadowDrawable.kt | 157 ++++++---- .../drawable/OutsetBoxShadowDrawable.kt | 140 +++++---- .../drawable/BoxShadowDrawableTest.kt | 294 ++++++++++++++++++ 3 files changed, 467 insertions(+), 124 deletions(-) create mode 100644 packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/drawable/BoxShadowDrawableTest.kt diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/drawable/InsetBoxShadowDrawable.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/drawable/InsetBoxShadowDrawable.kt index c59484be0f7f..cbe7489d5fd5 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/drawable/InsetBoxShadowDrawable.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/drawable/InsetBoxShadowDrawable.kt @@ -15,6 +15,7 @@ import android.graphics.ColorFilter import android.graphics.Paint import android.graphics.Path import android.graphics.PixelFormat +import android.graphics.Rect import android.graphics.RectF import android.graphics.drawable.Drawable import androidx.annotation.RequiresApi @@ -45,12 +46,8 @@ internal class InsetBoxShadowDrawable( private val offsetY: Float, private val blurRadius: Float, private val spread: Float, - /* - * We assume borderRadius & borderInsets to be shared across multiple drawables - * therefore user should invalidate this drawable when changing either of them - */ - var borderInsets: BorderInsets? = null, - var borderRadius: BorderRadiusStyle? = null, + borderInsets: BorderInsets? = null, + borderRadius: BorderRadiusStyle? = null, ) : Drawable() { private val shadowPaint = Paint().apply { @@ -61,6 +58,41 @@ internal class InsetBoxShadowDrawable( } } + private var needUpdatePath = true + private val paddingBoxRect = RectF() + private val paddingBoxRadii = FloatArray(8) + private var hasPaddingBoxRadii = false + private val paddingBoxPath = Path() + private val innerRect = RectF() + private val innerRadii = FloatArray(8) + private val outerRect = RectF() + + /* + * We assume borderRadius & borderInsets to be shared across multiple drawables + * therefore user should assign them to this drawable again when changing either of them + */ + var borderInsets: BorderInsets? = borderInsets + set(value) { + field = value + needUpdatePath = true + } + + var borderRadius: BorderRadiusStyle? = borderRadius + set(value) { + field = value + needUpdatePath = true + } + + override fun onBoundsChange(bounds: Rect) { + super.onBoundsChange(bounds) + needUpdatePath = true + } + + override fun onLayoutDirectionChanged(layoutDirection: Int): Boolean { + needUpdatePath = true + return true + } + override fun setAlpha(alpha: Int) { shadowPaint.alpha = (((alpha / 255f) * (Color.alpha(shadowColor) / 255f)) * 255f).roundToInt() invalidateSelf() @@ -82,69 +114,80 @@ internal class InsetBoxShadowDrawable( } override fun draw(canvas: Canvas) { + updatePath() + + canvas.save().let { saveCount -> + if (hasPaddingBoxRadii) { + canvas.clipPath(paddingBoxPath) + canvas.drawDoubleRoundRect(outerRect, ZERO_RADII, innerRect, innerRadii, shadowPaint) + } else { + canvas.clipRect(paddingBoxRect) + canvas.drawDoubleRoundRect(outerRect, ZERO_RADII, innerRect, ZERO_RADII, shadowPaint) + } + + canvas.restoreToCount(saveCount) + } + } + + private fun updatePath() { + if (!needUpdatePath) { + return + } + needUpdatePath = false + val computedBorderRadii = computeBorderRadii() val computedBorderInsets = computeBorderInsets() - val paddingBoxRect = - RectF( - bounds.left + (computedBorderInsets?.left ?: 0f), - bounds.top + (computedBorderInsets?.top ?: 0f), - bounds.right - (computedBorderInsets?.right ?: 0f), - bounds.bottom - (computedBorderInsets?.bottom ?: 0f), - ) - val paddingBoxRadii = - computedBorderRadii?.let { - floatArrayOf( - innerRadius(it.topLeft.horizontal, computedBorderInsets?.left), - innerRadius(it.topLeft.vertical, computedBorderInsets?.top), - innerRadius(it.topRight.horizontal, computedBorderInsets?.right), - innerRadius(it.topRight.vertical, computedBorderInsets?.top), - innerRadius(it.bottomRight.horizontal, computedBorderInsets?.right), - innerRadius(it.bottomRight.vertical, computedBorderInsets?.bottom), - innerRadius(it.bottomLeft.horizontal, computedBorderInsets?.left), - innerRadius(it.bottomLeft.vertical, computedBorderInsets?.bottom), - ) - } + paddingBoxRect.set( + bounds.left + (computedBorderInsets?.left ?: 0f), + bounds.top + (computedBorderInsets?.top ?: 0f), + bounds.right - (computedBorderInsets?.right ?: 0f), + bounds.bottom - (computedBorderInsets?.bottom ?: 0f), + ) + hasPaddingBoxRadii = computedBorderRadii != null + if (computedBorderRadii != null) { + paddingBoxRadii[0] = + innerRadius(computedBorderRadii.topLeft.horizontal, computedBorderInsets?.left) + paddingBoxRadii[1] = + innerRadius(computedBorderRadii.topLeft.vertical, computedBorderInsets?.top) + paddingBoxRadii[2] = + innerRadius(computedBorderRadii.topRight.horizontal, computedBorderInsets?.right) + paddingBoxRadii[3] = + innerRadius(computedBorderRadii.topRight.vertical, computedBorderInsets?.top) + paddingBoxRadii[4] = + innerRadius(computedBorderRadii.bottomRight.horizontal, computedBorderInsets?.right) + paddingBoxRadii[5] = + innerRadius(computedBorderRadii.bottomRight.vertical, computedBorderInsets?.bottom) + paddingBoxRadii[6] = + innerRadius(computedBorderRadii.bottomLeft.horizontal, computedBorderInsets?.left) + paddingBoxRadii[7] = + innerRadius(computedBorderRadii.bottomLeft.vertical, computedBorderInsets?.bottom) + } val x = offsetX.dpToPx() val y = offsetY.dpToPx() val spreadExtent = spread.dpToPx() - val innerRect = - RectF(paddingBoxRect).apply { - if (2 * spreadExtent > paddingBoxRect.width()) { - setEmpty() - } else { - inset(spreadExtent, spreadExtent) - } - offset(x, y) - } + innerRect.set(paddingBoxRect) + if (2 * spreadExtent > paddingBoxRect.width()) { + innerRect.setEmpty() + } else { + innerRect.inset(spreadExtent, spreadExtent) + } + innerRect.offset(x, y) // Graciously stolen from Blink // https://source.chromium.org/chromium/chromium/src/+/main:third_party/blink/renderer/core/paint/box_painter_base.cc;l=338;drc=0a301506035e13015ea5c8dd39164d0d5954fa60 val blurExtent = FilterHelper.sigmaToRadius(blurRadius) - val outerRect = - RectF(innerRect).apply { - set(paddingBoxRect) - inset(-blurExtent, -blurExtent) - union(RectF(innerRect)) - } - - canvas.save().let { saveCount -> - if (paddingBoxRadii != null) { - canvas.clipPath( - Path().apply { addRoundRect(paddingBoxRect, paddingBoxRadii, Path.Direction.CW) }, - ) - - val innerRadii = - paddingBoxRadii.map { adjustRadiusForSpread(it, -spreadExtent) }.toFloatArray() - - canvas.drawDoubleRoundRect(outerRect, ZERO_RADII, innerRect, innerRadii, shadowPaint) - } else { - canvas.clipRect(paddingBoxRect) - canvas.drawDoubleRoundRect(outerRect, ZERO_RADII, innerRect, ZERO_RADII, shadowPaint) + outerRect.set(paddingBoxRect) + outerRect.inset(-blurExtent, -blurExtent) + outerRect.union(innerRect) + + if (hasPaddingBoxRadii) { + paddingBoxPath.rewind() + paddingBoxPath.addRoundRect(paddingBoxRect, paddingBoxRadii, Path.Direction.CW) + for (index in innerRadii.indices) { + innerRadii[index] = adjustRadiusForSpread(paddingBoxRadii[index], -spreadExtent) } - - canvas.restoreToCount(saveCount) } } diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/drawable/OutsetBoxShadowDrawable.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/drawable/OutsetBoxShadowDrawable.kt index c67596d44bea..e643535668d2 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/drawable/OutsetBoxShadowDrawable.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/drawable/OutsetBoxShadowDrawable.kt @@ -15,6 +15,7 @@ import android.graphics.ColorFilter import android.graphics.Paint import android.graphics.Path import android.graphics.PixelFormat +import android.graphics.Rect import android.graphics.RectF import android.graphics.drawable.Drawable import androidx.annotation.RequiresApi @@ -42,11 +43,7 @@ internal class OutsetBoxShadowDrawable( private val offsetY: Float, private val blurRadius: Float, private val spread: Float, - /* - * We assume borderRadius to be shared across multiple drawables - * therefore we should manually invalidate this drawable when changing it - */ - var borderRadius: BorderRadiusStyle? = null, + borderRadius: BorderRadiusStyle? = null, ) : Drawable() { private val shadowPaint = Paint().apply { @@ -57,6 +54,34 @@ internal class OutsetBoxShadowDrawable( } } + private var needUpdatePath = true + private val shadowRect = RectF() + private var hasRoundedBorders = false + private val subpixelInsetBounds = RectF() + private val radii = FloatArray(8) + private val clipOutPath = Path() + private val shadowPath = Path() + + /* + * We assume borderRadius to be shared across multiple drawables + * therefore we should assign it to this drawable again when changing it + */ + var borderRadius: BorderRadiusStyle? = borderRadius + set(value) { + field = value + needUpdatePath = true + } + + override fun onBoundsChange(bounds: Rect) { + super.onBoundsChange(bounds) + needUpdatePath = true + } + + override fun onLayoutDirectionChanged(layoutDirection: Int): Boolean { + needUpdatePath = true + return true + } + override fun setAlpha(alpha: Int) { shadowPaint.alpha = (((alpha / 255f) * (Color.alpha(shadowColor) / 255f)) * 255f).roundToInt() invalidateSelf() @@ -78,6 +103,26 @@ internal class OutsetBoxShadowDrawable( } override fun draw(canvas: Canvas) { + updatePath() + + canvas.save().let { saveCount -> + if (hasRoundedBorders) { + canvas.clipOutPath(clipOutPath) + canvas.drawPath(shadowPath, shadowPaint) + } else { + canvas.clipOutRect(bounds) + canvas.drawRect(shadowRect, shadowPaint) + } + canvas.restoreToCount(saveCount) + } + } + + private fun updatePath() { + if (!needUpdatePath) { + return + } + needUpdatePath = false + val resolutionWidth = bounds.width().toFloat().pxToDp() val resolutionHeight = bounds.height().toFloat().pxToDp() val computedBorderRadii = @@ -94,74 +139,35 @@ internal class OutsetBoxShadowDrawable( } val spreadExtent = spread.dpToPx() - val shadowRect = - RectF(bounds).apply { - inset(-spreadExtent, -spreadExtent) - offset(offsetX.dpToPx(), offsetY.dpToPx()) - } + shadowRect.set(bounds) + shadowRect.inset(-spreadExtent, -spreadExtent) + shadowRect.offset(offsetX.dpToPx(), offsetY.dpToPx()) - canvas.save().let { saveCount -> - if (computedBorderRadii?.hasRoundedBorders() == true) { - drawShadowRoundRect(canvas, shadowRect, spreadExtent, computedBorderRadii) - } else { - drawShadowRect(canvas, shadowRect) - } - canvas.restoreToCount(saveCount) + hasRoundedBorders = computedBorderRadii?.hasRoundedBorders() == true + if (computedBorderRadii == null || !hasRoundedBorders) { + return } - } - private fun drawShadowRoundRect( - canvas: Canvas, - shadowRect: RectF, - spreadExtent: Float, - computedBorderRadii: ComputedBorderRadius, - ) { // We inset the clip slightly, to avoid Skia artifacts with antialiased // clipping. This inset is only visible when no background is present. // https://neugierig.org/software/chromium/notes/2010/07/clipping.html - val subpixelInsetBounds = RectF(bounds).apply { inset(0.4f, 0.4f) } - canvas.clipOutPath( - Path().apply { - addRoundRect( - subpixelInsetBounds, - floatArrayOf( - computedBorderRadii.topLeft.horizontal, - computedBorderRadii.topLeft.vertical, - computedBorderRadii.topRight.horizontal, - computedBorderRadii.topRight.vertical, - computedBorderRadii.bottomRight.horizontal, - computedBorderRadii.bottomRight.vertical, - computedBorderRadii.bottomLeft.horizontal, - computedBorderRadii.bottomLeft.vertical, - ), - Path.Direction.CW, - ) - }, - ) - - canvas.drawPath( - Path().apply { - addRoundRect( - shadowRect, - floatArrayOf( - adjustRadiusForSpread(computedBorderRadii.topLeft.horizontal, spreadExtent), - adjustRadiusForSpread(computedBorderRadii.topLeft.vertical, spreadExtent), - adjustRadiusForSpread(computedBorderRadii.topRight.horizontal, spreadExtent), - adjustRadiusForSpread(computedBorderRadii.topRight.vertical, spreadExtent), - adjustRadiusForSpread(computedBorderRadii.bottomRight.horizontal, spreadExtent), - adjustRadiusForSpread(computedBorderRadii.bottomRight.vertical, spreadExtent), - adjustRadiusForSpread(computedBorderRadii.bottomLeft.horizontal, spreadExtent), - adjustRadiusForSpread(computedBorderRadii.bottomLeft.vertical, spreadExtent), - ), - Path.Direction.CW, - ) - }, - shadowPaint, - ) - } + subpixelInsetBounds.set(bounds) + subpixelInsetBounds.inset(0.4f, 0.4f) + radii[0] = computedBorderRadii.topLeft.horizontal + radii[1] = computedBorderRadii.topLeft.vertical + radii[2] = computedBorderRadii.topRight.horizontal + radii[3] = computedBorderRadii.topRight.vertical + radii[4] = computedBorderRadii.bottomRight.horizontal + radii[5] = computedBorderRadii.bottomRight.vertical + radii[6] = computedBorderRadii.bottomLeft.horizontal + radii[7] = computedBorderRadii.bottomLeft.vertical + clipOutPath.rewind() + clipOutPath.addRoundRect(subpixelInsetBounds, radii, Path.Direction.CW) - private fun drawShadowRect(canvas: Canvas, shadowRect: RectF) { - canvas.clipOutRect(bounds) - canvas.drawRect(shadowRect, shadowPaint) + for (index in radii.indices) { + radii[index] = adjustRadiusForSpread(radii[index], spreadExtent) + } + shadowPath.rewind() + shadowPath.addRoundRect(shadowRect, radii, Path.Direction.CW) } } diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/drawable/BoxShadowDrawableTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/drawable/BoxShadowDrawableTest.kt new file mode 100644 index 000000000000..a1e66d64dace --- /dev/null +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/drawable/BoxShadowDrawableTest.kt @@ -0,0 +1,294 @@ +/* + * 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.drawable + +import android.content.Context +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.graphics.Path +import android.graphics.PorterDuff +import android.graphics.PorterDuffColorFilter +import android.graphics.Rect +import android.graphics.RectF +import android.graphics.drawable.Drawable +import android.util.DisplayMetrics +import android.util.LayoutDirection +import com.facebook.react.internal.featureflags.ReactNativeFeatureFlagsForTests +import com.facebook.react.uimanager.DisplayMetricsHolder +import com.facebook.react.uimanager.LengthPercentage +import com.facebook.react.uimanager.LengthPercentageType +import com.facebook.react.uimanager.style.BorderInsets +import com.facebook.react.uimanager.style.BorderRadiusProp +import com.facebook.react.uimanager.style.BorderRadiusStyle +import com.facebook.react.uimanager.style.LogicalEdge +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.mockito.kotlin.any +import org.mockito.kotlin.spy +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment + +/** Tests for [InsetBoxShadowDrawable] and [OutsetBoxShadowDrawable] */ +@RunWith(RobolectricTestRunner::class) +class BoxShadowDrawableTest { + + private val context: Context = RuntimeEnvironment.getApplication() + + @Before + fun setup() { + ReactNativeFeatureFlagsForTests.setUp() + DisplayMetricsHolder.setScreenDisplayMetrics(DisplayMetrics().apply { density = 2f }) + } + + @After + fun tearDown() { + DisplayMetricsHolder.setScreenDisplayMetrics(null) + } + + @Test + fun roundedInsetShadowDrawsTheSameGeometryObjectsOnEveryDraw() { + assertDrawsTheSameGeometryObjectsOnEveryDraw(insetShadow(roundedBy(8f))) + } + + @Test + fun rectangularInsetShadowDrawsTheSameGeometryObjectsOnEveryDraw() { + assertDrawsTheSameGeometryObjectsOnEveryDraw(insetShadow(null)) + } + + @Test + fun roundedOutsetShadowDrawsTheSameGeometryObjectsOnEveryDraw() { + assertDrawsTheSameGeometryObjectsOnEveryDraw(outsetShadow(roundedBy(8f))) + } + + @Test + fun rectangularOutsetShadowDrawsTheSameGeometryObjectsOnEveryDraw() { + assertDrawsTheSameGeometryObjectsOnEveryDraw(outsetShadow(null)) + } + + @Test + fun insetShadowFollowsBoundsThatGrowFromEmpty() { + val drawable = insetShadow(roundedBy(8f)) + drawable.draw(RecordingCanvas()) + + drawable.bounds = Rect(0, 0, 100, 50) + + assertDrawsLike(drawable, insetShadow(roundedBy(8f)).withBounds(Rect(0, 0, 100, 50))) + } + + @Test + fun outsetShadowFollowsBoundsThatGrowFromEmpty() { + val drawable = outsetShadow(roundedBy(8f)) + drawable.draw(RecordingCanvas()) + + drawable.bounds = Rect(0, 0, 100, 50) + + assertDrawsLike(drawable, outsetShadow(roundedBy(8f)).withBounds(Rect(0, 0, 100, 50))) + } + + @Test + fun insetShadowFollowsARadiusChangedInPlaceAndReassigned() { + val borderRadius = roundedBy(8f) + val drawable = insetShadow(borderRadius).withBounds(Rect(0, 0, 100, 50)) + drawable.draw(RecordingCanvas()) + + borderRadius.set(BorderRadiusProp.BORDER_RADIUS, points(4f)) + drawable.borderRadius = borderRadius + + assertDrawsLike(drawable, insetShadow(roundedBy(4f)).withBounds(Rect(0, 0, 100, 50))) + } + + @Test + fun outsetShadowFollowsARadiusChangedInPlaceAndReassigned() { + val borderRadius = roundedBy(8f) + val drawable = outsetShadow(borderRadius).withBounds(Rect(0, 0, 100, 50)) + drawable.draw(RecordingCanvas()) + + borderRadius.set(BorderRadiusProp.BORDER_RADIUS, points(4f)) + drawable.borderRadius = borderRadius + + assertDrawsLike(drawable, outsetShadow(roundedBy(4f)).withBounds(Rect(0, 0, 100, 50))) + } + + @Test + fun insetShadowFollowsBorderInsetsChangedInPlaceAndReassigned() { + val borderInsets = BorderInsets() + val drawable = insetShadow(null, borderInsets).withBounds(Rect(0, 0, 100, 50)) + drawable.draw(RecordingCanvas()) + + borderInsets.setBorderWidth(LogicalEdge.ALL, 3f) + drawable.borderInsets = borderInsets + + val expectedInsets = BorderInsets().apply { setBorderWidth(LogicalEdge.ALL, 3f) } + assertDrawsLike(drawable, insetShadow(null, expectedInsets).withBounds(Rect(0, 0, 100, 50))) + } + + @Test + fun insetShadowKeepsItsGeometryWhenItsAlphaOrColorFilterChanges() { + val borderRadius = spy(roundedBy(8f)) + val drawable = insetShadow(borderRadius).withBounds(Rect(0, 0, 100, 50)) + drawable.draw(RecordingCanvas()) + + drawable.alpha = 128 + drawable.colorFilter = PorterDuffColorFilter(Color.RED, PorterDuff.Mode.SRC_IN) + drawable.draw(RecordingCanvas()) + + verify(borderRadius, times(1)).resolve(any(), any(), any(), any()) + } + + @Test + fun outsetShadowKeepsItsGeometryWhenItsAlphaOrColorFilterChanges() { + val borderRadius = spy(roundedBy(8f)) + val drawable = outsetShadow(borderRadius).withBounds(Rect(0, 0, 100, 50)) + drawable.draw(RecordingCanvas()) + + drawable.alpha = 128 + drawable.colorFilter = PorterDuffColorFilter(Color.RED, PorterDuff.Mode.SRC_IN) + drawable.draw(RecordingCanvas()) + + verify(borderRadius, times(1)).resolve(any(), any(), any(), any()) + } + + @Test + fun insetShadowFollowsALayoutDirectionChange() { + val drawable = insetShadow(startRoundedBy(8f)).withBounds(Rect(0, 0, 100, 50)) + drawable.draw(RecordingCanvas()) + + drawable.layoutDirection = LayoutDirection.RTL + + val expected = insetShadow(startRoundedBy(8f)).withBounds(Rect(0, 0, 100, 50)) + expected.layoutDirection = LayoutDirection.RTL + assertDrawsLike(drawable, expected) + } + + @Test + fun outsetShadowFollowsALayoutDirectionChange() { + val drawable = outsetShadow(startRoundedBy(8f)).withBounds(Rect(0, 0, 100, 50)) + drawable.draw(RecordingCanvas()) + + drawable.layoutDirection = LayoutDirection.RTL + + val expected = outsetShadow(startRoundedBy(8f)).withBounds(Rect(0, 0, 100, 50)) + expected.layoutDirection = LayoutDirection.RTL + assertDrawsLike(drawable, expected) + } + + private fun insetShadow( + borderRadius: BorderRadiusStyle?, + borderInsets: BorderInsets? = null, + ): InsetBoxShadowDrawable = + InsetBoxShadowDrawable( + context = context, + shadowColor = Color.BLACK, + offsetX = 2f, + offsetY = 3f, + blurRadius = 0f, + spread = 1f, + borderInsets = borderInsets, + borderRadius = borderRadius, + ) + + private fun outsetShadow(borderRadius: BorderRadiusStyle?): OutsetBoxShadowDrawable = + OutsetBoxShadowDrawable( + context = context, + shadowColor = Color.BLACK, + offsetX = 2f, + offsetY = 3f, + blurRadius = 0f, + spread = 1f, + borderRadius = borderRadius, + ) + + private fun points(value: Float): LengthPercentage = + LengthPercentage(value, LengthPercentageType.POINT) + + private fun roundedBy(radius: Float): BorderRadiusStyle = + BorderRadiusStyle().apply { set(BorderRadiusProp.BORDER_RADIUS, points(radius)) } + + private fun startRoundedBy(radius: Float): BorderRadiusStyle = + BorderRadiusStyle().apply { set(BorderRadiusProp.BORDER_TOP_START_RADIUS, points(radius)) } + + private fun T.withBounds(bounds: Rect): T = apply { this.bounds = bounds } + + private fun assertDrawsTheSameGeometryObjectsOnEveryDraw(drawable: Drawable) { + drawable.bounds = Rect(0, 0, 100, 50) + + val firstDraw = RecordingCanvas().also { drawable.draw(it) }.geometry + val secondDraw = RecordingCanvas().also { drawable.draw(it) }.geometry + + assertThat(secondDraw).hasSameSizeAs(firstDraw) + firstDraw.zip(secondDraw).forEach { (first, second) -> assertThat(second).isSameAs(first) } + } + + private fun assertDrawsLike(drawable: Drawable, expected: Drawable) { + assertThat(RecordingCanvas().also { drawable.draw(it) }.calls) + .isEqualTo(RecordingCanvas().also { expected.draw(it) }.calls) + } + + /** Records each call a drawable makes as text, and every geometry object it hands the canvas. */ + private class RecordingCanvas : Canvas() { + val calls = mutableListOf() + val geometry = mutableListOf() + + override fun clipPath(path: Path): Boolean { + record("clipPath ${describe(path)}", path) + return true + } + + override fun clipOutPath(path: Path): Boolean { + record("clipOutPath ${describe(path)}", path) + return true + } + + override fun clipRect(rect: RectF): Boolean { + record("clipRect $rect", rect) + return true + } + + override fun clipOutRect(rect: Rect): Boolean { + record("clipOutRect $rect", rect) + return true + } + + override fun drawPath(path: Path, paint: Paint) { + record("drawPath ${describe(path)}", path) + } + + override fun drawRect(rect: RectF, paint: Paint) { + record("drawRect $rect", rect) + } + + override fun drawDoubleRoundRect( + outer: RectF, + outerRadii: FloatArray, + inner: RectF, + innerRadii: FloatArray, + paint: Paint, + ) { + record( + "drawDoubleRoundRect $outer ${outerRadii.toList()} $inner ${innerRadii.toList()}", + outer, + outerRadii, + inner, + innerRadii, + ) + } + + private fun record(call: String, vararg drawnGeometry: Any) { + calls.add(call) + geometry.addAll(drawnGeometry) + } + + private fun describe(path: Path): String = path.approximate(0.5f).toList().toString() + } +} From 0a26210b916fafe7f44d894684daf504266f9739 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Fri, 13 Nov 2026 19:43:06 +0200 Subject: [PATCH 2/4] Rebuild box-shadow geometry when the density or RTL swap changes The box-shadow drawables cache their geometry, which depends on the screen density and, in RTL, on the left/right swap preference, but rebuilt it only on a bounds or layout direction change and when borderRadius or borderInsets was assigned. Neither the density, which changes in place for an activity that handles density configuration changes, nor I18nManager.swapLeftAndRightInRTL() notifies a drawable, so one drawn again afterwards kept the old geometry. Each drawable now records the density and swap it built its geometry with and rebuilds it when either differs. --- .../drawable/InsetBoxShadowDrawable.kt | 13 ++- .../drawable/OutsetBoxShadowDrawable.kt | 13 ++- .../drawable/BoxShadowDrawableTest.kt | 88 ++++++++++++++++++- 3 files changed, 111 insertions(+), 3 deletions(-) diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/drawable/InsetBoxShadowDrawable.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/drawable/InsetBoxShadowDrawable.kt index cbe7489d5fd5..f3d30d2e52ee 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/drawable/InsetBoxShadowDrawable.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/drawable/InsetBoxShadowDrawable.kt @@ -18,8 +18,11 @@ import android.graphics.PixelFormat import android.graphics.Rect import android.graphics.RectF import android.graphics.drawable.Drawable +import android.util.LayoutDirection import androidx.annotation.RequiresApi +import com.facebook.react.modules.i18nmanager.I18nUtil import com.facebook.react.uimanager.FilterHelper +import com.facebook.react.uimanager.PixelUtil import com.facebook.react.uimanager.PixelUtil.dpToPx import com.facebook.react.uimanager.PixelUtil.pxToDp import com.facebook.react.uimanager.style.BorderInsets @@ -59,6 +62,8 @@ internal class InsetBoxShadowDrawable( } private var needUpdatePath = true + private var pathDensity = 0f + private var pathSwapsLeftAndRight = false private val paddingBoxRect = RectF() private val paddingBoxRadii = FloatArray(8) private var hasPaddingBoxRadii = false @@ -130,10 +135,16 @@ internal class InsetBoxShadowDrawable( } private fun updatePath() { - if (!needUpdatePath) { + // Neither the density nor the RTL left/right swap preference notifies the drawable + val density = PixelUtil.getDisplayMetricDensity() + val swapsLeftAndRight = + layoutDirection == LayoutDirection.RTL && I18nUtil.instance.doLeftAndRightSwapInRTL(context) + if (!needUpdatePath && density == pathDensity && swapsLeftAndRight == pathSwapsLeftAndRight) { return } needUpdatePath = false + pathDensity = density + pathSwapsLeftAndRight = swapsLeftAndRight val computedBorderRadii = computeBorderRadii() val computedBorderInsets = computeBorderInsets() diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/drawable/OutsetBoxShadowDrawable.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/drawable/OutsetBoxShadowDrawable.kt index e643535668d2..f93d1da484f3 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/drawable/OutsetBoxShadowDrawable.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/drawable/OutsetBoxShadowDrawable.kt @@ -18,8 +18,11 @@ import android.graphics.PixelFormat import android.graphics.Rect import android.graphics.RectF import android.graphics.drawable.Drawable +import android.util.LayoutDirection import androidx.annotation.RequiresApi +import com.facebook.react.modules.i18nmanager.I18nUtil import com.facebook.react.uimanager.FilterHelper +import com.facebook.react.uimanager.PixelUtil import com.facebook.react.uimanager.PixelUtil.dpToPx import com.facebook.react.uimanager.PixelUtil.pxToDp import com.facebook.react.uimanager.style.BorderRadiusStyle @@ -55,6 +58,8 @@ internal class OutsetBoxShadowDrawable( } private var needUpdatePath = true + private var pathDensity = 0f + private var pathSwapsLeftAndRight = false private val shadowRect = RectF() private var hasRoundedBorders = false private val subpixelInsetBounds = RectF() @@ -118,10 +123,16 @@ internal class OutsetBoxShadowDrawable( } private fun updatePath() { - if (!needUpdatePath) { + // Neither the density nor the RTL left/right swap preference notifies the drawable + val density = PixelUtil.getDisplayMetricDensity() + val swapsLeftAndRight = + layoutDirection == LayoutDirection.RTL && I18nUtil.instance.doLeftAndRightSwapInRTL(context) + if (!needUpdatePath && density == pathDensity && swapsLeftAndRight == pathSwapsLeftAndRight) { return } needUpdatePath = false + pathDensity = density + pathSwapsLeftAndRight = swapsLeftAndRight val resolutionWidth = bounds.width().toFloat().pxToDp() val resolutionHeight = bounds.height().toFloat().pxToDp() diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/drawable/BoxShadowDrawableTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/drawable/BoxShadowDrawableTest.kt index a1e66d64dace..8d9fb9f84672 100644 --- a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/drawable/BoxShadowDrawableTest.kt +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/drawable/BoxShadowDrawableTest.kt @@ -20,6 +20,7 @@ import android.graphics.drawable.Drawable import android.util.DisplayMetrics import android.util.LayoutDirection import com.facebook.react.internal.featureflags.ReactNativeFeatureFlagsForTests +import com.facebook.react.modules.i18nmanager.I18nUtil import com.facebook.react.uimanager.DisplayMetricsHolder import com.facebook.react.uimanager.LengthPercentage import com.facebook.react.uimanager.LengthPercentageType @@ -48,12 +49,13 @@ class BoxShadowDrawableTest { @Before fun setup() { ReactNativeFeatureFlagsForTests.setUp() - DisplayMetricsHolder.setScreenDisplayMetrics(DisplayMetrics().apply { density = 2f }) + setScreenDensity(2f) } @After fun tearDown() { DisplayMetricsHolder.setScreenDisplayMetrics(null) + I18nUtil.instance.swapLeftAndRightInRTL(context, true) } @Test @@ -183,6 +185,81 @@ class BoxShadowDrawableTest { assertDrawsLike(drawable, expected) } + @Test + fun insetShadowFollowsADensityChange() { + val drawable = insetShadow(roundedBy(8f), allEdgesBy(3f)).withBounds(Rect(0, 0, 100, 50)) + drawable.draw(RecordingCanvas()) + + setScreenDensity(3f) + + val expected = insetShadow(roundedBy(8f), allEdgesBy(3f)).withBounds(Rect(0, 0, 100, 50)) + assertDrawsLike(drawable, expected) + } + + @Test + fun outsetShadowFollowsADensityChange() { + val drawable = outsetShadow(roundedBy(8f)).withBounds(Rect(0, 0, 100, 50)) + drawable.draw(RecordingCanvas()) + + setScreenDensity(3f) + + assertDrawsLike(drawable, outsetShadow(roundedBy(8f)).withBounds(Rect(0, 0, 100, 50))) + } + + @Test + fun insetShadowFollowsTheLeftAndRightSwapPreferenceInRtl() { + val drawable = insetShadow(topLeftRoundedBy(8f), leftEdgeBy(3f)).withBounds(Rect(0, 0, 100, 50)) + drawable.layoutDirection = LayoutDirection.RTL + drawable.draw(RecordingCanvas()) + + I18nUtil.instance.swapLeftAndRightInRTL(context, false) + + val expected = insetShadow(topLeftRoundedBy(8f), leftEdgeBy(3f)).withBounds(Rect(0, 0, 100, 50)) + expected.layoutDirection = LayoutDirection.RTL + assertDrawsLike(drawable, expected) + } + + @Test + fun outsetShadowFollowsTheLeftAndRightSwapPreferenceInRtl() { + val drawable = outsetShadow(topLeftRoundedBy(8f)).withBounds(Rect(0, 0, 100, 50)) + drawable.layoutDirection = LayoutDirection.RTL + drawable.draw(RecordingCanvas()) + + I18nUtil.instance.swapLeftAndRightInRTL(context, false) + + val expected = outsetShadow(topLeftRoundedBy(8f)).withBounds(Rect(0, 0, 100, 50)) + expected.layoutDirection = LayoutDirection.RTL + assertDrawsLike(drawable, expected) + } + + @Test + fun insetShadowInRtlKeepsItsGeometryAcrossDraws() { + val borderRadius = spy(topLeftRoundedBy(8f)) + val drawable = insetShadow(borderRadius, leftEdgeBy(3f)).withBounds(Rect(0, 0, 100, 50)) + drawable.layoutDirection = LayoutDirection.RTL + + drawable.draw(RecordingCanvas()) + drawable.draw(RecordingCanvas()) + + verify(borderRadius, times(1)).resolve(any(), any(), any(), any()) + } + + @Test + fun outsetShadowInRtlKeepsItsGeometryAcrossDraws() { + val borderRadius = spy(topLeftRoundedBy(8f)) + val drawable = outsetShadow(borderRadius).withBounds(Rect(0, 0, 100, 50)) + drawable.layoutDirection = LayoutDirection.RTL + + drawable.draw(RecordingCanvas()) + drawable.draw(RecordingCanvas()) + + verify(borderRadius, times(1)).resolve(any(), any(), any(), any()) + } + + private fun setScreenDensity(density: Float) { + DisplayMetricsHolder.setScreenDisplayMetrics(DisplayMetrics().apply { this.density = density }) + } + private fun insetShadow( borderRadius: BorderRadiusStyle?, borderInsets: BorderInsets? = null, @@ -218,6 +295,15 @@ class BoxShadowDrawableTest { private fun startRoundedBy(radius: Float): BorderRadiusStyle = BorderRadiusStyle().apply { set(BorderRadiusProp.BORDER_TOP_START_RADIUS, points(radius)) } + private fun topLeftRoundedBy(radius: Float): BorderRadiusStyle = + BorderRadiusStyle().apply { set(BorderRadiusProp.BORDER_TOP_LEFT_RADIUS, points(radius)) } + + private fun allEdgesBy(width: Float): BorderInsets = + BorderInsets().apply { setBorderWidth(LogicalEdge.ALL, width) } + + private fun leftEdgeBy(width: Float): BorderInsets = + BorderInsets().apply { setBorderWidth(LogicalEdge.LEFT, width) } + private fun T.withBounds(bounds: Rect): T = apply { this.bounds = bounds } private fun assertDrawsTheSameGeometryObjectsOnEveryDraw(drawable: Drawable) { From dcd1cb9edca6835d5ec768ceb95f0c825c0534b7 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Fri, 13 Nov 2026 21:50:35 +0200 Subject: [PATCH 3/4] Read the RTL swap preference only where a box shadow resolves it The geometry check read the left/right swap preference on every RTL draw, also for a shadow with no border radius and no border insets, whose geometry never reads it. It now reads it only when the drawable has a border radius, or border insets for an inset shadow, as resolving them does. --- .../drawable/InsetBoxShadowDrawable.kt | 4 +- .../drawable/OutsetBoxShadowDrawable.kt | 4 +- .../drawable/BoxShadowDrawableTest.kt | 55 ++++++++++++++++++- 3 files changed, 58 insertions(+), 5 deletions(-) diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/drawable/InsetBoxShadowDrawable.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/drawable/InsetBoxShadowDrawable.kt index f3d30d2e52ee..3f0302f4fe29 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/drawable/InsetBoxShadowDrawable.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/drawable/InsetBoxShadowDrawable.kt @@ -138,7 +138,9 @@ internal class InsetBoxShadowDrawable( // Neither the density nor the RTL left/right swap preference notifies the drawable val density = PixelUtil.getDisplayMetricDensity() val swapsLeftAndRight = - layoutDirection == LayoutDirection.RTL && I18nUtil.instance.doLeftAndRightSwapInRTL(context) + layoutDirection == LayoutDirection.RTL && + (borderRadius != null || borderInsets != null) && + I18nUtil.instance.doLeftAndRightSwapInRTL(context) if (!needUpdatePath && density == pathDensity && swapsLeftAndRight == pathSwapsLeftAndRight) { return } diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/drawable/OutsetBoxShadowDrawable.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/drawable/OutsetBoxShadowDrawable.kt index f93d1da484f3..0e685da9a023 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/drawable/OutsetBoxShadowDrawable.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/drawable/OutsetBoxShadowDrawable.kt @@ -126,7 +126,9 @@ internal class OutsetBoxShadowDrawable( // Neither the density nor the RTL left/right swap preference notifies the drawable val density = PixelUtil.getDisplayMetricDensity() val swapsLeftAndRight = - layoutDirection == LayoutDirection.RTL && I18nUtil.instance.doLeftAndRightSwapInRTL(context) + layoutDirection == LayoutDirection.RTL && + borderRadius != null && + I18nUtil.instance.doLeftAndRightSwapInRTL(context) if (!needUpdatePath && density == pathDensity && swapsLeftAndRight == pathSwapsLeftAndRight) { return } diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/drawable/BoxShadowDrawableTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/drawable/BoxShadowDrawableTest.kt index 8d9fb9f84672..3e0df093f69c 100644 --- a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/drawable/BoxShadowDrawableTest.kt +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/drawable/BoxShadowDrawableTest.kt @@ -8,6 +8,8 @@ package com.facebook.react.uimanager.drawable import android.content.Context +import android.content.ContextWrapper +import android.content.SharedPreferences import android.graphics.Canvas import android.graphics.Color import android.graphics.Paint @@ -256,6 +258,39 @@ class BoxShadowDrawableTest { verify(borderRadius, times(1)).resolve(any(), any(), any(), any()) } + @Test + fun rtlShadowsWithoutARadiusOrInsetsReadNoSwapPreference() { + val countingContext = PreferenceCountingContext(context) + val shadows = + listOf( + insetShadow(null, drawableContext = countingContext), + outsetShadow(null, drawableContext = countingContext), + ) + shadows.forEach { + it.bounds = Rect(0, 0, 100, 50) + it.layoutDirection = LayoutDirection.RTL + } + + repeat(2) { shadows.forEach { it.draw(RecordingCanvas()) } } + + assertThat(countingContext.preferenceReads).isZero() + } + + @Test + fun ltrShadowsReadNoSwapPreference() { + val countingContext = PreferenceCountingContext(context) + val shadows = + listOf( + insetShadow(topLeftRoundedBy(8f), leftEdgeBy(3f), drawableContext = countingContext), + outsetShadow(topLeftRoundedBy(8f), drawableContext = countingContext), + ) + shadows.forEach { it.bounds = Rect(0, 0, 100, 50) } + + repeat(2) { shadows.forEach { it.draw(RecordingCanvas()) } } + + assertThat(countingContext.preferenceReads).isZero() + } + private fun setScreenDensity(density: Float) { DisplayMetricsHolder.setScreenDisplayMetrics(DisplayMetrics().apply { this.density = density }) } @@ -263,9 +298,10 @@ class BoxShadowDrawableTest { private fun insetShadow( borderRadius: BorderRadiusStyle?, borderInsets: BorderInsets? = null, + drawableContext: Context = context, ): InsetBoxShadowDrawable = InsetBoxShadowDrawable( - context = context, + context = drawableContext, shadowColor = Color.BLACK, offsetX = 2f, offsetY = 3f, @@ -275,9 +311,12 @@ class BoxShadowDrawableTest { borderRadius = borderRadius, ) - private fun outsetShadow(borderRadius: BorderRadiusStyle?): OutsetBoxShadowDrawable = + private fun outsetShadow( + borderRadius: BorderRadiusStyle?, + drawableContext: Context = context, + ): OutsetBoxShadowDrawable = OutsetBoxShadowDrawable( - context = context, + context = drawableContext, shadowColor = Color.BLACK, offsetX = 2f, offsetY = 3f, @@ -321,6 +360,16 @@ class BoxShadowDrawableTest { .isEqualTo(RecordingCanvas().also { expected.draw(it) }.calls) } + private class PreferenceCountingContext(base: Context) : ContextWrapper(base) { + var preferenceReads = 0 + private set + + override fun getSharedPreferences(name: String?, mode: Int): SharedPreferences { + preferenceReads += 1 + return super.getSharedPreferences(name, mode) + } + } + /** Records each call a drawable makes as text, and every geometry object it hands the canvas. */ private class RecordingCanvas : Canvas() { val calls = mutableListOf() From 7dafeabe80662f1c6aeeb764bedd6af23215036d Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Sun, 18 Oct 2026 23:10:51 +0300 Subject: [PATCH 4/4] Let Skia cache a blurred inset box shadow A blurred inset shadow is drawn with drawDoubleRoundRect, and Skia turns a double round rect with a mask filter into a new volatile path on every draw, which it never caches: the blurred mask is rendered again, offscreen, every time the view is drawn. Draw a blurred inset shadow from a cached even-odd path instead, so Skia can key its blurred mask on the path, the blur and the matrix. An unblurred shadow keeps drawDoubleRoundRect, which Skia draws analytically. A spread past half the padding box's height now empties the shadow's inner shape, as one past half its width did: the inner rect was left inverted, which drawDoubleRoundRect sorts into a hole the shape does not have and the path drew without one. --- .../drawable/InsetBoxShadowDrawable.kt | 35 +++++- .../drawable/BoxShadowDrawableTest.kt | 117 +++++++++++++++++- 2 files changed, 146 insertions(+), 6 deletions(-) diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/drawable/InsetBoxShadowDrawable.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/drawable/InsetBoxShadowDrawable.kt index 3f0302f4fe29..bcd5262101b6 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/drawable/InsetBoxShadowDrawable.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/drawable/InsetBoxShadowDrawable.kt @@ -61,6 +61,8 @@ internal class InsetBoxShadowDrawable( } } + private val isBlurred = shadowPaint.maskFilter != null + private var needUpdatePath = true private var pathDensity = 0f private var pathSwapsLeftAndRight = false @@ -71,6 +73,7 @@ internal class InsetBoxShadowDrawable( private val innerRect = RectF() private val innerRadii = FloatArray(8) private val outerRect = RectF() + private val blurredShadowPath = Path() /* * We assume borderRadius & borderInsets to be shared across multiple drawables @@ -124,10 +127,23 @@ internal class InsetBoxShadowDrawable( canvas.save().let { saveCount -> if (hasPaddingBoxRadii) { canvas.clipPath(paddingBoxPath) - canvas.drawDoubleRoundRect(outerRect, ZERO_RADII, innerRect, innerRadii, shadowPaint) } else { canvas.clipRect(paddingBoxRect) - canvas.drawDoubleRoundRect(outerRect, ZERO_RADII, innerRect, ZERO_RADII, shadowPaint) + } + + // Skia caches a blurred mask only for a path it can key, and a double round rect reaches it + // as a volatile path, which it never keys. + // https://skia.googlesource.com/skia/+/d426e7d44d66c9a940dd42f5dde62c712caf801a/src/gpu/ganesh/Device.cpp#690 + if (isBlurred) { + canvas.drawPath(blurredShadowPath, shadowPaint) + } else { + canvas.drawDoubleRoundRect( + outerRect, + ZERO_RADII, + innerRect, + if (hasPaddingBoxRadii) innerRadii else ZERO_RADII, + shadowPaint, + ) } canvas.restoreToCount(saveCount) @@ -181,7 +197,7 @@ internal class InsetBoxShadowDrawable( val y = offsetY.dpToPx() val spreadExtent = spread.dpToPx() innerRect.set(paddingBoxRect) - if (2 * spreadExtent > paddingBoxRect.width()) { + if (2 * spreadExtent > paddingBoxRect.width() || 2 * spreadExtent > paddingBoxRect.height()) { innerRect.setEmpty() } else { innerRect.inset(spreadExtent, spreadExtent) @@ -202,6 +218,19 @@ internal class InsetBoxShadowDrawable( innerRadii[index] = adjustRadiusForSpread(paddingBoxRadii[index], -spreadExtent) } } + + if (isBlurred) { + blurredShadowPath.rewind() + blurredShadowPath.fillType = Path.FillType.EVEN_ODD + blurredShadowPath.addRect(outerRect, Path.Direction.CW) + if (!innerRect.isEmpty) { + blurredShadowPath.addRoundRect( + innerRect, + if (hasPaddingBoxRadii) innerRadii else ZERO_RADII, + Path.Direction.CW, + ) + } + } } private fun computeBorderRadii(): ComputedBorderRadius? { diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/drawable/BoxShadowDrawableTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/drawable/BoxShadowDrawableTest.kt index 3e0df093f69c..aa940dd47245 100644 --- a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/drawable/BoxShadowDrawableTest.kt +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/drawable/BoxShadowDrawableTest.kt @@ -70,6 +70,27 @@ class BoxShadowDrawableTest { assertDrawsTheSameGeometryObjectsOnEveryDraw(insetShadow(null)) } + @Test + fun blurredRoundedInsetShadowDrawsTheSamePathOnEveryDraw() { + assertDrawsTheSamePathOnEveryDraw(insetShadow(roundedBy(8f), blurRadius = 4f)) + } + + @Test + fun blurredRectangularInsetShadowDrawsTheSamePathOnEveryDraw() { + assertDrawsTheSamePathOnEveryDraw(insetShadow(null, blurRadius = 4f)) + } + + @Test + fun unblurredInsetShadowDrawsADoubleRoundRect() { + val calls = + RecordingCanvas() + .also { insetShadow(roundedBy(8f)).withBounds(Rect(0, 0, 100, 50)).draw(it) } + .calls + + assertThat(calls).anyMatch { it.startsWith("drawDoubleRoundRect") } + assertThat(calls).noneMatch { it.startsWith("drawPath") } + } + @Test fun roundedOutsetShadowDrawsTheSameGeometryObjectsOnEveryDraw() { assertDrawsTheSameGeometryObjectsOnEveryDraw(outsetShadow(roundedBy(8f))) @@ -90,6 +111,80 @@ class BoxShadowDrawableTest { assertDrawsLike(drawable, insetShadow(roundedBy(8f)).withBounds(Rect(0, 0, 100, 50))) } + @Test + fun blurredInsetShadowFollowsBoundsThatGrowFromEmpty() { + val drawable = insetShadow(roundedBy(8f), blurRadius = 4f) + drawable.draw(RecordingCanvas()) + + drawable.bounds = Rect(0, 0, 100, 50) + + assertDrawsLike( + drawable, + insetShadow(roundedBy(8f), blurRadius = 4f).withBounds(Rect(0, 0, 100, 50)), + ) + } + + @Test + fun blurredInsetShadowFollowsARadiusChangedInPlaceAndReassigned() { + val borderRadius = roundedBy(8f) + val drawable = insetShadow(borderRadius, blurRadius = 4f).withBounds(Rect(0, 0, 100, 50)) + drawable.draw(RecordingCanvas()) + + borderRadius.set(BorderRadiusProp.BORDER_RADIUS, points(4f)) + drawable.borderRadius = borderRadius + + assertDrawsLike( + drawable, + insetShadow(roundedBy(4f), blurRadius = 4f).withBounds(Rect(0, 0, 100, 50)), + ) + } + + @Test + fun blurredInsetShadowFollowsADensityChange() { + val drawable = insetShadow(roundedBy(8f), blurRadius = 4f).withBounds(Rect(0, 0, 100, 50)) + drawable.draw(RecordingCanvas()) + + setScreenDensity(3f) + + assertDrawsLike( + drawable, + insetShadow(roundedBy(8f), blurRadius = 4f).withBounds(Rect(0, 0, 100, 50)), + ) + } + + @Test + fun blurredInsetShadowFollowsTheLeftAndRightSwapPreferenceInRtl() { + val drawable = + insetShadow(topLeftRoundedBy(8f), leftEdgeBy(3f), blurRadius = 4f) + .withBounds(Rect(0, 0, 100, 50)) + drawable.layoutDirection = LayoutDirection.RTL + drawable.draw(RecordingCanvas()) + + I18nUtil.instance.swapLeftAndRightInRTL(context, false) + + val expected = + insetShadow(topLeftRoundedBy(8f), leftEdgeBy(3f), blurRadius = 4f) + .withBounds(Rect(0, 0, 100, 50)) + expected.layoutDirection = LayoutDirection.RTL + assertDrawsLike(drawable, expected) + } + + @Test + fun insetShadowWhoseSpreadPassesHalfItsHeightDrawsLikeOneWhoseSpreadPassesItsWidth() { + assertDrawsLike( + insetShadow(null, spread = 15f).withBounds(Rect(0, 0, 100, 50)), + insetShadow(null, spread = 60f).withBounds(Rect(0, 0, 100, 50)), + ) + } + + @Test + fun blurredInsetShadowWhoseSpreadPassesHalfItsHeightDrawsLikeOneWhoseSpreadPassesItsWidth() { + assertDrawsLike( + insetShadow(null, blurRadius = 4f, spread = 15f).withBounds(Rect(0, 0, 100, 50)), + insetShadow(null, blurRadius = 4f, spread = 60f).withBounds(Rect(0, 0, 100, 50)), + ) + } + @Test fun outsetShadowFollowsBoundsThatGrowFromEmpty() { val drawable = outsetShadow(roundedBy(8f)) @@ -298,6 +393,8 @@ class BoxShadowDrawableTest { private fun insetShadow( borderRadius: BorderRadiusStyle?, borderInsets: BorderInsets? = null, + blurRadius: Float = 0f, + spread: Float = 1f, drawableContext: Context = context, ): InsetBoxShadowDrawable = InsetBoxShadowDrawable( @@ -305,8 +402,8 @@ class BoxShadowDrawableTest { shadowColor = Color.BLACK, offsetX = 2f, offsetY = 3f, - blurRadius = 0f, - spread = 1f, + blurRadius = blurRadius, + spread = spread, borderInsets = borderInsets, borderRadius = borderRadius, ) @@ -355,6 +452,18 @@ class BoxShadowDrawableTest { firstDraw.zip(secondDraw).forEach { (first, second) -> assertThat(second).isSameAs(first) } } + private fun assertDrawsTheSamePathOnEveryDraw(drawable: Drawable) { + drawable.bounds = Rect(0, 0, 100, 50) + + val firstDraw = RecordingCanvas().also { drawable.draw(it) }.drawnPaths + val secondDraw = RecordingCanvas().also { drawable.draw(it) }.drawnPaths + + assertThat(firstDraw).hasSize(1) + assertThat(secondDraw).hasSize(1) + assertThat(secondDraw.single()).isSameAs(firstDraw.single()) + assertThat(firstDraw.single().fillType).isEqualTo(Path.FillType.EVEN_ODD) + } + private fun assertDrawsLike(drawable: Drawable, expected: Drawable) { assertThat(RecordingCanvas().also { drawable.draw(it) }.calls) .isEqualTo(RecordingCanvas().also { expected.draw(it) }.calls) @@ -374,6 +483,7 @@ class BoxShadowDrawableTest { private class RecordingCanvas : Canvas() { val calls = mutableListOf() val geometry = mutableListOf() + val drawnPaths = mutableListOf() override fun clipPath(path: Path): Boolean { record("clipPath ${describe(path)}", path) @@ -396,7 +506,8 @@ class BoxShadowDrawableTest { } override fun drawPath(path: Path, paint: Paint) { - record("drawPath ${describe(path)}", path) + drawnPaths.add(path) + record("drawPath ${path.fillType} ${describe(path)}", path) } override fun drawRect(rect: RectF, paint: Paint) {