Skip to content

Commit

Permalink
Inset box shadow impl
Browse files Browse the repository at this point in the history
Summary:
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

Differential Revision: D59300215
  • Loading branch information
joevilches authored and facebook-github-bot committed Jul 8, 2024
1 parent 99dcf1e commit f5ca5f5
Show file tree
Hide file tree
Showing 4 changed files with 297 additions and 1 deletion.
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 {
public 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,53 @@
/*
* 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 ->
adjustedBorderRadii.set(
borderRadiusProp,
adjustedBorderRadius(spread, backgroundBorderRadii.get(borderRadiusProp), 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,134 @@
/*
* 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.Rect
import android.graphics.RenderNode
import android.graphics.drawable.Drawable
import androidx.annotation.RequiresApi
import com.facebook.common.logging.FLog
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)
internal class InsetBoxShadowDrawable(
private val context: Context,
private val background: CSSBackgroundDrawable,
shadowColor: Int,
private val offsetX: Float,
private val offsetY: Float,
private val blurRadius: Float,
private val spread: Float,
) : Drawable() {
private val shadowShapeDrawable = CSSBackgroundDrawable(context).apply { color = shadowColor }

private val renderNode =
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, "InsetBoxShadowDrawable requires a hardware accelerated canvas")
return
}

if (shadowShapeDrawable.bounds != bounds ||
shadowShapeDrawable.layoutDirection != layoutDirection ||
shadowShapeDrawable.borderRadius != background.borderRadius ||
shadowShapeDrawable.colorFilter != colorFilter) {
canvas.save()

// 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()
// 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
val shadowShapeBounds =
Rect(bounds).apply { inset(-2 * adjustedBlurRadius, -2 * adjustedBlurRadius) }
shadowShapeDrawable.bounds = shadowShapeBounds
shadowShapeDrawable.layoutDirection = layoutDirection
shadowShapeDrawable.colorFilter = colorFilter

// We create a new drawable that represents the clear region that we clip
// out of the shadow. The shadow itself is just a colored rect before this
with(renderNode) {
setPosition(Rect(bounds).apply { inset(-2 * adjustedBlurRadius, -2 * adjustedBlurRadius) })
val spreadExtent = PixelUtil.toPixelFromDIP(spread).roundToInt().coerceAtLeast(0)
val clearRegionBounds = Rect(bounds).apply { inset(spreadExtent, spreadExtent) }
val clearRegionBorderRadii =
getShadowBorderRadii(
-spreadExtent.toFloat(),
background.borderRadius,
bounds.width().toFloat(),
bounds.height().toFloat())
val clearRegionDrawable =
CSSBackgroundDrawable(context).apply {
borderRadius = clearRegionBorderRadii
bounds =
Rect(clearRegionBounds).apply {
offset(
PixelUtil.toPixelFromDIP(offsetX).roundToInt() + adjustedBlurRadius,
PixelUtil.toPixelFromDIP(offsetY).roundToInt() + adjustedBlurRadius)
}
}

beginRecording().let { canvas ->
val borderBoxPath = clearRegionDrawable.getBorderBoxPath()
if (borderBoxPath != null) {
canvas.clipOutPath(borderBoxPath)
} else {
canvas.clipOutRect(clearRegionDrawable.borderBoxRect)
}

shadowShapeDrawable.draw(canvas)
endRecording()
}
}

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

canvas.restore()
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
/*
* 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.Rect
import android.graphics.RenderNode
import android.graphics.drawable.Drawable
import androidx.annotation.RequiresApi
import com.facebook.common.logging.FLog
import com.facebook.react.uimanager.FilterHelper
import com.facebook.react.uimanager.PixelUtil
import kotlin.math.roundToInt

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 */
@RequiresApi(31)
internal class OutsetBoxShadowDrawable(
context: Context,
private val background: CSSBackgroundDrawable,
shadowColor: Int,
private val offsetX: Float,
private val offsetY: Float,
private val blurRadius: Float,
private val spread: Float,
) : Drawable() {
private val shadowShapeDrawable = CSSBackgroundDrawable(context).apply { color = shadowColor }

private val renderNode =
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, "OutsetBoxShadowDrawable requires a hardware accelerated canvas")
return
}

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())

if (shadowShapeDrawable.bounds != shadowShapeBounds ||
shadowShapeDrawable.layoutDirection != layoutDirection ||
shadowShapeDrawable.borderRadius != background.borderRadius ||
shadowShapeDrawable.colorFilter != colorFilter) {
canvas.save()
shadowShapeDrawable.bounds = shadowShapeBounds
shadowShapeDrawable.layoutDirection = layoutDirection
shadowShapeDrawable.borderRadius =
getShadowBorderRadii(
spreadExtent.toFloat(),
background.borderRadius,
bounds.width().toFloat(),
bounds.height().toFloat())
shadowShapeDrawable.colorFilter = colorFilter

with(renderNode) {
setPosition(
Rect(shadowShapeFrame).apply {
offset(
PixelUtil.toPixelFromDIP(offsetX).roundToInt(),
PixelUtil.toPixelFromDIP(offsetY).roundToInt())
})

beginRecording().let { canvas ->
shadowShapeDrawable.draw(canvas)
endRecording()
}
}
}

with(canvas) {
val borderBoxPath = background.getBorderBoxPath()
if (borderBoxPath != null) {
clipOutPath(borderBoxPath)
} else {
clipOutRect(background.getBorderBoxRect())
}

drawRenderNode(renderNode)
}

canvas.restore()
}
}

0 comments on commit f5ca5f5

Please sign in to comment.