Skip to content

Commit

Permalink
Inset box shadow impl (#45337)
Browse files Browse the repository at this point in the history
Summary:
Pull Request resolved: #45337

tsia. Some things to consider when reviewing:

* Made a new drawable for inset shadows
* The drawable in this class is the same size as the view with some padding. The padding is needed for 2 reasons
  * Blur near edges looks good
  * Blur artifacts can appear inside the view if the clear region barely exits the bounds of the view
* We draw the clear shape with another drawable, which solely exists so that we can get the border box path for the adjust border. We just use this path to clip out the shadow

Changelog: [Internal]

Reviewed By: NickGerleman

Differential Revision: D59300215

fbshipit-source-id: 30acc7aafd82122aa278a42d06418bb1079ca71f
  • Loading branch information
joevilches authored and facebook-github-bot committed Jul 13, 2024
1 parent 0b20ea9 commit 65a3259
Show file tree
Hide file tree
Showing 4 changed files with 204 additions and 58 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -388,7 +388,7 @@ internal object FilterHelper {
}
}

private fun sigmaToRadius(sigma: Float): Float {
internal fun sigmaToRadius(sigma: Float): Float {
// Android takes blur amount as a radius while web takes a sigma. This value
// is used under the hood to convert between them on Android
// https://cs.android.com/android/platform/superproject/main/+/main:frameworks/base/libs/hwui/jni/RenderEffect.cpp
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
/*
* 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 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

internal fun getShadowBorderRadii(
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)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
/*
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

package com.facebook.react.uimanager.drawable

import android.content.Context
import android.graphics.Canvas
import android.graphics.ColorFilter
import android.graphics.Paint
import android.graphics.Rect
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.PixelUtil
import kotlin.math.roundToInt

private const val TAG = "InsetBoxShadowDrawable"

// "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 inner box-shadow https://www.w3.org/TR/css-backgrounds-3/#shadow-shape */
@RequiresApi(31)
@OptIn(UnstableReactNativeAPI::class)
internal class InsetBoxShadowDrawable(
private val context: Context,
private val background: CSSBackgroundDrawable,
private val shadowColor: Int,
private val offsetX: Float,
private val offsetY: Float,
private val blurRadius: Float,
private val spread: Float,
) : Drawable() {
private val renderNode =
RenderNode(TAG).apply {
clipToBounds = false
setRenderEffect(FilterHelper.createBlurEffect(blurRadius * BLUR_RADIUS_SIGMA_SCALE))
}

private val clearRegionDrawable = CSSBackgroundDrawable(context)

private val shadowPaint = Paint().apply { color = shadowColor }

override fun setAlpha(alpha: Int) {
renderNode.alpha = alpha / 255f
}

override fun setColorFilter(colorFilter: ColorFilter?): Unit = Unit

override fun getOpacity(): Int = (renderNode.alpha * 255).roundToInt()

override fun draw(canvas: Canvas) {
if (!canvas.isHardwareAccelerated) {
FLog.w(TAG, "InsetBoxShadowDrawable requires a hardware accelerated canvas")
return
}
// We need the actual size the blur will increase the shadow by so we can
// properly pad. This is not simply the input as Android has it's own
// distinct blur algorithm
val adjustedBlurRadius =
FilterHelper.sigmaToRadius(blurRadius * BLUR_RADIUS_SIGMA_SCALE).roundToInt()
val padding = 2 * adjustedBlurRadius

val spreadExtent = PixelUtil.toPixelFromDIP(spread).roundToInt().coerceAtLeast(0)
val clearRegionBounds = Rect()
background.getPaddingBoxRect().round(clearRegionBounds)
clearRegionBounds.inset(spreadExtent, spreadExtent)
clearRegionBounds.offset(
PixelUtil.toPixelFromDIP(offsetX).roundToInt() + padding / 2,
PixelUtil.toPixelFromDIP(offsetY).roundToInt() + padding / 2)
val clearRegionBorderRadii =
getShadowBorderRadii(
-spreadExtent.toFloat(),
background.borderRadius,
clearRegionBounds.width().toFloat(),
clearRegionBounds.height().toFloat())

if (shadowPaint.colorFilter != colorFilter ||
clearRegionDrawable.layoutDirection != layoutDirection ||
clearRegionDrawable.borderRadius != clearRegionBorderRadii ||
clearRegionDrawable.bounds != clearRegionBounds) {
canvas.save()

shadowPaint.colorFilter = colorFilter
clearRegionDrawable.bounds = clearRegionBounds
clearRegionDrawable.layoutDirection = layoutDirection
clearRegionDrawable.borderRadius = clearRegionBorderRadii

with(renderNode) {
// We pad by the blur radius so that the edges of the blur look good and
// the blur artifacts can bleed into the view if needed
setPosition(Rect(bounds).apply { inset(-padding, -padding) })
beginRecording().let { canvas ->
val borderBoxPath = clearRegionDrawable.getBorderBoxPath()
if (borderBoxPath != null) {
canvas.clipOutPath(borderBoxPath)
} else {
canvas.clipOutRect(clearRegionDrawable.borderBoxRect)
}

canvas.drawPaint(shadowPaint)
endRecording()
}
}

// We actually draw the render node into our canvas and clip out the
// padding
with(canvas) {
val paddingBoxPath = background.getPaddingBoxPath()
if (paddingBoxPath != null) {
canvas.clipPath(paddingBoxPath)
} else {
canvas.clipRect(background.getPaddingBoxRect())
}
// This positions the render node properly since we padded it
canvas.translate(padding / 2f, padding / 2f)
drawRenderNode(renderNode)
}

canvas.restore()
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,44 +17,47 @@ 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.BorderRadiusProp
import com.facebook.react.uimanager.style.BorderRadiusStyle
import kotlin.math.roundToInt

private const val TAG = "BoxShadowDrawable"
private const val TAG = "OutsetBoxShadowDrawable"

// "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 */
/** Draws an outer box-shadow https://www.w3.org/TR/css-backgrounds-3/#shadow-shape */
@RequiresApi(31)
@OptIn(UnstableReactNativeAPI::class)
internal class BoxShadowDrawable(
internal class OutsetBoxShadowDrawable(
context: Context,
private val background: CSSBackgroundDrawable,
shadowColor: Int,
private val offsetX: Float,
private val offsetY: Float,
blurRadius: Float,
private val blurRadius: Float,
private val spread: Float,
) : Drawable() {

private val shadowShapeDrawable = CSSBackgroundDrawable(context).apply { color = shadowColor }

private val renderNode =
RenderNode("BoxShadowDrawable").apply {
RenderNode(TAG).apply {
clipToBounds = false
setRenderEffect(FilterHelper.createBlurEffect(blurRadius * BLUR_RADIUS_SIGMA_SCALE))
}

override fun setAlpha(alpha: Int) {
renderNode.alpha = alpha / 255f
}

override fun setColorFilter(colorFilter: ColorFilter?): Unit = Unit

override fun getOpacity(): Int = (renderNode.alpha * 255).roundToInt()

override fun draw(canvas: Canvas) {
if (!canvas.isHardwareAccelerated) {
FLog.w(TAG, "BoxShadowDrawable requires a hardware accelerated canvas")
FLog.w(TAG, "OutsetBoxShadowDrawable requires a hardware accelerated canvas")
return
}

Expand All @@ -72,6 +75,7 @@ internal class BoxShadowDrawable(
shadowShapeDrawable.layoutDirection != layoutDirection ||
shadowShapeDrawable.borderRadius != borderRadii ||
shadowShapeDrawable.colorFilter != colorFilter) {
canvas.save()
shadowShapeDrawable.bounds = shadowShapeBounds
shadowShapeDrawable.layoutDirection = layoutDirection
shadowShapeDrawable.borderRadius = borderRadii
Expand Down Expand Up @@ -102,52 +106,7 @@ internal class BoxShadowDrawable(

drawRenderNode(renderNode)
}
}

override fun setAlpha(alpha: Int) {
renderNode.alpha = alpha / 255f
}

override fun setColorFilter(colorFilter: ColorFilter?): Unit = Unit

override fun getOpacity(): Int = (renderNode.alpha * 255).roundToInt()

private fun getShadowBorderRadii(
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 {
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)
canvas.restore()
}
}

0 comments on commit 65a3259

Please sign in to comment.