From be3936762f252bbac7fdd7b045fad989051de148 Mon Sep 17 00:00:00 2001 From: Nick Gerleman Date: Tue, 30 Jul 2024 15:46:28 -0700 Subject: [PATCH] Workaround gap between border and outer box shadow (#45797) Summary: Pull Request resolved: https://github.com/facebook/react-native/pull/45797 Android borders are drawn using a path generated by `addRoundRect()` inset by half the border width, using the full border width as stoke width. The edges of the ellipsis drawn for rounded borders do not line up with the math used to trace the bounding border-box path. In a relatively similar hack to elsewhere in border drawing code for gap between content and the border, we inset the clipOut path, as if its bounding rectangle were about half a subpixel smaller, to mininally overlap the border on these edges. We then place the outer box shadows under the border in z-ordering, so that the minimal extra insetting is only visible with transparent backgrounds. Changelog: [Internal] Reviewed By: joevilches Differential Revision: D60389685 fbshipit-source-id: 8c449cc3eee1a3e4100f06fd87f27ae341e02eac --- .../uimanager/BackgroundStyleApplicator.kt | 38 +++++----- .../drawable/BoxShadowBorderRadius.kt | 60 +++++----------- .../drawable/CompositeBackgroundDrawable.kt | 26 ++++--- .../drawable/InsetBoxShadowDrawable.kt | 46 +++++-------- .../drawable/OutsetBoxShadowDrawable.kt | 69 +++++++++++++++---- 5 files changed, 132 insertions(+), 107 deletions(-) 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)