From a23555298c1276efca6a706f39a41c60616fd7f5 Mon Sep 17 00:00:00 2001 From: Jorge Cabiedes Acosta Date: Thu, 3 Oct 2024 16:25:05 -0700 Subject: [PATCH] Add `BackgroundDrawable` to replace background logic on `CSSBackgroundDrawable` (#46709) Summary: Pull Request resolved: https://github.com/facebook/react-native/pull/46709 **Note:** This diff still does nothing yet, it will be enabled on a diff further up the stack. This split is just to simplify reviewing `CSSBackgroundDrawable` holds the drawing logic for both **Borders** and **Background**. This is not ideal since it results in a huge file which does 2 things. We now have `CompositeBackgroundDrawable` which allows us to set a different drawable per "layer" on our view. By splitting up **Border** and **Background** logic we get better modularity and it'll make it easier to implement more `backgroundImage` features without further bloating the `CSSBackgroundDrawable` file Also, this helps with the kotlinification efforts Changelog: [Internal] Reviewed By: NickGerleman Differential Revision: D63137921 --- .../ReactAndroid/api/ReactAndroid.api | 1 + .../uimanager/drawable/BackgroundDrawable.kt | 251 ++++++++++++++++++ .../uimanager/style/ComputedBorderRadius.kt | 6 +- 3 files changed, 257 insertions(+), 1 deletion(-) create mode 100644 packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/drawable/BackgroundDrawable.kt diff --git a/packages/react-native/ReactAndroid/api/ReactAndroid.api b/packages/react-native/ReactAndroid/api/ReactAndroid.api index 01c875b378bf24..4b97d05a71eadc 100644 --- a/packages/react-native/ReactAndroid/api/ReactAndroid.api +++ b/packages/react-native/ReactAndroid/api/ReactAndroid.api @@ -6157,6 +6157,7 @@ public final class com/facebook/react/uimanager/style/ComputedBorderRadius { public final fun getTopRight ()Lcom/facebook/react/uimanager/style/CornerRadii; public final fun hasRoundedBorders ()Z public fun hashCode ()I + public final fun isUniform ()Z public fun toString ()Ljava/lang/String; } diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/drawable/BackgroundDrawable.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/drawable/BackgroundDrawable.kt new file mode 100644 index 00000000000000..fd635d0be5c05f --- /dev/null +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/drawable/BackgroundDrawable.kt @@ -0,0 +1,251 @@ +/* + * 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.Color +import android.graphics.ColorFilter +import android.graphics.ComposeShader +import android.graphics.Paint +import android.graphics.Path +import android.graphics.PorterDuff +import android.graphics.Rect +import android.graphics.RectF +import android.graphics.Shader +import android.graphics.drawable.Drawable +import androidx.core.graphics.ColorUtils +import com.facebook.react.uimanager.PixelUtil.dpToPx +import com.facebook.react.uimanager.PixelUtil.toDIPFromPixel +import com.facebook.react.uimanager.style.BackgroundImageLayer +import com.facebook.react.uimanager.style.BorderInsets +import com.facebook.react.uimanager.style.BorderRadiusStyle +import com.facebook.react.uimanager.style.ComputedBorderRadius +import kotlin.properties.ObservableProperty +import kotlin.properties.ReadWriteProperty +import kotlin.reflect.KProperty + +internal class BackgroundDrawable( + private val context: Context, + var borderRadius: BorderRadiusStyle? = null, + var borderInsets: BorderInsets? = null, +) : Drawable() { + private fun invalidatingChange(initialValue: T): ReadWriteProperty = + object : ObservableProperty(initialValue) { + override fun afterChange(property: KProperty<*>, oldValue: T, newValue: T) { + if (oldValue != newValue) { + invalidateSelf() + } + } + } + + /** + * There is a small gap between the edges of adjacent paths, such as between its Border and its + * Outline. The smallest amount (found to be 0.8f) is used to shrink outline's path, overlapping + * them and closing the visible gap. + */ + private val gapBetweenPaths = 0.8f + private var backgroundAlpha: Int = 255 + private var computedBorderInsets: RectF? = null + private var computedBorderRadius: ComputedBorderRadius? = null + private var needUpdatePath = true + + var backgroundColor: Int by invalidatingChange(Color.TRANSPARENT) + var paddingBoxRect: RectF = RectF() + private set + + var paddingBoxRenderPath: Path? = null + private set + + var backgroundImageLayers: List? by invalidatingChange(null) + + private val backgroundPaint: Paint = + Paint(Paint.ANTI_ALIAS_FLAG).apply { + style = Paint.Style.FILL + color = backgroundColor + alpha = 255 + } + + override fun invalidateSelf() { + needUpdatePath = true + super.invalidateSelf() + } + + override fun onBoundsChange(bounds: Rect) { + super.onBoundsChange(bounds) + needUpdatePath = true + } + + override fun setAlpha(alpha: Int) { + this.backgroundAlpha = alpha + invalidateSelf() + } + + override fun setColorFilter(colorFilter: ColorFilter?) { + // do nothing + } + + override fun getOpacity(): Int = (Color.alpha(backgroundColor) * backgroundAlpha) ushr 8 + + override fun draw(canvas: Canvas) { + updatePath() + canvas.save() + + var innerRadiusX = 0f + var innerRadiusY = 0f + // Draws the View without its border first (with background color fill) + val useColor = ColorUtils.setAlphaComponent(backgroundColor, opacity) + if (Color.alpha(useColor) != 0) { + backgroundPaint.color = useColor + if (computedBorderRadius?.isUniform() == true && borderRadius?.hasRoundedBorders() == true) { + innerRadiusX = + getInnerBorderRadius( + computedBorderRadius?.topLeft?.horizontal?.dpToPx(), computedBorderInsets?.left) + innerRadiusY = + getInnerBorderRadius( + computedBorderRadius?.topLeft?.vertical?.dpToPx(), computedBorderInsets?.top) + canvas.drawRoundRect(paddingBoxRect, innerRadiusX, innerRadiusY, backgroundPaint) + } else if (borderRadius?.hasRoundedBorders() != true) { + canvas.drawRect(paddingBoxRect, backgroundPaint) + } else { + canvas.drawPath(checkNotNull(paddingBoxRenderPath), backgroundPaint) + } + } + + if (backgroundImageLayers != null && backgroundImageLayers?.isNotEmpty() == true) { + backgroundPaint.setShader(getBackgroundImageShader()) + if (computedBorderRadius?.isUniform() == true && borderRadius?.hasRoundedBorders() == true) { + canvas.drawRoundRect(paddingBoxRect, innerRadiusX, innerRadiusY, backgroundPaint) + } else if (borderRadius?.hasRoundedBorders() != true) { + canvas.drawRect(paddingBoxRect, backgroundPaint) + } else { + canvas.drawPath(checkNotNull(paddingBoxRenderPath), backgroundPaint) + } + backgroundPaint.setShader(null) + } + canvas.restore() + } + + private fun computeBorderInsets(): RectF = + borderInsets?.resolve(layoutDirection, context).let { + RectF( + it?.left?.dpToPx() ?: 0f, + it?.top?.dpToPx() ?: 0f, + it?.right?.dpToPx() ?: 0f, + it?.bottom?.dpToPx() ?: 0f) + } + + private fun getBackgroundImageShader(): Shader? { + backgroundImageLayers?.let { layers -> + var compositeShader: Shader? = null + for (backgroundImageLayer in layers) { + val currentShader = backgroundImageLayer.getShader(bounds) ?: continue + + compositeShader = + if (compositeShader == null) { + currentShader + } else { + ComposeShader(currentShader, compositeShader, PorterDuff.Mode.SRC_OVER) + } + } + return compositeShader + } + return null + } + + /** + * Here, "inner" refers to the border radius on the inside of the border. So it ends up being the + * "outer" border radius inset by the respective width. + */ + private fun getInnerBorderRadius(computedRadius: Float?, borderWidth: Float?): Float { + return ((computedRadius ?: 0f) - (borderWidth ?: 0f)).coerceAtLeast(0f) + } + + private fun updatePath() { + if (!needUpdatePath) { + return + } + needUpdatePath = false + + computedBorderInsets = computeBorderInsets() + computedBorderRadius = + borderRadius?.resolve( + layoutDirection, + context, + toDIPFromPixel(bounds.width().toFloat()), + toDIPFromPixel(bounds.height().toFloat())) + + if (computedBorderRadius?.hasRoundedBorders() == true && + computedBorderRadius?.isUniform() == false) { + paddingBoxRenderPath = paddingBoxRenderPath ?: Path() + paddingBoxRenderPath?.reset() + } + + // only close gap between border and background if we draw the border, otherwise + // we wind up pixelating small pixel-radius curves + var pathAdjustment = 0f + if (computedBorderInsets != null && + (computedBorderInsets?.left != 0f || + computedBorderInsets?.top != 0f || + computedBorderInsets?.right != 0f || + computedBorderInsets?.bottom != 0f)) { + pathAdjustment = gapBetweenPaths + } + + // There is a small gap between backgroundDrawable and + // borderDrawable. pathAdjustment is used to slightly enlarge the rectangle + // (paddingBoxRect), ensuring the border can be + // drawn on top without the gap. + paddingBoxRect.left = bounds.left + (computedBorderInsets?.left ?: 0f) - pathAdjustment + paddingBoxRect.top = bounds.top + (computedBorderInsets?.top ?: 0f) - pathAdjustment + paddingBoxRect.right = bounds.right - (computedBorderInsets?.right ?: 0f) + pathAdjustment + paddingBoxRect.bottom = bounds.bottom - (computedBorderInsets?.bottom ?: 0f) + pathAdjustment + + if (borderRadius?.hasRoundedBorders() == true && computedBorderRadius?.isUniform() != true) { + + val innerTopLeftRadiusX = + getInnerBorderRadius( + computedBorderRadius?.topLeft?.horizontal?.dpToPx(), computedBorderInsets?.left) + val innerTopLeftRadiusY = + getInnerBorderRadius( + computedBorderRadius?.topLeft?.vertical?.dpToPx(), computedBorderInsets?.top) + val innerTopRightRadiusX = + getInnerBorderRadius( + computedBorderRadius?.topRight?.horizontal?.dpToPx(), computedBorderInsets?.right) + val innerTopRightRadiusY = + getInnerBorderRadius( + computedBorderRadius?.topRight?.vertical?.dpToPx(), computedBorderInsets?.top) + val innerBottomRightRadiusX = + getInnerBorderRadius( + computedBorderRadius?.bottomRight?.horizontal?.dpToPx(), computedBorderInsets?.right) + val innerBottomRightRadiusY = + getInnerBorderRadius( + computedBorderRadius?.bottomRight?.vertical?.dpToPx(), computedBorderInsets?.bottom) + val innerBottomLeftRadiusX = + getInnerBorderRadius( + computedBorderRadius?.bottomLeft?.horizontal?.dpToPx(), computedBorderInsets?.left) + val innerBottomLeftRadiusY = + getInnerBorderRadius( + computedBorderRadius?.bottomLeft?.vertical?.dpToPx(), computedBorderInsets?.bottom) + + paddingBoxRenderPath?.addRoundRect( + paddingBoxRect, + floatArrayOf( + innerTopLeftRadiusX, + innerTopLeftRadiusY, + innerTopRightRadiusX, + innerTopRightRadiusY, + innerBottomRightRadiusX, + innerBottomRightRadiusY, + innerBottomLeftRadiusX, + innerBottomLeftRadiusY, + ), + Path.Direction.CW) + } + } +} diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/style/ComputedBorderRadius.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/style/ComputedBorderRadius.kt index a16c951510e267..234f04bd4f2cee 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/style/ComputedBorderRadius.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/style/ComputedBorderRadius.kt @@ -15,7 +15,7 @@ public enum class ComputedBorderRadiusProp { COMPUTED_BORDER_BOTTOM_LEFT_RADIUS, } -/** Phsysical edge lengths (in DIPs) for a border-radius. */ +/** Physical edge lengths (in DIPs) for a border-radius. */ public data class ComputedBorderRadius( val topLeft: CornerRadii, val topRight: CornerRadii, @@ -32,6 +32,10 @@ public data class ComputedBorderRadius( bottomRight.horizontal > 0f } + public fun isUniform(): Boolean { + return topLeft == topRight && topLeft == bottomLeft && topLeft == bottomRight + } + public fun get(property: ComputedBorderRadiusProp): CornerRadii { return when (property) { ComputedBorderRadiusProp.COMPUTED_BORDER_TOP_LEFT_RADIUS -> topLeft