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 c59484be0f7f..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,17 +8,20 @@ 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 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 @@ -29,43 +32,114 @@ 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, + 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: Boolean + get() = shadowPaint.maskFilter != null + + 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 + 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 + } + + 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() @@ -82,69 +156,125 @@ internal class InsetBoxShadowDrawable( } override fun draw(canvas: Canvas) { + updateMaskFilter() + 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. + // 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) + } + } + + 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() + val swapsLeftAndRight = + layoutDirection == LayoutDirection.RTL && + (borderRadius != null || borderInsets != null) && + I18nUtil.instance.doLeftAndRightSwapInRTL(context) + if (!needUpdatePath && density == pathDensity && swapsLeftAndRight == pathSwapsLeftAndRight) { + return + } + needUpdatePath = false + pathDensity = density + pathSwapsLeftAndRight = swapsLeftAndRight + 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() || 2 * spreadExtent > paddingBoxRect.height()) { + 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..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,17 +8,19 @@ 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 android.util.LayoutDirection import androidx.annotation.RequiresApi -import com.facebook.react.uimanager.FilterHelper +import com.facebook.react.modules.i18nmanager.I18nUtil +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 @@ -28,40 +30,100 @@ 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, + 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 + private var pathSwapsLeftAndRight = false + 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 + } + + 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() @@ -78,6 +140,45 @@ internal class OutsetBoxShadowDrawable( } override fun draw(canvas: Canvas) { + updateMaskFilter() + 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 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() + val swapsLeftAndRight = + layoutDirection == LayoutDirection.RTL && + borderRadius != null && + 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() val computedBorderRadii = @@ -94,74 +195,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..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 new file mode 100644 index 000000000000..2dbaccb21e9c --- /dev/null +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/drawable/BoxShadowDrawableTest.kt @@ -0,0 +1,800 @@ +/* + * 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.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 +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.modules.i18nmanager.I18nUtil +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() + setScreenDensity(2f) + } + + @After + fun tearDown() { + DisplayMetricsHolder.setScreenDisplayMetrics(null) + I18nUtil.instance.swapLeftAndRightInRTL(context, true) + } + + @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 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)) + 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.setShadowValues(shadowColor = Color.RED) + it.alpha = 128 + it.colorFilter = PorterDuffColorFilter(Color.BLUE, PorterDuff.Mode.SRC_IN) + } + } + + @Test + fun blurredInsetShadowKeepsItsGeometryWhenItsColorAlphaOrColorFilterChanges() { + assertKeepsItsGeometryThrough({ insetShadow(it, blurRadius = 4f) }) { + it.setShadowValues(shadowColor = Color.RED) + it.alpha = 128 + it.colorFilter = PorterDuffColorFilter(Color.BLUE, PorterDuff.Mode.SRC_IN) + } + } + + @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) + } + } + + @Test + fun outsetShadowKeepsItsGeometryWhenItsBlurChanges() { + assertKeepsItsGeometryThrough({ outsetShadow(it, blurRadius = 4f) }) { + it.setShadowValues(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 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()) + } + + @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() + } + + @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 }) + } + + private fun insetShadow( + 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 = 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, + drawableContext: Context = context, + ): OutsetBoxShadowDrawable = + OutsetBoxShadowDrawable( + context = drawableContext, + 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 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) { + 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 assertKeepsItsMaskFilterAcrossDraws(drawable: Drawable) { + drawable.bounds = Rect(0, 0, 100, 50) + + 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()) + } + + private fun assertRebuildsItsMaskFilterAfterADensityChange(drawable: Drawable) { + drawable.bounds = Rect(0, 0, 100, 50) + val beforeTheChange = MaskFilterRecordingCanvas().also { drawable.draw(it) }.maskFilters + + setScreenDensity(3f) + + val afterTheChange = MaskFilterRecordingCanvas().also { drawable.draw(it) }.maskFilters + assertThat(afterTheChange.single()).isNotNull().isNotSameAs(beforeTheChange.single()) + } + + /** Records the mask filter of each paint a drawable draws with. */ + private class MaskFilterRecordingCanvas : Canvas() { + val maskFilters = mutableListOf() + + 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) { + maskFilters.add(paint.maskFilter) + } + + override fun drawRect(rect: RectF, paint: Paint) { + maskFilters.add(paint.maskFilter) + } + + override fun drawDoubleRoundRect( + outer: RectF, + outerRadii: FloatArray, + inner: RectF, + innerRadii: FloatArray, + paint: Paint, + ) { + maskFilters.add(paint.maskFilter) + } + } + + 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 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}" +}