From cdf7b6b90de71a859ac45f2ca6a12d469e6a00dd Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Sun, 18 Oct 2026 23:09:47 +0300 Subject: [PATCH 1/5] 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/5] 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/5] 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/5] 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) { From 5af3a0e9882506e898eedee37e705a3623323c35 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Fri, 13 Nov 2026 23:59:16 +0200 Subject: [PATCH 5/5] Update box-shadow drawables in place when only their values change An animated box shadow hands BackgroundStyleApplicator.setBoxShadow a list of the same shape with new values on every frame, and setBoxShadow answered each one with new shadow drawables, a new CompositeBackgroundDrawable and a new view background, where setOutlineColor sets the color on the existing OutlineDrawable. Both shadow drawables now take their color, offsets, blur radius and spread together through setShadowValues, which invalidates the drawable once, and only when one of them changes, and setBoxShadow hands the existing drawables their new values when the list keeps the same number of inset and outset shadows. A color change keeps the cached geometry, so a blurred inset shadow keeps Skia's cached mask while its color animates. The mask filter is rebuilt when the blur radius or the screen density changes, as a new drawable would build it. CompositeBackgroundDrawable types its shadow lists by kind, so the applicator updates them without casts. The tests draw through RecordingCanvas, the file #59189 adds for the background and border drawable tests, byte for byte, so whichever of the two lands first carries it. --- .../uimanager/BackgroundStyleApplicator.kt | 107 +++-- .../uimanager/drawable/BoxShadowMaskFilter.kt | 26 ++ .../drawable/CompositeBackgroundDrawable.kt | 8 +- .../drawable/InsetBoxShadowDrawable.kt | 87 +++- .../drawable/OutsetBoxShadowDrawable.kt | 85 +++- .../BackgroundStyleApplicatorBoxShadowTest.kt | 300 +++++++++++++ .../drawable/BoxShadowDrawableTest.kt | 396 +++++++++++++++--- .../drawable/InvalidationCountingCallback.kt | 23 + .../uimanager/drawable/RecordingCanvas.kt | 93 ++++ 9 files changed, 980 insertions(+), 145 deletions(-) create mode 100644 packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/drawable/BoxShadowMaskFilter.kt create mode 100644 packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/BackgroundStyleApplicatorBoxShadowTest.kt create mode 100644 packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/drawable/InvalidationCountingCallback.kt create mode 100644 packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/drawable/RecordingCanvas.kt diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/BackgroundStyleApplicator.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/BackgroundStyleApplicator.kt index c3af3626ae6e..94c9fbdce2cd 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/BackgroundStyleApplicator.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/BackgroundStyleApplicator.kt @@ -145,7 +145,7 @@ public object BackgroundStyleApplicator { composite.borderInsets?.setBorderWidth(edge, width) if (Build.VERSION.SDK_INT >= MIN_INSET_BOX_SHADOW_SDK_VERSION) { - for (shadow in composite.innerShadows.filterIsInstance()) { + for (shadow in composite.innerShadows) { shadow.borderInsets = composite.borderInsets } } @@ -224,15 +224,13 @@ public object BackgroundStyleApplicator { compositeBackgroundDrawable.border?.invalidateSelf() if (Build.VERSION.SDK_INT >= MIN_OUTSET_BOX_SHADOW_SDK_VERSION) { - for (shadow in - compositeBackgroundDrawable.outerShadows.filterIsInstance()) { + for (shadow in compositeBackgroundDrawable.outerShadows) { shadow.borderRadius = compositeBackgroundDrawable.borderRadius } } if (Build.VERSION.SDK_INT >= MIN_INSET_BOX_SHADOW_SDK_VERSION) { - for (shadow in - compositeBackgroundDrawable.innerShadows.filterIsInstance()) { + for (shadow in compositeBackgroundDrawable.innerShadows) { shadow.borderRadius = compositeBackgroundDrawable.borderRadius } } @@ -375,6 +373,12 @@ public object BackgroundStyleApplicator { val borderInsets = compositeBackgroundDrawable.borderInsets val borderRadius = compositeBackgroundDrawable.borderRadius + // An animated shadow sets a list of the same shape on every frame, so its drawables take the + // new values in place instead of the view getting new drawables and a new background per frame. + val reuseShadows = holdsShadowDrawablesFor(compositeBackgroundDrawable, shadows) + var innerIndex = 0 + var outerIndex = 0 + /** * z-ordering of user-provided shadow-list is opposite direction of LayerDrawable z-ordering * https://drafts.csswg.org/css-backgrounds/#shadow-layers @@ -388,36 +392,77 @@ public object BackgroundStyleApplicator { val inset = boxShadow.inset ?: false if (inset && Build.VERSION.SDK_INT >= MIN_INSET_BOX_SHADOW_SDK_VERSION) { - innerShadows.add( - InsetBoxShadowDrawable( - context = view.context, - borderRadius = borderRadius, - borderInsets = borderInsets, - shadowColor = color, - offsetX = offsetX, - offsetY = offsetY, - blurRadius = blurRadius, - spread = spreadDistance, - ), - ) + if (reuseShadows) { + compositeBackgroundDrawable.innerShadows[innerIndex++].setShadowValues( + shadowColor = color, + offsetX = offsetX, + offsetY = offsetY, + blurRadius = blurRadius, + spread = spreadDistance, + ) + } else { + innerShadows.add( + InsetBoxShadowDrawable( + context = view.context, + borderRadius = borderRadius, + borderInsets = borderInsets, + shadowColor = color, + offsetX = offsetX, + offsetY = offsetY, + blurRadius = blurRadius, + spread = spreadDistance, + ), + ) + } } else if (!inset && Build.VERSION.SDK_INT >= MIN_OUTSET_BOX_SHADOW_SDK_VERSION) { - outerShadows.add( - OutsetBoxShadowDrawable( - context = view.context, - borderRadius = borderRadius, - shadowColor = color, - offsetX = offsetX, - offsetY = offsetY, - blurRadius = blurRadius, - spread = spreadDistance, - ), - ) + if (reuseShadows) { + compositeBackgroundDrawable.outerShadows[outerIndex++].setShadowValues( + shadowColor = color, + offsetX = offsetX, + offsetY = offsetY, + blurRadius = blurRadius, + spread = spreadDistance, + ) + } else { + outerShadows.add( + OutsetBoxShadowDrawable( + context = view.context, + borderRadius = borderRadius, + shadowColor = color, + offsetX = offsetX, + offsetY = offsetY, + blurRadius = blurRadius, + spread = spreadDistance, + ), + ) + } } } - view.background = - ensureCompositeBackgroundDrawable(view) - .withNewShadows(outerShadows = outerShadows, innerShadows = innerShadows) + if (!reuseShadows) { + view.background = + ensureCompositeBackgroundDrawable(view) + .withNewShadows(outerShadows = outerShadows, innerShadows = innerShadows) + } + } + + /** Whether the background already holds one shadow drawable per inset and per outset shadow. */ + private fun holdsShadowDrawablesFor( + compositeBackgroundDrawable: CompositeBackgroundDrawable, + shadows: List, + ): Boolean { + var innerCount = 0 + var outerCount = 0 + for (boxShadow in shadows) { + val inset = boxShadow.inset ?: false + if (inset && Build.VERSION.SDK_INT >= MIN_INSET_BOX_SHADOW_SDK_VERSION) { + innerCount++ + } else if (!inset && Build.VERSION.SDK_INT >= MIN_OUTSET_BOX_SHADOW_SDK_VERSION) { + outerCount++ + } + } + return innerCount == compositeBackgroundDrawable.innerShadows.size && + outerCount == compositeBackgroundDrawable.outerShadows.size } /** diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/drawable/BoxShadowMaskFilter.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/drawable/BoxShadowMaskFilter.kt new file mode 100644 index 000000000000..ab18d2e4fe81 --- /dev/null +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/drawable/BoxShadowMaskFilter.kt @@ -0,0 +1,26 @@ +/* + * 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.graphics.BlurMaskFilter +import com.facebook.react.uimanager.FilterHelper + +// "the resulting shadow must approximate {...} a Gaussian blur with a standard deviation equal +// to half the blur radius" +// https://www.w3.org/TR/css-backgrounds-3/#shadow-blur +private const val BLUR_RADIUS_SIGMA_SCALE = 0.5f + +/** The mask filter that blurs a box shadow by [blurRadius], or null when no blur shows. */ +internal fun createBoxShadowMaskFilter(blurRadius: Float): BlurMaskFilter? { + val convertedBlurRadius = FilterHelper.sigmaToRadius(blurRadius * BLUR_RADIUS_SIGMA_SCALE) + return if (convertedBlurRadius > 0) { + BlurMaskFilter(convertedBlurRadius, BlurMaskFilter.Blur.NORMAL) + } else { + null + } +} diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/drawable/CompositeBackgroundDrawable.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/drawable/CompositeBackgroundDrawable.kt index b42948166d46..961484830e48 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/drawable/CompositeBackgroundDrawable.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/drawable/CompositeBackgroundDrawable.kt @@ -36,7 +36,7 @@ internal class CompositeBackgroundDrawable( val originalBackground: Drawable? = null, /** Non-inset box shadows */ - val outerShadows: List = emptyList(), + val outerShadows: List = emptyList(), /** Background rendering Layer */ val background: BackgroundDrawable? = null, @@ -51,7 +51,7 @@ internal class CompositeBackgroundDrawable( val feedbackUnderlay: Drawable? = null, /** Inset box-shadows */ - val innerShadows: List = emptyList(), + val innerShadows: List = emptyList(), /** Outline */ val outline: OutlineDrawable? = null, @@ -117,8 +117,8 @@ internal class CompositeBackgroundDrawable( } fun withNewShadows( - outerShadows: List, - innerShadows: List, + outerShadows: List, + innerShadows: List, ): CompositeBackgroundDrawable { return CompositeBackgroundDrawable( context, 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 bcd5262101b6..0d8e724b7771 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 @@ -8,7 +8,6 @@ package com.facebook.react.uimanager.drawable import android.content.Context -import android.graphics.BlurMaskFilter import android.graphics.Canvas import android.graphics.Color import android.graphics.ColorFilter @@ -33,35 +32,28 @@ import kotlin.math.roundToInt internal const val MIN_INSET_BOX_SHADOW_SDK_VERSION = 29 -// "the resulting shadow must approximate {...} a Gaussian blur with a standard deviation equal -// to half the blur radius" -// https://www.w3.org/TR/css-backgrounds-3/#shadow-blur -private const val BLUR_RADIUS_SIGMA_SCALE = 0.5f - private val ZERO_RADII = floatArrayOf(0f, 0f, 0f, 0f, 0f, 0f, 0f, 0f) /** Draws an inner box-shadow https://www.w3.org/TR/css-backgrounds-3/#shadow-shape */ @RequiresApi(MIN_INSET_BOX_SHADOW_SDK_VERSION) internal class InsetBoxShadowDrawable( private val context: Context, - private val shadowColor: Int, - private val offsetX: Float, - private val offsetY: Float, - private val blurRadius: Float, - private val spread: Float, + private var shadowColor: Int, + private var offsetX: Float, + private var offsetY: Float, + private var blurRadius: Float, + private var spread: Float, borderInsets: BorderInsets? = null, borderRadius: BorderRadiusStyle? = null, ) : Drawable() { - private val shadowPaint = - Paint().apply { - color = shadowColor - val convertedBlurRadius = FilterHelper.sigmaToRadius(blurRadius * BLUR_RADIUS_SIGMA_SCALE) - if (convertedBlurRadius > 0) { - maskFilter = BlurMaskFilter(convertedBlurRadius, BlurMaskFilter.Blur.NORMAL) - } - } + private val shadowPaint = Paint() + private var needUpdateMaskFilter = true + private var maskFilterDensity = 0f + + private var drawableAlpha = 255 - private val isBlurred = shadowPaint.maskFilter != null + private val isBlurred: Boolean + get() = shadowPaint.maskFilter != null private var needUpdatePath = true private var pathDensity = 0f @@ -91,6 +83,10 @@ internal class InsetBoxShadowDrawable( needUpdatePath = true } + init { + applyShadowColor() + } + override fun onBoundsChange(bounds: Rect) { super.onBoundsChange(bounds) needUpdatePath = true @@ -101,11 +97,49 @@ internal class InsetBoxShadowDrawable( return true } + fun setShadowValues( + shadowColor: Int = this.shadowColor, + offsetX: Float = this.offsetX, + offsetY: Float = this.offsetY, + blurRadius: Float = this.blurRadius, + spread: Float = this.spread, + ) { + val colorChanged = shadowColor != this.shadowColor + val blurChanged = blurRadius != this.blurRadius + val geometryChanged = + blurChanged || offsetX != this.offsetX || offsetY != this.offsetY || spread != this.spread + if (!colorChanged && !geometryChanged) { + return + } + this.shadowColor = shadowColor + this.offsetX = offsetX + this.offsetY = offsetY + this.blurRadius = blurRadius + this.spread = spread + if (colorChanged) { + applyShadowColor() + } + if (blurChanged) { + needUpdateMaskFilter = true + } + if (geometryChanged) { + needUpdatePath = true + } + invalidateSelf() + } + override fun setAlpha(alpha: Int) { - shadowPaint.alpha = (((alpha / 255f) * (Color.alpha(shadowColor) / 255f)) * 255f).roundToInt() + drawableAlpha = alpha + applyShadowColor() invalidateSelf() } + private fun applyShadowColor() { + shadowPaint.color = shadowColor + shadowPaint.alpha = + (((drawableAlpha / 255f) * (Color.alpha(shadowColor) / 255f)) * 255f).roundToInt() + } + override fun setColorFilter(colorFilter: ColorFilter?) { shadowPaint.colorFilter = colorFilter invalidateSelf() @@ -122,6 +156,7 @@ internal class InsetBoxShadowDrawable( } override fun draw(canvas: Canvas) { + updateMaskFilter() updatePath() canvas.save().let { saveCount -> @@ -150,6 +185,16 @@ internal class InsetBoxShadowDrawable( } } + private fun updateMaskFilter() { + val density = PixelUtil.getDisplayMetricDensity() + if (!needUpdateMaskFilter && density == maskFilterDensity) { + return + } + needUpdateMaskFilter = false + maskFilterDensity = density + shadowPaint.maskFilter = createBoxShadowMaskFilter(blurRadius) + } + private fun updatePath() { // Neither the density nor the RTL left/right swap preference notifies the drawable val density = PixelUtil.getDisplayMetricDensity() 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 0e685da9a023..91fa15993a12 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 @@ -8,7 +8,6 @@ package com.facebook.react.uimanager.drawable import android.content.Context -import android.graphics.BlurMaskFilter import android.graphics.Canvas import android.graphics.Color import android.graphics.ColorFilter @@ -21,7 +20,6 @@ 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 @@ -32,30 +30,22 @@ import kotlin.math.roundToInt internal const val MIN_OUTSET_BOX_SHADOW_SDK_VERSION = 28 -// "the resulting shadow must approximate {...} a Gaussian blur with a standard deviation equal -// to half the blur radius" -// https://www.w3.org/TR/css-backgrounds-3/#shadow-blur -private const val BLUR_RADIUS_SIGMA_SCALE = 0.5f - /** Draws an outer box-shadow https://www.w3.org/TR/css-backgrounds-3/#shadow-shape */ @RequiresApi(MIN_OUTSET_BOX_SHADOW_SDK_VERSION) internal class OutsetBoxShadowDrawable( private val context: Context, - private val shadowColor: Int, - private val offsetX: Float, - private val offsetY: Float, - private val blurRadius: Float, - private val spread: Float, + private var shadowColor: Int, + private var offsetX: Float, + private var offsetY: Float, + private var blurRadius: Float, + private var spread: Float, borderRadius: BorderRadiusStyle? = null, ) : Drawable() { - private val shadowPaint = - Paint().apply { - color = shadowColor - val convertedBlurRadius = FilterHelper.sigmaToRadius(blurRadius * BLUR_RADIUS_SIGMA_SCALE) - if (convertedBlurRadius > 0) { - maskFilter = BlurMaskFilter(convertedBlurRadius, BlurMaskFilter.Blur.NORMAL) - } - } + private val shadowPaint = Paint() + private var needUpdateMaskFilter = true + private var maskFilterDensity = 0f + + private var drawableAlpha = 255 private var needUpdatePath = true private var pathDensity = 0f @@ -77,6 +67,10 @@ internal class OutsetBoxShadowDrawable( needUpdatePath = true } + init { + applyShadowColor() + } + override fun onBoundsChange(bounds: Rect) { super.onBoundsChange(bounds) needUpdatePath = true @@ -87,11 +81,49 @@ internal class OutsetBoxShadowDrawable( return true } + fun setShadowValues( + shadowColor: Int = this.shadowColor, + offsetX: Float = this.offsetX, + offsetY: Float = this.offsetY, + blurRadius: Float = this.blurRadius, + spread: Float = this.spread, + ) { + val colorChanged = shadowColor != this.shadowColor + val blurChanged = blurRadius != this.blurRadius + val geometryChanged = + offsetX != this.offsetX || offsetY != this.offsetY || spread != this.spread + if (!colorChanged && !blurChanged && !geometryChanged) { + return + } + this.shadowColor = shadowColor + this.offsetX = offsetX + this.offsetY = offsetY + this.blurRadius = blurRadius + this.spread = spread + if (colorChanged) { + applyShadowColor() + } + if (blurChanged) { + needUpdateMaskFilter = true + } + if (geometryChanged) { + needUpdatePath = true + } + invalidateSelf() + } + override fun setAlpha(alpha: Int) { - shadowPaint.alpha = (((alpha / 255f) * (Color.alpha(shadowColor) / 255f)) * 255f).roundToInt() + drawableAlpha = alpha + applyShadowColor() invalidateSelf() } + private fun applyShadowColor() { + shadowPaint.color = shadowColor + shadowPaint.alpha = + (((drawableAlpha / 255f) * (Color.alpha(shadowColor) / 255f)) * 255f).roundToInt() + } + override fun setColorFilter(colorFilter: ColorFilter?) { shadowPaint.colorFilter = colorFilter invalidateSelf() @@ -108,6 +140,7 @@ internal class OutsetBoxShadowDrawable( } override fun draw(canvas: Canvas) { + updateMaskFilter() updatePath() canvas.save().let { saveCount -> @@ -122,6 +155,16 @@ internal class OutsetBoxShadowDrawable( } } + private fun updateMaskFilter() { + val density = PixelUtil.getDisplayMetricDensity() + if (!needUpdateMaskFilter && density == maskFilterDensity) { + return + } + needUpdateMaskFilter = false + maskFilterDensity = density + shadowPaint.maskFilter = createBoxShadowMaskFilter(blurRadius) + } + private fun updatePath() { // Neither the density nor the RTL left/right swap preference notifies the drawable val density = PixelUtil.getDisplayMetricDensity() diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/BackgroundStyleApplicatorBoxShadowTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/BackgroundStyleApplicatorBoxShadowTest.kt new file mode 100644 index 000000000000..96924a0ae5b4 --- /dev/null +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/BackgroundStyleApplicatorBoxShadowTest.kt @@ -0,0 +1,300 @@ +/* + * 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 + +import android.content.Context +import android.graphics.Color +import android.graphics.Rect +import android.graphics.drawable.Drawable +import android.util.DisplayMetrics +import android.view.View +import com.facebook.react.internal.featureflags.ReactNativeFeatureFlagsForTests +import com.facebook.react.uimanager.drawable.CompositeBackgroundDrawable +import com.facebook.react.uimanager.drawable.InsetBoxShadowDrawable +import com.facebook.react.uimanager.drawable.InvalidationCountingCallback +import com.facebook.react.uimanager.drawable.OutsetBoxShadowDrawable +import com.facebook.react.uimanager.drawable.RecordingCanvas +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.BoxShadow +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.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment + +/** Tests for [BackgroundStyleApplicator.setBoxShadow] */ +@RunWith(RobolectricTestRunner::class) +class BackgroundStyleApplicatorBoxShadowTest { + + 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 aShadowListOfTheSameShapeKeepsTheBackgroundAndItsShadowDrawables() { + val view = View(context) + BackgroundStyleApplicator.setBoxShadow(view, listOf(outset(spread = 1f), inset(spread = 1f))) + val background = view.background as CompositeBackgroundDrawable + val outerShadow = background.outerShadows.single() + val innerShadow = background.innerShadows.single() + + BackgroundStyleApplicator.setBoxShadow(view, listOf(outset(spread = 2f), inset(spread = 2f))) + + assertThat(view.background).isSameAs(background) + assertThat(background.outerShadows.single()).isSameAs(outerShadow) + assertThat(background.innerShadows.single()).isSameAs(innerShadow) + } + + @Test + fun aShadowListOfTheSameShapeInvalidatesEachShadowOnce() { + val view = View(context) + BackgroundStyleApplicator.setBoxShadow(view, listOf(outset(spread = 1f), inset(spread = 1f))) + val background = view.background as CompositeBackgroundDrawable + val callback = InvalidationCountingCallback() + background.callback = callback + + BackgroundStyleApplicator.setBoxShadow( + view, + listOf( + outset(color = Color.RED, offsetX = -4f, offsetY = 5f, blurRadius = 4f, spread = 2f), + inset(color = Color.BLUE, offsetX = 6f, offsetY = -1f, blurRadius = 6f, spread = 3f), + ), + ) + + assertThat(callback.invalidations).isEqualTo(2) + } + + @Test + fun aShadowListOfTheSameShapeDrawsItsNewValues() { + val view = View(context) + BackgroundStyleApplicator.setBoxShadow(view, listOf(outset(spread = 1f), inset(spread = 1f))) + + BackgroundStyleApplicator.setBoxShadow( + view, + listOf( + outset(color = Color.RED, offsetX = -4f, offsetY = 5f, blurRadius = 4f, spread = 2f), + inset(color = Color.BLUE, offsetX = 6f, offsetY = -1f, blurRadius = 6f, spread = 3f), + ), + ) + + val background = view.background as CompositeBackgroundDrawable + assertDrawsLike( + background.outerShadows.single(), + OutsetBoxShadowDrawable( + context = context, + shadowColor = Color.RED, + offsetX = -4f, + offsetY = 5f, + blurRadius = 4f, + spread = 2f, + ), + ) + assertDrawsLike( + background.innerShadows.single(), + InsetBoxShadowDrawable( + context = context, + shadowColor = Color.BLUE, + offsetX = 6f, + offsetY = -1f, + blurRadius = 6f, + spread = 3f, + ), + ) + } + + @Test + fun aShadowListOfTheSameShapeAfterADensityChangeDrawsLikeNewShadows() { + val view = View(context) + BackgroundStyleApplicator.setBoxShadow( + view, + listOf(outset(blurRadius = 0.4f, spread = 1f), inset(blurRadius = 0.4f, spread = 1f)), + ) + val background = view.background as CompositeBackgroundDrawable + background.outerShadows.single().drawAt(Rect(0, 0, 100, 50)) + background.innerShadows.single().drawAt(Rect(0, 0, 100, 50)) + + DisplayMetricsHolder.setScreenDisplayMetrics(DisplayMetrics().apply { density = 3f }) + BackgroundStyleApplicator.setBoxShadow( + view, + listOf( + outset(color = Color.RED, blurRadius = 0.4f, spread = 1f), + inset(color = Color.BLUE, blurRadius = 0.4f, spread = 1f), + ), + ) + + assertDrawsLike( + background.outerShadows.single(), + OutsetBoxShadowDrawable( + context = context, + shadowColor = Color.RED, + offsetX = 2f, + offsetY = 3f, + blurRadius = 0.4f, + spread = 1f, + ), + ) + assertDrawsLike( + background.innerShadows.single(), + InsetBoxShadowDrawable( + context = context, + shadowColor = Color.BLUE, + offsetX = 2f, + offsetY = 3f, + blurRadius = 0.4f, + spread = 1f, + ), + ) + } + + @Test + fun aShadowListOfAnotherShapeGetsNewShadowDrawables() { + val view = View(context) + BackgroundStyleApplicator.setBoxShadow(view, listOf(outset(spread = 1f))) + val firstShadow = (view.background as CompositeBackgroundDrawable).outerShadows.single() + + BackgroundStyleApplicator.setBoxShadow(view, listOf(outset(spread = 1f), outset(spread = 2f))) + val twoShadows = (view.background as CompositeBackgroundDrawable).outerShadows + assertThat(twoShadows).hasSize(2) + assertThat(twoShadows).doesNotContain(firstShadow) + + BackgroundStyleApplicator.setBoxShadow(view, listOf(inset(spread = 1f), outset(spread = 2f))) + val background = view.background as CompositeBackgroundDrawable + assertThat(background.innerShadows).hasSize(1) + assertThat(background.outerShadows).hasSize(1) + assertThat(background.outerShadows.single()).isNotIn(twoShadows) + } + + @Test + fun aBorderRadiusSetAfterTheShadowsReachesThem() { + val view = View(context) + BackgroundStyleApplicator.setBoxShadow(view, listOf(outset(spread = 1f), inset(spread = 1f))) + + BackgroundStyleApplicator.setBorderRadius(view, BorderRadiusProp.BORDER_RADIUS, points(8f)) + + val borderRadius = BorderRadiusStyle().apply { set(BorderRadiusProp.BORDER_RADIUS, points(8f)) } + val background = view.background as CompositeBackgroundDrawable + assertDrawsLike( + background.outerShadows.single(), + OutsetBoxShadowDrawable( + context = context, + shadowColor = Color.BLACK, + offsetX = 2f, + offsetY = 3f, + blurRadius = 0f, + spread = 1f, + borderRadius = borderRadius, + ), + ) + assertDrawsLike( + background.innerShadows.single(), + InsetBoxShadowDrawable( + context = context, + shadowColor = Color.BLACK, + offsetX = 2f, + offsetY = 3f, + blurRadius = 0f, + spread = 1f, + borderRadius = borderRadius, + ), + ) + } + + @Test + fun aBorderWidthSetAfterTheShadowsReachesTheInsetShadow() { + val view = View(context) + BackgroundStyleApplicator.setBoxShadow(view, listOf(inset(spread = 1f))) + + BackgroundStyleApplicator.setBorderWidth(view, LogicalEdge.ALL, 3f) + + val background = view.background as CompositeBackgroundDrawable + assertDrawsLike( + background.innerShadows.single(), + InsetBoxShadowDrawable( + context = context, + shadowColor = Color.BLACK, + offsetX = 2f, + offsetY = 3f, + blurRadius = 0f, + spread = 1f, + borderInsets = BorderInsets().apply { setBorderWidth(LogicalEdge.ALL, 3f) }, + ), + ) + } + + @Test + fun anEmptyShadowListRemovesTheShadows() { + val view = View(context) + BackgroundStyleApplicator.setBoxShadow(view, listOf(outset(spread = 1f), inset(spread = 1f))) + + BackgroundStyleApplicator.setBoxShadow(view, emptyList()) + + val background = view.background as CompositeBackgroundDrawable + assertThat(background.outerShadows).isEmpty() + assertThat(background.innerShadows).isEmpty() + } + + private fun outset( + color: Int = Color.BLACK, + offsetX: Float = 2f, + offsetY: Float = 3f, + blurRadius: Float = 0f, + spread: Float, + ): BoxShadow = + BoxShadow( + offsetX = offsetX, + offsetY = offsetY, + color = color, + blurRadius = blurRadius, + spreadDistance = spread, + ) + + private fun inset( + color: Int = Color.BLACK, + offsetX: Float = 2f, + offsetY: Float = 3f, + blurRadius: Float = 0f, + spread: Float, + ): BoxShadow = + BoxShadow( + offsetX = offsetX, + offsetY = offsetY, + color = color, + blurRadius = blurRadius, + spreadDistance = spread, + inset = true, + ) + + private fun points(value: Float): LengthPercentage = + LengthPercentage(value, LengthPercentageType.POINT) + + private fun Drawable.drawAt(bounds: Rect) { + this.bounds = bounds + draw(RecordingCanvas()) + } + + private fun assertDrawsLike(drawable: Drawable, expected: Drawable) { + drawable.bounds = Rect(0, 0, 100, 50) + expected.bounds = Rect(0, 0, 100, 50) + assertThat(RecordingCanvas().also { drawable.draw(it) }.calls) + .isEqualTo(RecordingCanvas().also { expected.draw(it) }.calls) + } +} 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 aa940dd47245..2dbaccb21e9c 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 @@ -12,6 +12,7 @@ import android.content.ContextWrapper import android.content.SharedPreferences import android.graphics.Canvas import android.graphics.Color +import android.graphics.MaskFilter import android.graphics.Paint import android.graphics.Path import android.graphics.PorterDuff @@ -42,6 +43,8 @@ import org.mockito.kotlin.verify import org.robolectric.RobolectricTestRunner import org.robolectric.RuntimeEnvironment +private val TRANSLUCENT_RED = Color.argb(128, 255, 0, 0) + /** Tests for [InsetBoxShadowDrawable] and [OutsetBoxShadowDrawable] */ @RunWith(RobolectricTestRunner::class) class BoxShadowDrawableTest { @@ -233,29 +236,37 @@ class BoxShadowDrawableTest { } @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()) + fun insetShadowKeepsItsGeometryWhenItsColorAlphaOrColorFilterChanges() { + assertKeepsItsGeometryThrough({ insetShadow(it) }) { + it.setShadowValues(shadowColor = Color.RED) + it.alpha = 128 + it.colorFilter = PorterDuffColorFilter(Color.BLUE, PorterDuff.Mode.SRC_IN) + } } @Test - fun outsetShadowKeepsItsGeometryWhenItsAlphaOrColorFilterChanges() { - val borderRadius = spy(roundedBy(8f)) - val drawable = outsetShadow(borderRadius).withBounds(Rect(0, 0, 100, 50)) - drawable.draw(RecordingCanvas()) + fun blurredInsetShadowKeepsItsGeometryWhenItsColorAlphaOrColorFilterChanges() { + assertKeepsItsGeometryThrough({ insetShadow(it, blurRadius = 4f) }) { + it.setShadowValues(shadowColor = Color.RED) + it.alpha = 128 + it.colorFilter = PorterDuffColorFilter(Color.BLUE, PorterDuff.Mode.SRC_IN) + } + } - drawable.alpha = 128 - drawable.colorFilter = PorterDuffColorFilter(Color.RED, PorterDuff.Mode.SRC_IN) - drawable.draw(RecordingCanvas()) + @Test + fun outsetShadowKeepsItsGeometryWhenItsColorAlphaOrColorFilterChanges() { + assertKeepsItsGeometryThrough({ outsetShadow(it, blurRadius = 4f) }) { + it.setShadowValues(shadowColor = Color.RED) + it.alpha = 128 + it.colorFilter = PorterDuffColorFilter(Color.BLUE, PorterDuff.Mode.SRC_IN) + } + } - verify(borderRadius, times(1)).resolve(any(), any(), any(), any()) + @Test + fun outsetShadowKeepsItsGeometryWhenItsBlurChanges() { + assertKeepsItsGeometryThrough({ outsetShadow(it, blurRadius = 4f) }) { + it.setShadowValues(blurRadius = 6f) + } } @Test @@ -386,6 +397,210 @@ class BoxShadowDrawableTest { assertThat(countingContext.preferenceReads).isZero() } + @Test + fun insetShadowFollowsEachShadowValueChangedAloneInPlace() { + assertFollowsEachChangeAlone( + { insetShadow(roundedBy(8f)) }, + InPlaceChange("shadowColor", { it.setShadowValues(shadowColor = Color.RED) }) { + insetShadow(roundedBy(8f), shadowColor = Color.RED) + }, + InPlaceChange("offsetX", { it.setShadowValues(offsetX = -4f) }) { + insetShadow(roundedBy(8f), offsetX = -4f) + }, + InPlaceChange("offsetY", { it.setShadowValues(offsetY = 5f) }) { + insetShadow(roundedBy(8f), offsetY = 5f) + }, + InPlaceChange("blurRadius", { it.setShadowValues(blurRadius = 4f) }) { + insetShadow(roundedBy(8f), blurRadius = 4f) + }, + InPlaceChange("spread", { it.setShadowValues(spread = 3f) }) { + insetShadow(roundedBy(8f), spread = 3f) + }, + ) + } + + @Test + fun outsetShadowFollowsEachShadowValueChangedAloneInPlace() { + assertFollowsEachChangeAlone( + { outsetShadow(roundedBy(8f)) }, + InPlaceChange("shadowColor", { it.setShadowValues(shadowColor = Color.RED) }) { + outsetShadow(roundedBy(8f), shadowColor = Color.RED) + }, + InPlaceChange("offsetX", { it.setShadowValues(offsetX = -4f) }) { + outsetShadow(roundedBy(8f), offsetX = -4f) + }, + InPlaceChange("offsetY", { it.setShadowValues(offsetY = 5f) }) { + outsetShadow(roundedBy(8f), offsetY = 5f) + }, + InPlaceChange("blurRadius", { it.setShadowValues(blurRadius = 4f) }) { + outsetShadow(roundedBy(8f), blurRadius = 4f) + }, + InPlaceChange("spread", { it.setShadowValues(spread = 3f) }) { + outsetShadow(roundedBy(8f), spread = 3f) + }, + ) + } + + @Test + fun insetShadowLosesItsBlurInPlace() { + val drawable = insetShadow(roundedBy(8f), blurRadius = 4f).withBounds(Rect(0, 0, 100, 50)) + drawable.draw(RecordingCanvas()) + + drawable.setShadowValues(blurRadius = 0f) + + assertDrawsLike(drawable, insetShadow(roundedBy(8f)).withBounds(Rect(0, 0, 100, 50))) + } + + @Test + fun insetShadowBlurFollowsADensityChange() { + val drawable = insetShadow(roundedBy(8f), blurRadius = 0.4f).withBounds(Rect(0, 0, 100, 50)) + drawable.draw(RecordingCanvas()) + + setScreenDensity(3f) + + assertDrawsLike( + drawable, + insetShadow(roundedBy(8f), blurRadius = 0.4f).withBounds(Rect(0, 0, 100, 50)), + ) + } + + @Test + fun outsetShadowBlurFollowsADensityChange() { + val drawable = outsetShadow(roundedBy(8f), blurRadius = 0.4f).withBounds(Rect(0, 0, 100, 50)) + drawable.draw(RecordingCanvas()) + + setScreenDensity(3f) + + assertDrawsLike( + drawable, + outsetShadow(roundedBy(8f), blurRadius = 0.4f).withBounds(Rect(0, 0, 100, 50)), + ) + } + + @Test + fun insetShadowKeepsItsMaskFilterAcrossDraws() { + assertKeepsItsMaskFilterAcrossDraws(insetShadow(roundedBy(8f), blurRadius = 4f)) + } + + @Test + fun outsetShadowKeepsItsMaskFilterAcrossDraws() { + assertKeepsItsMaskFilterAcrossDraws(outsetShadow(roundedBy(8f), blurRadius = 4f)) + } + + @Test + fun insetShadowRebuildsItsMaskFilterAfterADensityChange() { + assertRebuildsItsMaskFilterAfterADensityChange(insetShadow(roundedBy(8f), blurRadius = 4f)) + } + + @Test + fun outsetShadowRebuildsItsMaskFilterAfterADensityChange() { + assertRebuildsItsMaskFilterAfterADensityChange(outsetShadow(roundedBy(8f), blurRadius = 4f)) + } + + @Test + fun insetShadowColorChangedInPlaceKeepsTheDrawableAlpha() { + val drawable = insetShadow(null).withBounds(Rect(0, 0, 100, 50)).apply { alpha = 102 } + + drawable.setShadowValues(shadowColor = TRANSLUCENT_RED) + + assertDrawsLike( + drawable, + insetShadow(null, shadowColor = TRANSLUCENT_RED).withBounds(Rect(0, 0, 100, 50)).apply { + alpha = 102 + }, + ) + assertDrawsUnlike( + drawable, + insetShadow(null, shadowColor = TRANSLUCENT_RED).withBounds(Rect(0, 0, 100, 50)), + ) + } + + @Test + fun outsetShadowColorChangedInPlaceKeepsTheDrawableAlpha() { + val drawable = outsetShadow(null).withBounds(Rect(0, 0, 100, 50)).apply { alpha = 102 } + + drawable.setShadowValues(shadowColor = TRANSLUCENT_RED) + + assertDrawsLike( + drawable, + outsetShadow(null, shadowColor = TRANSLUCENT_RED).withBounds(Rect(0, 0, 100, 50)).apply { + alpha = 102 + }, + ) + assertDrawsUnlike( + drawable, + outsetShadow(null, shadowColor = TRANSLUCENT_RED).withBounds(Rect(0, 0, 100, 50)), + ) + } + + @Test + fun insetShadowValuesSetToWhatTheyAlreadyAreDoNotInvalidate() { + val callback = InvalidationCountingCallback() + val drawable = insetShadow(roundedBy(8f), blurRadius = 4f).apply { this.callback = callback } + + drawable.setShadowValues( + shadowColor = Color.BLACK, + offsetX = 2f, + offsetY = 3f, + blurRadius = 4f, + spread = 1f, + ) + assertThat(callback.invalidations).isZero() + + drawable.setShadowValues(spread = 2f) + assertThat(callback.invalidations).isEqualTo(1) + } + + @Test + fun insetShadowValuesChangedTogetherInvalidateOnce() { + val callback = InvalidationCountingCallback() + val drawable = insetShadow(roundedBy(8f), blurRadius = 4f).apply { this.callback = callback } + + drawable.setShadowValues( + shadowColor = Color.RED, + offsetX = -4f, + offsetY = 5f, + blurRadius = 6f, + spread = 2f, + ) + + assertThat(callback.invalidations).isEqualTo(1) + } + + @Test + fun outsetShadowValuesSetToWhatTheyAlreadyAreDoNotInvalidate() { + val callback = InvalidationCountingCallback() + val drawable = outsetShadow(roundedBy(8f), blurRadius = 4f).apply { this.callback = callback } + + drawable.setShadowValues( + shadowColor = Color.BLACK, + offsetX = 2f, + offsetY = 3f, + blurRadius = 4f, + spread = 1f, + ) + assertThat(callback.invalidations).isZero() + + drawable.setShadowValues(spread = 2f) + assertThat(callback.invalidations).isEqualTo(1) + } + + @Test + fun outsetShadowValuesChangedTogetherInvalidateOnce() { + val callback = InvalidationCountingCallback() + val drawable = outsetShadow(roundedBy(8f), blurRadius = 4f).apply { this.callback = callback } + + drawable.setShadowValues( + shadowColor = Color.RED, + offsetX = -4f, + offsetY = 5f, + blurRadius = 6f, + spread = 2f, + ) + + assertThat(callback.invalidations).isEqualTo(1) + } + private fun setScreenDensity(density: Float) { DisplayMetricsHolder.setScreenDisplayMetrics(DisplayMetrics().apply { this.density = density }) } @@ -394,14 +609,17 @@ class BoxShadowDrawableTest { borderRadius: BorderRadiusStyle?, borderInsets: BorderInsets? = null, blurRadius: Float = 0f, + shadowColor: Int = Color.BLACK, + offsetX: Float = 2f, + offsetY: Float = 3f, spread: Float = 1f, drawableContext: Context = context, ): InsetBoxShadowDrawable = InsetBoxShadowDrawable( context = drawableContext, - shadowColor = Color.BLACK, - offsetX = 2f, - offsetY = 3f, + shadowColor = shadowColor, + offsetX = offsetX, + offsetY = offsetY, blurRadius = blurRadius, spread = spread, borderInsets = borderInsets, @@ -410,15 +628,20 @@ class BoxShadowDrawableTest { private fun outsetShadow( borderRadius: BorderRadiusStyle?, + blurRadius: Float = 0f, + shadowColor: Int = Color.BLACK, + offsetX: Float = 2f, + offsetY: Float = 3f, + spread: Float = 1f, drawableContext: Context = context, ): OutsetBoxShadowDrawable = OutsetBoxShadowDrawable( context = drawableContext, - shadowColor = Color.BLACK, - offsetX = 2f, - offsetY = 3f, - blurRadius = 0f, - spread = 1f, + shadowColor = shadowColor, + offsetX = offsetX, + offsetY = offsetY, + blurRadius = blurRadius, + spread = spread, borderRadius = borderRadius, ) @@ -469,49 +692,44 @@ class BoxShadowDrawableTest { .isEqualTo(RecordingCanvas().also { expected.draw(it) }.calls) } - private class PreferenceCountingContext(base: Context) : ContextWrapper(base) { - var preferenceReads = 0 - private set + private fun assertKeepsItsMaskFilterAcrossDraws(drawable: Drawable) { + drawable.bounds = Rect(0, 0, 100, 50) - override fun getSharedPreferences(name: String?, mode: Int): SharedPreferences { - preferenceReads += 1 - return super.getSharedPreferences(name, mode) - } + val firstDraw = MaskFilterRecordingCanvas().also { drawable.draw(it) }.maskFilters + val secondDraw = MaskFilterRecordingCanvas().also { drawable.draw(it) }.maskFilters + + assertThat(firstDraw.single()).isNotNull() + assertThat(secondDraw.single()).isSameAs(firstDraw.single()) } - /** 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() - val drawnPaths = mutableListOf() + private fun assertRebuildsItsMaskFilterAfterADensityChange(drawable: Drawable) { + drawable.bounds = Rect(0, 0, 100, 50) + val beforeTheChange = MaskFilterRecordingCanvas().also { drawable.draw(it) }.maskFilters - override fun clipPath(path: Path): Boolean { - record("clipPath ${describe(path)}", path) - return true - } + setScreenDensity(3f) - override fun clipOutPath(path: Path): Boolean { - record("clipOutPath ${describe(path)}", path) - return true - } + val afterTheChange = MaskFilterRecordingCanvas().also { drawable.draw(it) }.maskFilters + assertThat(afterTheChange.single()).isNotNull().isNotSameAs(beforeTheChange.single()) + } - override fun clipRect(rect: RectF): Boolean { - record("clipRect $rect", rect) - return true - } + /** Records the mask filter of each paint a drawable draws with. */ + private class MaskFilterRecordingCanvas : Canvas() { + val maskFilters = mutableListOf() - override fun clipOutRect(rect: Rect): Boolean { - record("clipOutRect $rect", rect) - return true - } + override fun clipPath(path: Path): Boolean = true + + override fun clipOutPath(path: Path): Boolean = true + + override fun clipRect(rect: RectF): Boolean = true + + override fun clipOutRect(rect: Rect): Boolean = true override fun drawPath(path: Path, paint: Paint) { - drawnPaths.add(path) - record("drawPath ${path.fillType} ${describe(path)}", path) + maskFilters.add(paint.maskFilter) } override fun drawRect(rect: RectF, paint: Paint) { - record("drawRect $rect", rect) + maskFilters.add(paint.maskFilter) } override fun drawDoubleRoundRect( @@ -521,20 +739,62 @@ class BoxShadowDrawableTest { innerRadii: FloatArray, paint: Paint, ) { - record( - "drawDoubleRoundRect $outer ${outerRadii.toList()} $inner ${innerRadii.toList()}", - outer, - outerRadii, - inner, - innerRadii, - ) + maskFilters.add(paint.maskFilter) } + } - private fun record(call: String, vararg drawnGeometry: Any) { - calls.add(call) - geometry.addAll(drawnGeometry) + 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) } + } - private fun describe(path: Path): String = path.approximate(0.5f).toList().toString() + private fun assertKeepsItsGeometryThrough( + build: (BorderRadiusStyle) -> T, + change: (T) -> Unit, + ) { + val borderRadius = spy(roundedBy(8f)) + val drawable = build(borderRadius).withBounds(Rect(0, 0, 100, 50)) + drawable.draw(RecordingCanvas()) + + change(drawable) + drawable.draw(RecordingCanvas()) + + verify(borderRadius, times(1)).resolve(any(), any(), any(), any()) } + + private fun assertDrawsUnlike(drawable: Drawable, other: Drawable) { + assertThat(RecordingCanvas().also { drawable.draw(it) }.calls) + .isNotEqualTo(RecordingCanvas().also { other.draw(it) }.calls) + } + + /** One shadow value set on a drawable that already drew, and a drawable built with it. */ + private class InPlaceChange( + val value: String, + val change: (T) -> Unit, + val expected: () -> T, + ) + + private fun assertFollowsEachChangeAlone( + build: () -> T, + vararg changes: InPlaceChange, + ) { + for (inPlaceChange in changes) { + val drawable = build().withBounds(Rect(0, 0, 100, 50)) + drawable.draw(RecordingCanvas()) + + inPlaceChange.change(drawable) + + val expected = inPlaceChange.expected().withBounds(Rect(0, 0, 100, 50)) + assertThat(RecordingCanvas().also { drawable.draw(it) }.calls) + .describedAs(inPlaceChange.value) + .isEqualTo(RecordingCanvas().also { expected.draw(it) }.calls) + } + } + + /** Counts the invalidations a drawable asks its callback for. */ } diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/drawable/InvalidationCountingCallback.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/drawable/InvalidationCountingCallback.kt new file mode 100644 index 000000000000..915217b9273c --- /dev/null +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/drawable/InvalidationCountingCallback.kt @@ -0,0 +1,23 @@ +/* + * 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.graphics.drawable.Drawable + +internal class InvalidationCountingCallback : Drawable.Callback { + var invalidations = 0 + private set + + override fun invalidateDrawable(who: Drawable) { + invalidations++ + } + + override fun scheduleDrawable(who: Drawable, what: Runnable, `when`: Long) = Unit + + override fun unscheduleDrawable(who: Drawable, what: Runnable) = Unit +} diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/drawable/RecordingCanvas.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/drawable/RecordingCanvas.kt new file mode 100644 index 000000000000..110da4cb85d9 --- /dev/null +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/drawable/RecordingCanvas.kt @@ -0,0 +1,93 @@ +/* + * 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.graphics.Canvas +import android.graphics.Paint +import android.graphics.Path +import android.graphics.Rect +import android.graphics.RectF + +/** + * Records the clip, translate and draw calls it overrides as text, with each draw's paint, and + * every geometry object those calls hand the canvas. + */ +internal class RecordingCanvas : Canvas() { + val calls = mutableListOf() + val geometry = mutableListOf() + val drawnPaths = 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 translate(dx: Float, dy: Float) { + record("translate $dx $dy") + } + + override fun drawPath(path: Path, paint: Paint) { + drawnPaths.add(path) + record("drawPath ${path.fillType} ${describe(path)} ${describe(paint)}", path) + } + + override fun drawRect(rect: RectF, paint: Paint) { + record("drawRect $rect ${describe(paint)}", rect) + } + + override fun drawRect(left: Float, top: Float, right: Float, bottom: Float, paint: Paint) { + record("drawRect $left $top $right $bottom ${describe(paint)}") + } + + override fun drawRoundRect(rect: RectF, rx: Float, ry: Float, paint: Paint) { + record("drawRoundRect $rect $rx $ry ${describe(paint)}", rect) + } + + override fun drawDoubleRoundRect( + outer: RectF, + outerRadii: FloatArray, + inner: RectF, + innerRadii: FloatArray, + paint: Paint, + ) { + record( + "drawDoubleRoundRect $outer ${outerRadii.toList()} $inner ${innerRadii.toList()} " + + describe(paint), + 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() + + private fun describe(paint: Paint): String = + "color=${Integer.toHexString(paint.color)} alpha=${paint.alpha} style=${paint.style} " + + "width=${paint.strokeWidth} blurred=${paint.maskFilter != null}" +}