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 c7d94a770c39..27870d780a96 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,75 @@ 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) { + val shadow = compositeBackgroundDrawable.innerShadows[innerIndex++] + shadow.shadowColor = color + shadow.offsetX = offsetX + shadow.offsetY = offsetY + shadow.blurRadius = blurRadius + shadow.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) { + val shadow = compositeBackgroundDrawable.outerShadows[outerIndex++] + shadow.shadowColor = color + shadow.offsetX = offsetX + shadow.offsetY = offsetY + shadow.blurRadius = blurRadius + shadow.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 335ea16bdd63..87ca41f5e1cc 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 c59484be0f7f..a285aab088ce 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,13 +8,13 @@ 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 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 @@ -29,40 +29,123 @@ 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, - /* - * 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, + shadowColor: Int, + offsetX: Float, + offsetY: Float, + blurRadius: Float, + 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().apply { maskFilter = createBoxShadowMaskFilter(blurRadius) } + + private var drawableAlpha = 255 + + private val isBlurred: Boolean + get() = shadowPaint.maskFilter != null + + var shadowColor: Int = shadowColor + set(value) { + if (value != field) { + field = value + applyShadowColor() + invalidateSelf() + } + } + + var offsetX: Float = offsetX + set(value) { + if (value != field) { + field = value + invalidateGeometry() + } + } + + var offsetY: Float = offsetY + set(value) { + if (value != field) { + field = value + invalidateGeometry() + } + } + + var blurRadius: Float = blurRadius + set(value) { + if (value != field) { + field = value + shadowPaint.maskFilter = createBoxShadowMaskFilter(value) + invalidateGeometry() + } + } + + var spread: Float = spread + set(value) { + if (value != field) { + field = value + invalidateGeometry() } + } + + 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() + private val blurredShadowPath = Path() + + /* + * 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 + } + + init { + applyShadowColor() + } + + 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() + drawableAlpha = alpha + applyShadowColor() + invalidateSelf() + } + + private fun applyShadowColor() { + shadowPaint.color = shadowColor + shadowPaint.alpha = + (((drawableAlpha / 255f) * (Color.alpha(shadowColor) / 255f)) * 255f).roundToInt() + } + + private fun invalidateGeometry() { + needUpdatePath = true invalidateSelf() } @@ -82,69 +165,105 @@ internal class InsetBoxShadowDrawable( } override fun draw(canvas: Canvas) { + updatePath() + + canvas.save().let { saveCount -> + if (hasPaddingBoxRadii) { + canvas.clipPath(paddingBoxPath) + } else { + canvas.clipRect(paddingBoxRect) + } + + // 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. + if (isBlurred) { + canvas.drawPath(blurredShadowPath, shadowPaint) + } else { + canvas.drawDoubleRoundRect( + outerRect, + ZERO_RADII, + innerRect, + if (hasPaddingBoxRadii) innerRadii else 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) }, - ) + outerRect.set(paddingBoxRect) + outerRect.inset(-blurExtent, -blurExtent) + outerRect.union(innerRect) - 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) + 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) + 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, + ) + } } } 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..b3a472165bb1 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,17 +8,16 @@ 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 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 -import com.facebook.react.uimanager.FilterHelper import com.facebook.react.uimanager.PixelUtil.dpToPx import com.facebook.react.uimanager.PixelUtil.pxToDp import com.facebook.react.uimanager.style.BorderRadiusStyle @@ -28,37 +27,109 @@ 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, - /* - * We assume borderRadius to be shared across multiple drawables - * therefore we should manually invalidate this drawable when changing it - */ - var borderRadius: BorderRadiusStyle? = null, + shadowColor: Int, + offsetX: Float, + offsetY: Float, + blurRadius: Float, + 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().apply { maskFilter = createBoxShadowMaskFilter(blurRadius) } + + private var drawableAlpha = 255 + + var shadowColor: Int = shadowColor + set(value) { + if (value != field) { + field = value + applyShadowColor() + invalidateSelf() + } + } + + var offsetX: Float = offsetX + set(value) { + if (value != field) { + field = value + invalidateGeometry() + } + } + + var offsetY: Float = offsetY + set(value) { + if (value != field) { + field = value + invalidateGeometry() + } + } + + var blurRadius: Float = blurRadius + set(value) { + if (value != field) { + field = value + shadowPaint.maskFilter = createBoxShadowMaskFilter(value) + invalidateSelf() } + } + + var spread: Float = spread + set(value) { + if (value != field) { + field = value + invalidateGeometry() + } + } + + 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 + } + + init { + applyShadowColor() + } + + 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() + drawableAlpha = alpha + applyShadowColor() + invalidateSelf() + } + + private fun applyShadowColor() { + shadowPaint.color = shadowColor + shadowPaint.alpha = + (((drawableAlpha / 255f) * (Color.alpha(shadowColor) / 255f)) * 255f).roundToInt() + } + + private fun invalidateGeometry() { + needUpdatePath = true invalidateSelf() } @@ -78,6 +149,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 +185,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/BackgroundStyleApplicatorBoxShadowTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/BackgroundStyleApplicatorBoxShadowTest.kt new file mode 100644 index 000000000000..643c2f2dc4a4 --- /dev/null +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/BackgroundStyleApplicatorBoxShadowTest.kt @@ -0,0 +1,231 @@ +/* + * 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.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 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 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 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 new file mode 100644 index 000000000000..53fb4f3b9ea6 --- /dev/null +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/drawable/BoxShadowDrawableTest.kt @@ -0,0 +1,485 @@ +/* + * 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.Color +import android.graphics.Path +import android.graphics.PorterDuff +import android.graphics.PorterDuffColorFilter +import android.graphics.Rect +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 + +private val TRANSLUCENT_RED = Color.argb(128, 255, 0, 0) + +/** 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 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))) + } + + @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 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 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 insetShadowKeepsItsGeometryWhenItsColorAlphaOrColorFilterChanges() { + assertKeepsItsGeometryThrough({ insetShadow(it) }) { + it.shadowColor = Color.RED + it.alpha = 128 + it.colorFilter = PorterDuffColorFilter(Color.BLUE, PorterDuff.Mode.SRC_IN) + } + } + + @Test + fun blurredInsetShadowKeepsItsGeometryWhenItsColorAlphaOrColorFilterChanges() { + assertKeepsItsGeometryThrough({ insetShadow(it, blurRadius = 4f) }) { + it.shadowColor = Color.RED + it.alpha = 128 + it.colorFilter = PorterDuffColorFilter(Color.BLUE, PorterDuff.Mode.SRC_IN) + } + } + + @Test + fun outsetShadowKeepsItsGeometryWhenItsColorAlphaOrColorFilterChanges() { + assertKeepsItsGeometryThrough({ outsetShadow(it, blurRadius = 4f) }) { + it.shadowColor = Color.RED + it.alpha = 128 + it.colorFilter = PorterDuffColorFilter(Color.BLUE, PorterDuff.Mode.SRC_IN) + } + } + + @Test + fun outsetShadowKeepsItsGeometryWhenItsBlurChanges() { + assertKeepsItsGeometryThrough({ outsetShadow(it, blurRadius = 4f) }) { it.blurRadius = 6f } + } + + @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) + } + + @Test + fun insetShadowFollowsEachShadowValueChangedAloneInPlace() { + assertFollowsEachChangeAlone( + { insetShadow(roundedBy(8f)) }, + InPlaceChange("shadowColor", { it.shadowColor = Color.RED }) { + insetShadow(roundedBy(8f), shadowColor = Color.RED) + }, + InPlaceChange("offsetX", { it.offsetX = -4f }) { + insetShadow(roundedBy(8f), offsetX = -4f) + }, + InPlaceChange("offsetY", { it.offsetY = 5f }) { insetShadow(roundedBy(8f), offsetY = 5f) }, + InPlaceChange("blurRadius", { it.blurRadius = 4f }) { + insetShadow(roundedBy(8f), blurRadius = 4f) + }, + InPlaceChange("spread", { it.spread = 3f }) { insetShadow(roundedBy(8f), spread = 3f) }, + ) + } + + @Test + fun outsetShadowFollowsEachShadowValueChangedAloneInPlace() { + assertFollowsEachChangeAlone( + { outsetShadow(roundedBy(8f)) }, + InPlaceChange("shadowColor", { it.shadowColor = Color.RED }) { + outsetShadow(roundedBy(8f), shadowColor = Color.RED) + }, + InPlaceChange("offsetX", { it.offsetX = -4f }) { + outsetShadow(roundedBy(8f), offsetX = -4f) + }, + InPlaceChange("offsetY", { it.offsetY = 5f }) { outsetShadow(roundedBy(8f), offsetY = 5f) }, + InPlaceChange("blurRadius", { it.blurRadius = 4f }) { + outsetShadow(roundedBy(8f), blurRadius = 4f) + }, + InPlaceChange("spread", { it.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.blurRadius = 0f + + assertDrawsLike(drawable, insetShadow(roundedBy(8f)).withBounds(Rect(0, 0, 100, 50))) + } + + @Test + fun insetShadowColorChangedInPlaceKeepsTheDrawableAlpha() { + val drawable = insetShadow(null).withBounds(Rect(0, 0, 100, 50)).apply { alpha = 102 } + + drawable.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.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 = CountingCallback() + val drawable = insetShadow(roundedBy(8f), blurRadius = 4f).apply { this.callback = callback } + + drawable.shadowColor = Color.BLACK + drawable.offsetX = 2f + drawable.offsetY = 3f + drawable.blurRadius = 4f + drawable.spread = 1f + assertThat(callback.invalidations).isZero() + + drawable.spread = 2f + assertThat(callback.invalidations).isEqualTo(1) + } + + @Test + fun outsetShadowValuesSetToWhatTheyAlreadyAreDoNotInvalidate() { + val callback = CountingCallback() + val drawable = outsetShadow(roundedBy(8f), blurRadius = 4f).apply { this.callback = callback } + + drawable.shadowColor = Color.BLACK + drawable.offsetX = 2f + drawable.offsetY = 3f + drawable.blurRadius = 4f + drawable.spread = 1f + assertThat(callback.invalidations).isZero() + + drawable.spread = 2f + assertThat(callback.invalidations).isEqualTo(1) + } + + private fun insetShadow( + borderRadius: BorderRadiusStyle?, + borderInsets: BorderInsets? = null, + blurRadius: Float = 0f, + shadowColor: Int = Color.BLACK, + offsetX: Float = 2f, + offsetY: Float = 3f, + spread: Float = 1f, + ): InsetBoxShadowDrawable = + InsetBoxShadowDrawable( + context = context, + shadowColor = shadowColor, + offsetX = offsetX, + offsetY = offsetY, + blurRadius = blurRadius, + spread = spread, + borderInsets = borderInsets, + borderRadius = borderRadius, + ) + + private fun outsetShadow( + borderRadius: BorderRadiusStyle?, + blurRadius: Float = 0f, + shadowColor: Int = Color.BLACK, + offsetX: Float = 2f, + offsetY: Float = 3f, + spread: Float = 1f, + ): OutsetBoxShadowDrawable = + OutsetBoxShadowDrawable( + context = context, + shadowColor = shadowColor, + offsetX = offsetX, + offsetY = offsetY, + blurRadius = blurRadius, + spread = spread, + 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 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) + } + + 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. */ + private class CountingCallback : Drawable.Callback { + var invalidations = 0 + + 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..4a469856242c --- /dev/null +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/drawable/RecordingCanvas.kt @@ -0,0 +1,81 @@ +/* + * 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 each call a drawable makes as text, with the paint it draws with, and every geometry + * object it hands 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 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 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} " + + "blurred=${paint.maskFilter != null}" +}