From 8d42c415980f3b954044cbca6d3a239809efeceb Mon Sep 17 00:00:00 2001 From: Nick Gerleman Date: Mon, 1 Apr 2024 22:17:00 -0700 Subject: [PATCH] Introduce "BoxShadowDrawable" for Android box shadows (#43722) Summary: Pull Request resolved: https://github.com/facebook/react-native/pull/43722 This change adds a drawable, when when drawn on the bounds of a border-box sized view, will draw a spec compliant box-shadow outside the box. This is reliant on Android `RenderNode` and `RenderEffect` APIs provided by API 31. Inset box shadows can also be added using a similar method, but this is not done yet. The code which manages this is in flux, but the underlying drawable should be good. Will add some tests once it's more wired up. Changelog: [Internal] Reviewed By: joevilches Differential Revision: D55561465 --- .../ReactAndroid/api/ReactAndroid.api | 8 ++ .../uimanager/drawable/BoxShadowDrawable.kt | 109 ++++++++++++++++++ 2 files changed, 117 insertions(+) create mode 100644 packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/drawable/BoxShadowDrawable.kt diff --git a/packages/react-native/ReactAndroid/api/ReactAndroid.api b/packages/react-native/ReactAndroid/api/ReactAndroid.api index a8b8826943087d..6da3e2b0cda081 100644 --- a/packages/react-native/ReactAndroid/api/ReactAndroid.api +++ b/packages/react-native/ReactAndroid/api/ReactAndroid.api @@ -5373,6 +5373,14 @@ public abstract interface class com/facebook/react/uimanager/debug/NotThreadSafe public abstract fun onViewHierarchyUpdateFinished ()V } +public final class com/facebook/react/uimanager/drawable/BoxShadowDrawable : com/facebook/react/uimanager/drawable/DecorationDrawable { + public fun (Landroid/content/Context;Lcom/facebook/react/uimanager/drawable/CSSBackgroundDrawable;IFFFF)V + public fun draw (Landroid/graphics/Canvas;)V + public fun getOpacity ()I + public fun setAlpha (I)V + public fun setColorFilter (Landroid/graphics/ColorFilter;)V +} + public class com/facebook/react/uimanager/drawable/CSSBackgroundDrawable : android/graphics/drawable/Drawable { public fun (Landroid/content/Context;)V public fun borderBoxPath ()Landroid/graphics/Path; diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/drawable/BoxShadowDrawable.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/drawable/BoxShadowDrawable.kt new file mode 100644 index 00000000000000..6cd43fe74ed8fa --- /dev/null +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/drawable/BoxShadowDrawable.kt @@ -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.RenderEffect +import android.graphics.RenderNode +import android.graphics.drawable.Drawable +import androidx.annotation.RequiresApi +import com.facebook.react.uimanager.FilterHelper +import com.facebook.react.uimanager.PixelUtil +import kotlin.math.ceil +import kotlin.math.roundToInt + +// "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 + +// "At the time of writing (January 2024) all major implementations use the familiar three-pass box +// blur approximation, which has extent: +// ((3 * sqrt(2 * π) / 4) * σ)." +// https://drafts.fxtf.org/filter-effects/#funcdef-filter-blur +private const val BLUR_RADIUS_EXTENT_SCALE = 1.88f * BLUR_RADIUS_SIGMA_SCALE + +/** Draws an outer-box shadow https://www.w3.org/TR/css-backgrounds-3/#shadow-shape */ +@RequiresApi(31) +public class BoxShadowDrawable( + context: Context, + private val background: CSSBackgroundDrawable, + private val shadowColor: Int, + private val offsetX: Float, + private val offsetY: Float, + private val blur: Float, + private val spread: Float, +) : Drawable() { + + private val shadowShapeDrawable = + CSSBackgroundDrawable(context).apply { + color = shadowColor + + setRadius(background.fullBorderRadius) + for (location in CSSBackgroundDrawable.BorderRadiusLocation.values()) { + setRadius(background.getBorderRadius(location), location.ordinal) + } + } + + private val renderNode = + RenderNode("BoxShadowDrawable").apply { + clipToBounds = false + setRenderEffect(createBlurEffect()) + } + + override fun draw(canvas: Canvas) { + 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.setBounds(shadowShapeBounds) + + with(renderNode) { + setPosition( + Rect(shadowShapeFrame).apply { + offset( + PixelUtil.toPixelFromDIP(offsetX).roundToInt(), + PixelUtil.toPixelFromDIP(offsetY).roundToInt()) + }) + + val blurExtent = ceil(BLUR_RADIUS_EXTENT_SCALE * PixelUtil.toPixelFromDIP(blur)).toInt() + setClipRect(Rect(shadowShapeBounds).apply { inset(-blurExtent, -blurExtent) }) + + discardDisplayList() + beginRecording().let { canvas -> + shadowShapeDrawable.draw(canvas) + endRecording() + } + } + } + + with(canvas) { + clipOutPath(background.borderBoxPath()) + drawRenderNode(renderNode) + } + } + + override fun setAlpha(alpha: Int) { + renderNode.alpha = alpha / 255f + } + + override fun setColorFilter(colorFilter: ColorFilter?) { + val chainedEffect = colorFilter?.let { RenderEffect.createColorFilterEffect(it) } + renderNode.setRenderEffect(createBlurEffect(chainedEffect)) + } + + private fun createBlurEffect(chainedEffect: RenderEffect? = null): RenderEffect? = + if (blur <= 0f) chainedEffect + else FilterHelper.createBlurEffect(blur * BLUR_RADIUS_SIGMA_SCALE, chainedEffect) + + override fun getOpacity(): Int = (renderNode.alpha * 255).roundToInt() +}