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
This commit is contained in:
Nick Gerleman
2024-07-30 15:46:28 -07:00
committed by Facebook GitHub Bot
parent 32d040380f
commit be3936762f
5 changed files with 132 additions and 107 deletions
@@ -91,16 +91,19 @@ public object BackgroundStyleApplicator {
@JvmStatic
@RequiresApi(31)
public fun setBoxShadow(view: View, shadows: List<BoxShadow>): 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<OutsetBoxShadowDrawable>()
val innerShadows = mutableListOf<InsetBoxShadowDrawable>()
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
}
@@ -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)
}
@@ -23,6 +23,9 @@ internal class CompositeBackgroundDrawable(
*/
public val originalBackground: Drawable? = null,
/** Non-inset box shadows */
public val outerShadows: List<Drawable> = 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<Drawable> = emptyList(),
/** Inset box-shadows */
public val innerShadows: List<Drawable> = 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<Drawable>): CompositeBackgroundDrawable {
return CompositeBackgroundDrawable(originalBackground, cssBackground, newShadows, nativeRipple)
public fun withNewShadows(
outerShadows: List<Drawable>,
innerShadows: List<Drawable>
): 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)
}
}
@@ -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),
)
}
}
@@ -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)