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 86b2739a55c..3d9cfc3d13b 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 @@ -91,16 +91,19 @@ public object BackgroundStyleApplicator { @JvmStatic @RequiresApi(31) public fun setBoxShadow(view: View, shadows: List): Unit { - val shadowDrawables = - shadows.map { boxShadow -> - val offsetX = boxShadow.offsetX - val offsetY = boxShadow.offsetY - val color = boxShadow.color ?: Color.BLACK - val blurRadius = boxShadow.blurRadius ?: 0f - val spreadDistance = boxShadow.spreadDistance ?: 0f - val inset = boxShadow.inset ?: false + val outerShadows = mutableListOf() + val innerShadows = mutableListOf() - if (inset) { + for (boxShadow in shadows) { + val offsetX = boxShadow.offsetX + val offsetY = boxShadow.offsetY + val color = boxShadow.color ?: Color.BLACK + val blurRadius = boxShadow.blurRadius ?: 0f + val spreadDistance = boxShadow.spreadDistance ?: 0f + val inset = boxShadow.inset ?: false + + if (inset) { + innerShadows.add( InsetBoxShadowDrawable( context = view.context, background = ensureCSSBackground(view), @@ -108,8 +111,9 @@ public object BackgroundStyleApplicator { offsetX = offsetX, offsetY = offsetY, blurRadius = blurRadius, - spread = spreadDistance) - } else { + spread = spreadDistance)) + } else { + outerShadows.add( OutsetBoxShadowDrawable( context = view.context, background = ensureCSSBackground(view), @@ -117,11 +121,13 @@ public object BackgroundStyleApplicator { offsetX = offsetX, offsetY = offsetY, blurRadius = blurRadius, - spread = spreadDistance) - } - } + spread = spreadDistance)) + } + } - view.background = ensureCompositeBackgroundDrawable(view).withNewShadows(shadowDrawables) + view.background = + ensureCompositeBackgroundDrawable(view) + .withNewShadows(outerShadows = outerShadows, innerShadows = innerShadows) } @JvmStatic @@ -165,7 +171,7 @@ public object BackgroundStyleApplicator { return view.background as CompositeBackgroundDrawable } - val compositeDrawable = CompositeBackgroundDrawable(view.background, null, emptyList(), null) + val compositeDrawable = CompositeBackgroundDrawable(originalBackground = view.background) view.background = compositeDrawable return compositeDrawable } diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/drawable/BoxShadowBorderRadius.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/drawable/BoxShadowBorderRadius.kt index 0fc15258b25..98365f7a86f 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/drawable/BoxShadowBorderRadius.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/drawable/BoxShadowBorderRadius.kt @@ -7,49 +7,23 @@ package com.facebook.react.uimanager.drawable -import com.facebook.react.uimanager.LengthPercentage -import com.facebook.react.uimanager.LengthPercentageType -import com.facebook.react.uimanager.style.BorderRadiusProp -import com.facebook.react.uimanager.style.BorderRadiusStyle +import kotlin.math.abs +import kotlin.math.pow -internal fun getShadowBorderRadii( +/** + * Manipulates a corner radius of the shadow-shape according to the radius of the original + * background, and the applied spread (negative, if inset). See algorithm at + * https://drafts.csswg.org/css-backgrounds/#shadow-shape + */ +internal fun adjustRadiusForSpread( + radius: Float, spread: Float, - backgroundBorderRadii: BorderRadiusStyle, - width: Float, - height: Float, -): BorderRadiusStyle { - val adjustedBorderRadii = BorderRadiusStyle() - val borderRadiusProps = BorderRadiusProp.values() - - borderRadiusProps.forEach { borderRadiusProp -> - val borderRadius = backgroundBorderRadii.get(borderRadiusProp) - adjustedBorderRadii.set( - borderRadiusProp, - if (borderRadius == null) null - else adjustedBorderRadius(spread, borderRadius, width, height)) - } - - return adjustedBorderRadii -} - -// See https://drafts.csswg.org/css-backgrounds/#shadow-shape -private fun adjustedBorderRadius( - spread: Float, - backgroundBorderRadius: LengthPercentage?, - width: Float, - height: Float, -): LengthPercentage? { - if (backgroundBorderRadius == null) { - return null - } - var adjustment = spread - val backgroundBorderRadiusValue = backgroundBorderRadius.resolve(width, height) - - if (backgroundBorderRadiusValue < Math.abs(spread)) { - val r = backgroundBorderRadiusValue / Math.abs(spread) - val p = Math.pow(r - 1.0, 3.0) - adjustment *= 1.0f + p.toFloat() - } - - return LengthPercentage(backgroundBorderRadiusValue + adjustment, LengthPercentageType.POINT) +): Float { + val spreadMultiplier = + if (radius < abs(spread)) { + 1 + (radius / abs(spread) - 1).pow(3) + } else { + 1f + } + return (radius + abs(spread) * spreadMultiplier).coerceAtLeast(0f) } 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 4f7a088d202..785c14fe935 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 @@ -23,6 +23,9 @@ internal class CompositeBackgroundDrawable( */ public val originalBackground: Drawable? = null, + /** Non-inset box shadows */ + public val outerShadows: List = emptyList(), + /** * CSS background layer and border rendering * @@ -31,8 +34,8 @@ internal class CompositeBackgroundDrawable( */ public val cssBackground: CSSBackgroundDrawable? = null, - /** Inner and outer box shadows */ - public val shadows: List = emptyList(), + /** Inset box-shadows */ + public val innerShadows: List = emptyList(), /** Native riplple effect (e.g. used by TouchableNativeFeedback) */ public val nativeRipple: Drawable? = null @@ -40,11 +43,12 @@ internal class CompositeBackgroundDrawable( LayerDrawable( listOfNotNull( originalBackground, - cssBackground, // z-ordering of user-provided shadow-list is opposite direction of LayerDrawable // z-ordering // https://drafts.csswg.org/css-backgrounds/#shadow-layers - *shadows.asReversed().toTypedArray(), + *outerShadows.asReversed().toTypedArray(), + cssBackground, + *innerShadows.asReversed().toTypedArray(), nativeRipple) .toTypedArray()) { @@ -58,14 +62,20 @@ internal class CompositeBackgroundDrawable( public fun withNewCssBackground( cssBackground: CSSBackgroundDrawable? ): CompositeBackgroundDrawable { - return CompositeBackgroundDrawable(originalBackground, cssBackground, shadows, nativeRipple) + return CompositeBackgroundDrawable( + originalBackground, outerShadows, cssBackground, innerShadows, nativeRipple) } - public fun withNewShadows(newShadows: List): CompositeBackgroundDrawable { - return CompositeBackgroundDrawable(originalBackground, cssBackground, newShadows, nativeRipple) + public fun withNewShadows( + outerShadows: List, + innerShadows: List + ): CompositeBackgroundDrawable { + return CompositeBackgroundDrawable( + originalBackground, outerShadows, cssBackground, innerShadows, nativeRipple) } public fun withNewNativeRipple(newRipple: Drawable?): CompositeBackgroundDrawable { - return CompositeBackgroundDrawable(originalBackground, cssBackground, shadows, newRipple) + return CompositeBackgroundDrawable( + originalBackground, outerShadows, cssBackground, innerShadows, newRipple) } } 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 7143a0dccb3..2e013050995 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 @@ -21,7 +21,6 @@ import com.facebook.react.uimanager.FilterHelper import com.facebook.react.uimanager.LengthPercentage import com.facebook.react.uimanager.LengthPercentageType import com.facebook.react.uimanager.PixelUtil -import com.facebook.react.uimanager.style.BorderRadiusProp import com.facebook.react.uimanager.style.BorderRadiusStyle import kotlin.math.roundToInt @@ -82,8 +81,7 @@ internal class InsetBoxShadowDrawable( PixelUtil.toPixelFromDIP(offsetX).roundToInt() + padding / 2, PixelUtil.toPixelFromDIP(offsetY).roundToInt() + padding / 2, ) - val clearRegionBorderRadii = - getClearRegionBorderRadii(spreadExtent, background, clearRegionBounds) + val clearRegionBorderRadii = getClearRegionBorderRadii(spreadExtent, background) if (shadowPaint.colorFilter != colorFilter || clearRegionDrawable.layoutDirection != layoutDirection || @@ -134,7 +132,6 @@ internal class InsetBoxShadowDrawable( private fun getClearRegionBorderRadii( spread: Int, background: CSSBackgroundDrawable, - clearRegionBounds: Rect, ): BorderRadiusStyle { val computedBorderRadii = background.computedBorderRadius val borderWidth = background.getDirectionAwareBorderInsets() @@ -150,29 +147,24 @@ internal class InsetBoxShadowDrawable( background.getInnerBorderRadius(bottomRightRadius, borderWidth.right) val innerBottomLeftRadius = background.getInnerBorderRadius(bottomLeftRadius, borderWidth.left) - val innerBorderRadii = BorderRadiusStyle() - innerBorderRadii.set( - BorderRadiusProp.BORDER_TOP_LEFT_RADIUS, - LengthPercentage(innerTopLeftRadius, LengthPercentageType.POINT), - ) - innerBorderRadii.set( - BorderRadiusProp.BORDER_TOP_RIGHT_RADIUS, - LengthPercentage(innerTopRightRadius, LengthPercentageType.POINT), - ) - innerBorderRadii.set( - BorderRadiusProp.BORDER_BOTTOM_RIGHT_RADIUS, - LengthPercentage(innerBottomRightRadius, LengthPercentageType.POINT), - ) - innerBorderRadii.set( - BorderRadiusProp.BORDER_BOTTOM_LEFT_RADIUS, - LengthPercentage(innerBottomLeftRadius, LengthPercentageType.POINT), - ) - - return getShadowBorderRadii( - -spread.toFloat(), - innerBorderRadii, - clearRegionBounds.width().toFloat(), - clearRegionBounds.height().toFloat(), + val spreadWithDirection = -spread.toFloat() + return BorderRadiusStyle( + topLeft = + LengthPercentage( + adjustRadiusForSpread(innerTopLeftRadius, spreadWithDirection), + LengthPercentageType.POINT), + topRight = + LengthPercentage( + adjustRadiusForSpread(innerTopRightRadius, spreadWithDirection), + LengthPercentageType.POINT), + bottomLeft = + LengthPercentage( + adjustRadiusForSpread(innerBottomLeftRadius, spreadWithDirection), + LengthPercentageType.POINT), + bottomRight = + LengthPercentage( + adjustRadiusForSpread(innerBottomRightRadius, spreadWithDirection), + LengthPercentageType.POINT), ) } } 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 1c9919ead58..81414c82b6b 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 @@ -10,14 +10,20 @@ package com.facebook.react.uimanager.drawable import android.content.Context import android.graphics.Canvas import android.graphics.ColorFilter +import android.graphics.Path import android.graphics.Rect +import android.graphics.RectF import android.graphics.RenderNode import android.graphics.drawable.Drawable import androidx.annotation.RequiresApi import com.facebook.common.logging.FLog import com.facebook.react.common.annotations.UnstableReactNativeAPI import com.facebook.react.uimanager.FilterHelper +import com.facebook.react.uimanager.LengthPercentage +import com.facebook.react.uimanager.LengthPercentageType import com.facebook.react.uimanager.PixelUtil +import com.facebook.react.uimanager.style.BorderRadiusStyle +import com.facebook.react.uimanager.style.ComputedBorderRadius import kotlin.math.roundToInt private const val TAG = "OutsetBoxShadowDrawable" @@ -31,7 +37,7 @@ private const val BLUR_RADIUS_SIGMA_SCALE = 0.5f @RequiresApi(31) @OptIn(UnstableReactNativeAPI::class) internal class OutsetBoxShadowDrawable( - context: Context, + private val context: Context, private val background: CSSBackgroundDrawable, shadowColor: Int, private val offsetX: Float, @@ -47,6 +53,8 @@ internal class OutsetBoxShadowDrawable( setRenderEffect(FilterHelper.createBlurEffect(blurRadius * BLUR_RADIUS_SIGMA_SCALE)) } + private val shadowClipOutPath: Path = Path() + override fun setAlpha(alpha: Int) { renderNode.alpha = alpha / 255f } @@ -64,22 +72,58 @@ internal class OutsetBoxShadowDrawable( val spreadExtent = PixelUtil.toPixelFromDIP(spread).roundToInt().coerceAtLeast(0) val shadowShapeFrame = Rect(bounds).apply { inset(-spreadExtent, -spreadExtent) } val shadowShapeBounds = Rect(0, 0, shadowShapeFrame.width(), shadowShapeFrame.height()) - val borderRadii = - getShadowBorderRadii( - spreadExtent.toFloat(), - background.borderRadius, - bounds.width().toFloat(), - bounds.height().toFloat()) + + val resolutionWidth = bounds.width().toFloat() + val resolutionHeight = bounds.height().toFloat() + val computedBorderRadii = + background.borderRadius.resolve(layoutDirection, context, resolutionWidth, resolutionHeight) + val shadowBorderRadii = + ComputedBorderRadius( + topLeft = adjustRadiusForSpread(computedBorderRadii.topLeft, spreadExtent.toFloat()), + topRight = adjustRadiusForSpread(computedBorderRadii.topRight, spreadExtent.toFloat()), + bottomRight = + adjustRadiusForSpread(computedBorderRadii.bottomRight, spreadExtent.toFloat()), + bottomLeft = + adjustRadiusForSpread(computedBorderRadii.bottomLeft, spreadExtent.toFloat()), + ) if (shadowShapeDrawable.bounds != shadowShapeBounds || shadowShapeDrawable.layoutDirection != layoutDirection || - shadowShapeDrawable.borderRadius != borderRadii || + shadowShapeDrawable.computedBorderRadius != shadowBorderRadii || shadowShapeDrawable.colorFilter != colorFilter) { shadowShapeDrawable.bounds = shadowShapeBounds shadowShapeDrawable.layoutDirection = layoutDirection - shadowShapeDrawable.borderRadius = borderRadii + shadowShapeDrawable.borderRadius = + BorderRadiusStyle( + topLeft = LengthPercentage(shadowBorderRadii.topLeft, LengthPercentageType.POINT), + topRight = LengthPercentage(shadowBorderRadii.topRight, LengthPercentageType.POINT), + bottomLeft = + LengthPercentage(shadowBorderRadii.bottomLeft, LengthPercentageType.POINT), + bottomRight = + LengthPercentage(shadowBorderRadii.bottomRight, LengthPercentageType.POINT)) shadowShapeDrawable.colorFilter = colorFilter + // We remove the portion of the shadow which overlaps the background border box, to avoid + // showing the shadow shape e.g. behind a transparent background. There may be a subpixel gap + // between the border box path, and the edge of border rendering, so we slightly inflate the + // shadow to cover it, placing it below the borders. + shadowClipOutPath.rewind() + if (background.hasRoundedBorders()) { + val subpixelInsetBounds = RectF(bounds).apply { inset(0.4f, 0.4f) } + shadowClipOutPath.addRoundRect( + subpixelInsetBounds, + floatArrayOf( + computedBorderRadii.topLeft, + computedBorderRadii.topLeft, + computedBorderRadii.topRight, + computedBorderRadii.topRight, + computedBorderRadii.bottomRight, + computedBorderRadii.bottomRight, + computedBorderRadii.bottomLeft, + computedBorderRadii.bottomLeft), + Path.Direction.CW) + } + with(renderNode) { setPosition( Rect(shadowShapeFrame).apply { @@ -98,11 +142,10 @@ internal class OutsetBoxShadowDrawable( with(canvas) { save() - val borderBoxPath = background.getBorderBoxPath() - if (borderBoxPath != null) { - clipOutPath(borderBoxPath) + if (background.hasRoundedBorders()) { + clipOutPath(shadowClipOutPath) } else { - clipOutRect(background.getBorderBoxRect()) + clipOutRect(bounds) } drawRenderNode(renderNode)