diff --git a/packages/react-native/ReactAndroid/api/ReactAndroid.api b/packages/react-native/ReactAndroid/api/ReactAndroid.api index 89f47e173af..052c6e82180 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 00000000000..c5965342356 --- /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 00000000000..4f7a088d202 --- /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) + } +}