From 1a78477ce528b36a97602d0210bd4ea8446804dc Mon Sep 17 00:00:00 2001 From: Nick Gerleman Date: Mon, 29 Jul 2024 15:12:28 -0700 Subject: [PATCH] Add CompositeBackgroundDrawable and BackgroundStyleApplicator (#45688) Summary: Pull Request resolved: https://github.com/facebook/react-native/pull/45688 Box shadows are handled as part of different drawables. We have other cases where we want to show multiple drawables at once, such as for ripple feedback, or more commonly, for app-wide TextInput styles (which adds padding). With more multi-background scenarios in the future, and CSSBackgroundDrawable already way overloaded, the arch here I want to go towards is less drawables, as hidden implementation details, with single responsibilities, more often switched out. Once path logic is extracted, this would also allow for better fast-paths, like not needing to create a (heavy) CSSBackgroundDrawable, for simple views with a color background. `CompositeBackgroundDrawable` is then a more structured LayerDrawable, which also lets us mutate or retrieve information from specific layers, and enforces the different types of layers are correctly z-ordered. `BackgroundStyleApplicator` is the public API for manipulating these styles, inspired by the existing `ReactViewBackgroundManager`. There are some important design differences. 1. The only per-view state is the publicly accessible background drawable. This means the applicator can be used on arbitrary views, and eventually used in BaseViewManager for all views (once all the QEs settle) 2. We have reliable accessors for every setter, which seem to be what folks use externally for animation 3. We work consistently in CSS device independent pixels (for the most part...) 4. More structure/safety in how we refer to edges vs uniform 5. Overflow state is not kept on the applicator, so views can set/keep their own defaults Overflow clipping must still be implemented per-view, during drawing unfortunately. Changelog: [Android][Added] - Add BackgroundStyleApplicator for managing view backgrounds Reviewed By: joevilches Differential Revision: D60252279 fbshipit-source-id: 4c6da3e128d4da94f35d50c30c7c412cb513cc12 --- .../ReactAndroid/api/ReactAndroid.api | 16 ++ .../uimanager/BackgroundStyleApplicator.kt | 174 ++++++++++++++++++ .../drawable/CompositeBackgroundDrawable.kt | 71 +++++++ 3 files changed, 261 insertions(+) create mode 100644 packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/BackgroundStyleApplicator.kt create mode 100644 packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/drawable/CompositeBackgroundDrawable.kt diff --git a/packages/react-native/ReactAndroid/api/ReactAndroid.api b/packages/react-native/ReactAndroid/api/ReactAndroid.api index 89f47e173af911..052c6e82180ce1 100644 --- a/packages/react-native/ReactAndroid/api/ReactAndroid.api +++ b/packages/react-native/ReactAndroid/api/ReactAndroid.api @@ -3995,6 +3995,22 @@ public abstract interface class com/facebook/react/turbomodule/core/interfaces/T public abstract fun getBindingsInstaller ()Lcom/facebook/react/turbomodule/core/interfaces/BindingsInstallerHolder; } +public final class com/facebook/react/uimanager/BackgroundStyleApplicator { + public static final field INSTANCE Lcom/facebook/react/uimanager/BackgroundStyleApplicator; + public static final fun clipToPaddingBox (Landroid/view/View;Landroid/graphics/Canvas;)V + public static final fun getBackgroundColor (Landroid/view/View;)Ljava/lang/Integer; + public static final fun getBorderColor (Landroid/view/View;Lcom/facebook/react/uimanager/style/LogicalEdge;)Ljava/lang/Integer; + public static final fun getBorderRadius (Landroid/view/View;Lcom/facebook/react/uimanager/style/BorderRadiusProp;)Lcom/facebook/react/uimanager/LengthPercentage; + public static final fun getBorderStyle (Landroid/view/View;)Lcom/facebook/react/uimanager/style/BorderStyle; + public static final fun getBorderWidth (Landroid/view/View;Lcom/facebook/react/uimanager/style/LogicalEdge;)Ljava/lang/Float; + public static final fun setBackgroundColor (Landroid/view/View;Ljava/lang/Integer;)V + public static final fun setBorderColor (Landroid/view/View;Lcom/facebook/react/uimanager/style/LogicalEdge;Ljava/lang/Integer;)V + public static final fun setBorderRadius (Landroid/view/View;Lcom/facebook/react/uimanager/style/BorderRadiusProp;Lcom/facebook/react/uimanager/LengthPercentage;)V + public static final fun setBorderStyle (Landroid/view/View;Lcom/facebook/react/uimanager/style/BorderStyle;)V + public static final fun setBorderWidth (Landroid/view/View;Lcom/facebook/react/uimanager/style/LogicalEdge;Ljava/lang/Float;)V + public static final fun setBoxShadow (Landroid/view/View;Ljava/util/List;)V +} + public abstract class com/facebook/react/uimanager/BaseViewManager : com/facebook/react/uimanager/ViewManager, android/view/View$OnLayoutChangeListener, com/facebook/react/uimanager/BaseViewManagerInterface { public fun ()V public fun (Lcom/facebook/react/bridge/ReactApplicationContext;)V diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/BackgroundStyleApplicator.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/BackgroundStyleApplicator.kt new file mode 100644 index 00000000000000..c5965342356233 --- /dev/null +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/BackgroundStyleApplicator.kt @@ -0,0 +1,174 @@ +/* + * 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 + +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Rect +import android.view.View +import androidx.annotation.ColorInt +import androidx.annotation.RequiresApi +import com.facebook.react.common.annotations.UnstableReactNativeAPI +import com.facebook.react.uimanager.drawable.CSSBackgroundDrawable +import com.facebook.react.uimanager.drawable.CompositeBackgroundDrawable +import com.facebook.react.uimanager.drawable.InsetBoxShadowDrawable +import com.facebook.react.uimanager.drawable.OutsetBoxShadowDrawable +import com.facebook.react.uimanager.style.BorderRadiusProp +import com.facebook.react.uimanager.style.BorderStyle +import com.facebook.react.uimanager.style.BoxShadow +import com.facebook.react.uimanager.style.LogicalEdge + +/** + * BackgroundStyleApplicator is responsible for applying backgrounds, borders, and related effects, + * to an Android view + */ +@OptIn(UnstableReactNativeAPI::class) +public object BackgroundStyleApplicator { + + @JvmStatic + public fun setBackgroundColor(view: View, @ColorInt color: Int?): Unit { + // No color to set, and no color already set + if ((color == null || color == Color.TRANSPARENT) && + view.background !is CompositeBackgroundDrawable) { + return + } + + ensureCSSBackground(view).color = color ?: Color.TRANSPARENT + } + + @JvmStatic + @ColorInt + public fun getBackgroundColor(view: View): Int? = getCSSBackground(view)?.color + + @JvmStatic + public fun setBorderWidth(view: View, edge: LogicalEdge, width: Float?): Unit = + ensureCSSBackground(view) + .setBorderWidth(edge.toSpacingType(), PixelUtil.toPixelFromDIP(width ?: Float.NaN)) + + @JvmStatic + public fun getBorderWidth(view: View, edge: LogicalEdge): Float? { + val width = getCSSBackground(view)?.getBorderWidth(edge.toSpacingType()) + return if (width == null || width.isNaN()) null else PixelUtil.toDIPFromPixel((width)) + } + + @JvmStatic + public fun setBorderColor(view: View, edge: LogicalEdge, @ColorInt color: Int?): Unit = + ensureCSSBackground(view).setBorderColor(edge.toSpacingType(), color) + + @JvmStatic + @ColorInt + public fun getBorderColor(view: View, edge: LogicalEdge): Int? = + getCSSBackground(view)?.getBorderColor(edge.toSpacingType()) + + @JvmStatic + public fun setBorderRadius( + view: View, + corner: BorderRadiusProp, + // TODO: LengthPercentage silently converts from pixels to DIPs before here already + radius: LengthPercentage? + ): Unit = ensureCSSBackground(view).setBorderRadius(corner, radius) + + @JvmStatic + public fun getBorderRadius(view: View, corner: BorderRadiusProp): LengthPercentage? = + getCSSBackground(view)?.borderRadius?.get(corner) + + @JvmStatic + public fun setBorderStyle(view: View, borderStyle: BorderStyle?): Unit { + ensureCSSBackground(view).borderStyle = borderStyle + } + + @JvmStatic + public fun getBorderStyle(view: View): BorderStyle? = getCSSBackground(view)?.borderStyle + + @JvmStatic + @RequiresApi(31) + public fun setBoxShadow(view: View, shadows: List): 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 + + if (inset) { + InsetBoxShadowDrawable( + context = view.context, + background = ensureCSSBackground(view), + shadowColor = color, + offsetX = offsetX, + offsetY = offsetY, + blurRadius = blurRadius, + spread = spreadDistance) + } else { + OutsetBoxShadowDrawable( + context = view.context, + background = ensureCSSBackground(view), + shadowColor = color, + offsetX = offsetX, + offsetY = offsetY, + blurRadius = blurRadius, + spread = spreadDistance) + } + } + + view.background = ensureCompositeBackgroundDrawable(view).withNewShadows(shadowDrawables) + } + + @JvmStatic + public fun clipToPaddingBox(view: View, canvas: Canvas): Unit { + // The canvas may be scrolled, so we need to offset + val drawingRect = Rect() + view.getDrawingRect(drawingRect) + + val cssBackground = getCSSBackground(view) + if (cssBackground == null) { + canvas.clipRect(drawingRect) + return + } + + val paddingBoxPath = cssBackground.paddingBoxPath + if (paddingBoxPath != null) { + paddingBoxPath.offset(drawingRect.left.toFloat(), drawingRect.top.toFloat()) + canvas.clipPath(paddingBoxPath) + } else { + val paddingBoxRect = cssBackground.paddingBoxRect + paddingBoxRect.offset(drawingRect.left.toFloat(), drawingRect.top.toFloat()) + canvas.clipRect(paddingBoxRect) + } + } + + private fun ensureCompositeBackgroundDrawable(view: View): CompositeBackgroundDrawable { + if (view.background is CompositeBackgroundDrawable) { + return view.background as CompositeBackgroundDrawable + } + + val compositeDrawable = CompositeBackgroundDrawable(view.background, null, emptyList(), null) + view.background = compositeDrawable + return compositeDrawable + } + + private fun ensureCSSBackground(view: View): CSSBackgroundDrawable { + val compositeBackgroundDrawable = ensureCompositeBackgroundDrawable(view) + if (compositeBackgroundDrawable.cssBackground != null) { + return compositeBackgroundDrawable.cssBackground + } else { + val cssBackground = CSSBackgroundDrawable(view.context) + view.background = compositeBackgroundDrawable.withNewCssBackground(cssBackground) + return cssBackground + } + } + + private fun getCSSBackground(view: View): CSSBackgroundDrawable? { + if (view.background is CompositeBackgroundDrawable) { + return (view.background as CompositeBackgroundDrawable).cssBackground + } + return null + } +} diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/drawable/CompositeBackgroundDrawable.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/drawable/CompositeBackgroundDrawable.kt new file mode 100644 index 00000000000000..4f7a088d202d2e --- /dev/null +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/drawable/CompositeBackgroundDrawable.kt @@ -0,0 +1,71 @@ +/* + * 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.graphics.drawable.Drawable +import android.graphics.drawable.LayerDrawable +import com.facebook.react.common.annotations.UnstableReactNativeAPI + +/** + * CompositeBackgroundDrawable can overlay multiple different layers, shadows, and native effects + * such as ripple, into an Android View's background drawable. + */ +@OptIn(UnstableReactNativeAPI::class) +internal class CompositeBackgroundDrawable( + /** + * Any non-react-managed background already part of the view, like one set as Android style on a + * TextInput + */ + public val originalBackground: Drawable? = null, + + /** + * CSS background layer and border rendering + * + * TODO: we should extract path logic from here, and fast-path to using simpler drawables like + * ColorDrawable in the common cases + */ + public val cssBackground: CSSBackgroundDrawable? = null, + + /** Inner and outer box shadows */ + public val shadows: List = emptyList(), + + /** Native riplple effect (e.g. used by TouchableNativeFeedback) */ + public val nativeRipple: Drawable? = null +) : + 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(), + nativeRipple) + .toTypedArray()) { + + init { + // We want to overlay drawables, instead of placing future drawables within the content area of + // previous ones. E.g. an EditText style may set padding on a TextInput, but we don't want to + // constrain background color to the area inside of the padding. + setPaddingMode(LayerDrawable.PADDING_MODE_STACK) + } + + public fun withNewCssBackground( + cssBackground: CSSBackgroundDrawable? + ): CompositeBackgroundDrawable { + return CompositeBackgroundDrawable(originalBackground, cssBackground, shadows, nativeRipple) + } + + public fun withNewShadows(newShadows: List): CompositeBackgroundDrawable { + return CompositeBackgroundDrawable(originalBackground, cssBackground, newShadows, nativeRipple) + } + + public fun withNewNativeRipple(newRipple: Drawable?): CompositeBackgroundDrawable { + return CompositeBackgroundDrawable(originalBackground, cssBackground, shadows, newRipple) + } +}