From a6c1b7988ced1139781ef76cb9a68fbb16bde1df Mon Sep 17 00:00:00 2001 From: Jorge Cabiedes Acosta Date: Fri, 13 Sep 2024 14:12:01 -0700 Subject: [PATCH] Implement `outline` properties on Android (#46284) Summary: Pull Request resolved: https://github.com/facebook/react-native/pull/46284 This diff adds: `outline-width`: https://developer.mozilla.org/en-US/docs/Web/CSS/outline-width `outline-color`: https://developer.mozilla.org/en-US/docs/Web/CSS/outline-color `outline-style`: https://developer.mozilla.org/en-US/docs/Web/CSS/outline-style `outline-offset`: https://developer.mozilla.org/en-US/docs/Web/CSS/outline-offset Using `BackgroundStyleApplicator` Changelog: [Android] [Added] - Outline properties `outline-width`, `outline-color`, `outline-style` & `outline-offset` Reviewed By: NickGerleman Differential Revision: D61293868 --- .../View/ReactNativeStyleAttributes.js | 4 + .../NativeComponent/BaseViewConfig.android.js | 5 + .../Libraries/StyleSheet/StyleSheetTypes.d.ts | 4 + .../Libraries/StyleSheet/StyleSheetTypes.js | 4 + .../__snapshots__/public-api-test.js.snap | 4 + .../ReactAndroid/api/ReactAndroid.api | 31 +++ .../uimanager/BackgroundStyleApplicator.kt | 67 +++++++ .../react/uimanager/BaseViewManager.java | 33 ++++ .../com/facebook/react/uimanager/ViewProps.kt | 4 + .../drawable/CompositeBackgroundDrawable.kt | 17 +- .../uimanager/drawable/OutlineDrawable.kt | 182 ++++++++++++++++++ .../react/uimanager/style/OutlineStyle.kt | 26 +++ .../components/view/BaseViewProps.cpp | 37 ++++ .../renderer/components/view/BaseViewProps.h | 6 + .../components/view/ViewShadowNode.cpp | 3 +- .../renderer/components/view/conversions.h | 26 +++ .../renderer/components/view/primitives.h | 2 + .../rn-tester/js/examples/View/ViewExample.js | 122 ++++++++++++ 18 files changed, 572 insertions(+), 5 deletions(-) create mode 100644 packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/drawable/OutlineDrawable.kt create mode 100644 packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/style/OutlineStyle.kt diff --git a/packages/react-native/Libraries/Components/View/ReactNativeStyleAttributes.js b/packages/react-native/Libraries/Components/View/ReactNativeStyleAttributes.js index df1ed4cd5d979c..2bfc2ae19f2926 100644 --- a/packages/react-native/Libraries/Components/View/ReactNativeStyleAttributes.js +++ b/packages/react-native/Libraries/Components/View/ReactNativeStyleAttributes.js @@ -174,6 +174,10 @@ const ReactNativeStyleAttributes: {[string]: AnyAttributeType, ...} = { borderTopStartRadius: true, cursor: true, opacity: true, + outlineColor: colorAttributes, + outlineOffset: true, + outlineStyle: true, + outlineWidth: true, pointerEvents: true, /** diff --git a/packages/react-native/Libraries/NativeComponent/BaseViewConfig.android.js b/packages/react-native/Libraries/NativeComponent/BaseViewConfig.android.js index 70765523f98400..3c73549c1a2a8c 100644 --- a/packages/react-native/Libraries/NativeComponent/BaseViewConfig.android.js +++ b/packages/react-native/Libraries/NativeComponent/BaseViewConfig.android.js @@ -268,6 +268,11 @@ const validAttributesForNonEventProps = { borderLeftWidth: true, borderRightWidth: true, + outlineColor: {process: require('../StyleSheet/processColor').default}, + outlineOffset: true, + outlineStyle: true, + outlineWidth: true, + start: true, end: true, left: true, diff --git a/packages/react-native/Libraries/StyleSheet/StyleSheetTypes.d.ts b/packages/react-native/Libraries/StyleSheet/StyleSheetTypes.d.ts index 62f52a7db03622..9226d25e395fcb 100644 --- a/packages/react-native/Libraries/StyleSheet/StyleSheetTypes.d.ts +++ b/packages/react-native/Libraries/StyleSheet/StyleSheetTypes.d.ts @@ -411,6 +411,10 @@ export interface ViewStyle extends FlexStyle, ShadowStyleIOS, TransformsStyle { borderTopLeftRadius?: AnimatableNumericValue | string | undefined; borderTopRightRadius?: AnimatableNumericValue | string | undefined; borderTopStartRadius?: AnimatableNumericValue | string | undefined; + outlineColor?: ColorValue | undefined; + outlineOffset?: AnimatableNumericValue | undefined; + outlineStyle?: 'solid' | 'dotted' | 'dashed' | undefined; + outlineWidth?: AnimatableNumericValue | undefined; opacity?: AnimatableNumericValue | undefined; /** * Sets the elevation of a view, using Android's underlying diff --git a/packages/react-native/Libraries/StyleSheet/StyleSheetTypes.js b/packages/react-native/Libraries/StyleSheet/StyleSheetTypes.js index 4be9ae684baac5..83b3d12ba76849 100644 --- a/packages/react-native/Libraries/StyleSheet/StyleSheetTypes.js +++ b/packages/react-native/Libraries/StyleSheet/StyleSheetTypes.js @@ -785,6 +785,10 @@ export type ____ViewStyle_InternalCore = $ReadOnly<{ borderStartWidth?: AnimatableNumericValue, borderTopWidth?: AnimatableNumericValue, opacity?: AnimatableNumericValue, + outlineColor?: ____ColorValue_Internal, + outlineOffset?: AnimatableNumericValue, + outlineStyle?: 'solid' | 'dotted' | 'dashed', + outlineWidth?: AnimatableNumericValue, elevation?: number, pointerEvents?: 'auto' | 'none' | 'box-none' | 'box-only', cursor?: CursorValue, diff --git a/packages/react-native/Libraries/__tests__/__snapshots__/public-api-test.js.snap b/packages/react-native/Libraries/__tests__/__snapshots__/public-api-test.js.snap index d2e35fcce8271c..00b85b64cbe8c0 100644 --- a/packages/react-native/Libraries/__tests__/__snapshots__/public-api-test.js.snap +++ b/packages/react-native/Libraries/__tests__/__snapshots__/public-api-test.js.snap @@ -8363,6 +8363,10 @@ export type ____ViewStyle_InternalCore = $ReadOnly<{ borderStartWidth?: AnimatableNumericValue, borderTopWidth?: AnimatableNumericValue, opacity?: AnimatableNumericValue, + outlineColor?: ____ColorValue_Internal, + outlineOffset?: AnimatableNumericValue, + outlineStyle?: \\"solid\\" | \\"dotted\\" | \\"dashed\\", + outlineWidth?: AnimatableNumericValue, elevation?: number, pointerEvents?: \\"auto\\" | \\"none\\" | \\"box-none\\" | \\"box-only\\", cursor?: CursorValue, diff --git a/packages/react-native/ReactAndroid/api/ReactAndroid.api b/packages/react-native/ReactAndroid/api/ReactAndroid.api index c7f223a1353826..539d1e65255415 100644 --- a/packages/react-native/ReactAndroid/api/ReactAndroid.api +++ b/packages/react-native/ReactAndroid/api/ReactAndroid.api @@ -4128,6 +4128,10 @@ public final class com/facebook/react/uimanager/BackgroundStyleApplicator { 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 getOutlineColor (Landroid/view/View;)Ljava/lang/Integer; + public final fun getOutlineOffset (Landroid/view/View;)Ljava/lang/Float; + public final fun getOutlineStyle (Landroid/view/View;)Lcom/facebook/react/uimanager/style/OutlineStyle; + public final fun getOutlineWidth (Landroid/view/View;)Ljava/lang/Float; public static final fun reset (Landroid/view/View;)V public static final fun setBackgroundColor (Landroid/view/View;Ljava/lang/Integer;)V public static final fun setBackgroundImage (Landroid/view/View;Ljava/util/List;)V @@ -4138,6 +4142,10 @@ public final class com/facebook/react/uimanager/BackgroundStyleApplicator { public static final fun setBoxShadow (Landroid/view/View;Lcom/facebook/react/bridge/ReadableArray;)V public static final fun setBoxShadow (Landroid/view/View;Ljava/util/List;)V public static final fun setFeedbackUnderlay (Landroid/view/View;Landroid/graphics/drawable/Drawable;)V + public static final fun setOutlineColor (Landroid/view/View;Ljava/lang/Integer;)V + public static final fun setOutlineOffset (Landroid/view/View;F)V + public static final fun setOutlineStyle (Landroid/view/View;Lcom/facebook/react/uimanager/style/OutlineStyle;)V + public static final fun setOutlineWidth (Landroid/view/View;F)V } public abstract class com/facebook/react/uimanager/BaseViewManager : com/facebook/react/uimanager/ViewManager, android/view/View$OnLayoutChangeListener, com/facebook/react/uimanager/BaseViewManagerInterface { @@ -4173,6 +4181,10 @@ public abstract class com/facebook/react/uimanager/BaseViewManager : com/faceboo public fun setMoveShouldSetResponderCapture (Landroid/view/View;Z)V public fun setNativeId (Landroid/view/View;Ljava/lang/String;)V public fun setOpacity (Landroid/view/View;F)V + public fun setOutlineColor (Landroid/view/View;Ljava/lang/Integer;)V + public fun setOutlineOffset (Landroid/view/View;F)V + public fun setOutlineStyle (Landroid/view/View;Ljava/lang/String;)V + public fun setOutlineWidth (Landroid/view/View;F)V public fun setPointerEnter (Landroid/view/View;Z)V public fun setPointerEnterCapture (Landroid/view/View;Z)V public fun setPointerLeave (Landroid/view/View;Z)V @@ -5645,6 +5657,10 @@ public final class com/facebook/react/uimanager/ViewProps { public static final field ON Ljava/lang/String; public static final field ON_LAYOUT Ljava/lang/String; public static final field OPACITY Ljava/lang/String; + public static final field OUTLINE_COLOR Ljava/lang/String; + public static final field OUTLINE_OFFSET Ljava/lang/String; + public static final field OUTLINE_STYLE Ljava/lang/String; + public static final field OUTLINE_WIDTH Ljava/lang/String; public static final field OVERFLOW Ljava/lang/String; public static final field PADDING Ljava/lang/String; public static final field PADDING_BOTTOM Ljava/lang/String; @@ -6235,6 +6251,21 @@ public final class com/facebook/react/uimanager/style/LogicalEdge$Companion { public final fun fromSpacingType (I)Lcom/facebook/react/uimanager/style/LogicalEdge; } +public final class com/facebook/react/uimanager/style/OutlineStyle : java/lang/Enum { + public static final field Companion Lcom/facebook/react/uimanager/style/OutlineStyle$Companion; + public static final field DASHED Lcom/facebook/react/uimanager/style/OutlineStyle; + public static final field DOTTED Lcom/facebook/react/uimanager/style/OutlineStyle; + public static final field SOLID Lcom/facebook/react/uimanager/style/OutlineStyle; + public static final fun fromString (Ljava/lang/String;)Lcom/facebook/react/uimanager/style/OutlineStyle; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public static fun valueOf (Ljava/lang/String;)Lcom/facebook/react/uimanager/style/OutlineStyle; + public static fun values ()[Lcom/facebook/react/uimanager/style/OutlineStyle; +} + +public final class com/facebook/react/uimanager/style/OutlineStyle$Companion { + public final fun fromString (Ljava/lang/String;)Lcom/facebook/react/uimanager/style/OutlineStyle; +} + public final class com/facebook/react/uimanager/style/Overflow : java/lang/Enum { public static final field Companion Lcom/facebook/react/uimanager/style/Overflow$Companion; public static final field HIDDEN Lcom/facebook/react/uimanager/style/Overflow; 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 index d6368fd67ec303..21bb6cad0a7f7f 100644 --- 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 @@ -25,6 +25,7 @@ import com.facebook.react.uimanager.drawable.CompositeBackgroundDrawable import com.facebook.react.uimanager.drawable.InsetBoxShadowDrawable import com.facebook.react.uimanager.drawable.MIN_INSET_BOX_SHADOW_SDK_VERSION import com.facebook.react.uimanager.drawable.MIN_OUTSET_BOX_SHADOW_SDK_VERSION +import com.facebook.react.uimanager.drawable.OutlineDrawable import com.facebook.react.uimanager.drawable.OutsetBoxShadowDrawable import com.facebook.react.uimanager.style.BackgroundImageLayer import com.facebook.react.uimanager.style.BorderInsets @@ -33,6 +34,7 @@ import com.facebook.react.uimanager.style.BorderRadiusStyle import com.facebook.react.uimanager.style.BorderStyle import com.facebook.react.uimanager.style.BoxShadow import com.facebook.react.uimanager.style.LogicalEdge +import com.facebook.react.uimanager.style.OutlineStyle /** * BackgroundStyleApplicator is responsible for applying backgrounds, borders, and related effects, @@ -123,6 +125,13 @@ public object BackgroundStyleApplicator { } } } + + val outline = compositeBackgroundDrawable.outline + if (outline != null) { + outline.borderRadius = outline.borderRadius ?: BorderRadiusStyle() + outline.borderRadius?.set(corner, radius) + outline.invalidateSelf() + } } @JvmStatic @@ -137,6 +146,42 @@ public object BackgroundStyleApplicator { @JvmStatic public fun getBorderStyle(view: View): BorderStyle? = getCSSBackground(view)?.borderStyle + @JvmStatic + public fun setOutlineColor(view: View, @ColorInt outlineColor: Int?): Unit { + val outline = ensureOutlineDrawable(view) + if (outlineColor != null) { + outline.outlineColor = outlineColor + } + } + + @JvmStatic public fun getOutlineColor(view: View): Int? = getOutlineDrawable(view)?.outlineColor + + @JvmStatic + public fun setOutlineOffset(view: View, outlineOffset: Float): Unit { + val outline = ensureOutlineDrawable(view) + outline.outlineOffset = outlineOffset.dpToPx() + } + + public fun getOutlineOffset(view: View): Float? = getOutlineDrawable(view)?.outlineOffset + + @JvmStatic + public fun setOutlineStyle(view: View, outlineStyle: OutlineStyle?): Unit { + val outline = ensureOutlineDrawable(view) + if (outlineStyle != null) { + outline.outlineStyle = outlineStyle + } + } + + public fun getOutlineStyle(view: View): OutlineStyle? = getOutlineDrawable(view)?.outlineStyle + + @JvmStatic + public fun setOutlineWidth(view: View, width: Float): Unit { + val outline = ensureOutlineDrawable(view) + outline.outlineWidth = width.dpToPx() + } + + public fun getOutlineWidth(view: View): Float? = getOutlineDrawable(view)?.outlineOffset + @JvmStatic public fun setBoxShadow(view: View, shadows: List): Unit { if (ViewUtil.getUIManagerType(view) != UIManagerType.FABRIC) { @@ -260,4 +305,26 @@ public object BackgroundStyleApplicator { private fun getCSSBackground(view: View): CSSBackgroundDrawable? = getCompositeBackgroundDrawable(view)?.cssBackground + + private fun ensureOutlineDrawable(view: View): OutlineDrawable { + val compositeBackgroundDrawable = ensureCompositeBackgroundDrawable(view) + var outline = compositeBackgroundDrawable.outline + if (outline == null) { + outline = + OutlineDrawable( + context = view.context, + borderRadius = ensureCSSBackground(view).borderRadius.copy(), + outlineColor = Color.BLACK, + outlineOffset = 0f, + outlineStyle = OutlineStyle.SOLID, + outlineWidth = 0f, + ) + view.background = compositeBackgroundDrawable.withNewOutline(outline) + } + + return outline + } + + private fun getOutlineDrawable(view: View): OutlineDrawable? = + getCompositeBackgroundDrawable(view)?.outline } diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/BaseViewManager.java b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/BaseViewManager.java index d9a79ccf2b1245..4c9df40644a5bb 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/BaseViewManager.java +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/BaseViewManager.java @@ -27,12 +27,14 @@ import com.facebook.react.bridge.ReadableType; import com.facebook.react.common.MapBuilder; import com.facebook.react.common.ReactConstants; +import com.facebook.react.internal.featureflags.ReactNativeFeatureFlags; import com.facebook.react.uimanager.ReactAccessibilityDelegate.AccessibilityRole; import com.facebook.react.uimanager.ReactAccessibilityDelegate.Role; import com.facebook.react.uimanager.annotations.ReactProp; import com.facebook.react.uimanager.common.UIManagerType; import com.facebook.react.uimanager.common.ViewUtil; import com.facebook.react.uimanager.events.PointerEventHelper; +import com.facebook.react.uimanager.style.OutlineStyle; import com.facebook.react.uimanager.util.ReactFindViewUtil; import java.util.ArrayList; import java.util.HashMap; @@ -780,6 +782,37 @@ public void setBorderTopRightRadius(T view, float borderRadius) { logUnsupportedPropertyWarning(ViewProps.BORDER_TOP_RIGHT_RADIUS); } + @ReactProp(name = ViewProps.OUTLINE_COLOR, customType = "Color") + public void setOutlineColor(T view, @Nullable Integer color) { + if (ReactNativeFeatureFlags.enableBackgroundStyleApplicator()) { + BackgroundStyleApplicator.setOutlineColor(view, color); + } + } + + @ReactProp(name = ViewProps.OUTLINE_OFFSET) + public void setOutlineOffset(T view, float offset) { + if (ReactNativeFeatureFlags.enableBackgroundStyleApplicator()) { + BackgroundStyleApplicator.setOutlineOffset(view, offset); + } + } + + @ReactProp(name = ViewProps.OUTLINE_STYLE) + public void setOutlineStyle(T view, @Nullable String outlineStyle) { + if (ReactNativeFeatureFlags.enableBackgroundStyleApplicator()) { + @Nullable + OutlineStyle parsedOutlineStyle = + outlineStyle == null ? null : OutlineStyle.fromString(outlineStyle); + BackgroundStyleApplicator.setOutlineStyle(view, parsedOutlineStyle); + } + } + + @ReactProp(name = ViewProps.OUTLINE_WIDTH) + public void setOutlineWidth(T view, float width) { + if (ReactNativeFeatureFlags.enableBackgroundStyleApplicator()) { + BackgroundStyleApplicator.setOutlineWidth(view, width); + } + } + private void logUnsupportedPropertyWarning(String propName) { FLog.w(ReactConstants.TAG, "%s doesn't support property '%s'", getName(), propName); } diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/ViewProps.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/ViewProps.kt index c35d9bfc179a72..af124078859f50 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/ViewProps.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/ViewProps.kt @@ -139,6 +139,10 @@ public object ViewProps { public const val BOX_SHADOW: String = "boxShadow" public const val FILTER: String = "filter" public const val MIX_BLEND_MODE: String = "experimental_mixBlendMode" + public const val OUTLINE_COLOR: String = "outlineColor" + public const val OUTLINE_OFFSET: String = "outlineOffset" + public const val OUTLINE_STYLE: String = "outlineStyle" + public const val OUTLINE_WIDTH: String = "outlineWidth" public const val TRANSFORM: String = "transform" public const val TRANSFORM_ORIGIN: String = "transformOrigin" public const val ELEVATION: String = "elevation" 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 index 1da46a7d6b221e..adf57cc7c53f8b 100644 --- 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 @@ -40,6 +40,9 @@ internal class CompositeBackgroundDrawable( /** Inset box-shadows */ public val innerShadows: List = emptyList(), + + /** Outline */ + public val outline: OutlineDrawable? = null, ) : LayerDrawable( listOfNotNull( @@ -50,7 +53,8 @@ internal class CompositeBackgroundDrawable( *outerShadows.asReversed().toTypedArray(), cssBackground, feedbackUnderlay, - *innerShadows.asReversed().toTypedArray()) + *innerShadows.asReversed().toTypedArray(), + outline) .toTypedArray()) { // Holder value for currently set insets @@ -67,7 +71,7 @@ internal class CompositeBackgroundDrawable( cssBackground: CSSBackgroundDrawable? ): CompositeBackgroundDrawable { return CompositeBackgroundDrawable( - originalBackground, outerShadows, cssBackground, feedbackUnderlay, innerShadows) + originalBackground, outerShadows, cssBackground, feedbackUnderlay, innerShadows, outline) } public fun withNewShadows( @@ -75,11 +79,16 @@ internal class CompositeBackgroundDrawable( innerShadows: List ): CompositeBackgroundDrawable { return CompositeBackgroundDrawable( - originalBackground, outerShadows, cssBackground, feedbackUnderlay, innerShadows) + originalBackground, outerShadows, cssBackground, feedbackUnderlay, innerShadows, outline) + } + + public fun withNewOutline(outline: OutlineDrawable): CompositeBackgroundDrawable { + return CompositeBackgroundDrawable( + originalBackground, outerShadows, cssBackground, feedbackUnderlay, innerShadows, outline) } public fun withNewFeedbackUnderlay(newUnderlay: Drawable?): CompositeBackgroundDrawable { return CompositeBackgroundDrawable( - originalBackground, outerShadows, cssBackground, newUnderlay, innerShadows) + originalBackground, outerShadows, cssBackground, newUnderlay, innerShadows, outline) } } diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/drawable/OutlineDrawable.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/drawable/OutlineDrawable.kt new file mode 100644 index 00000000000000..e23562bc539351 --- /dev/null +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/drawable/OutlineDrawable.kt @@ -0,0 +1,182 @@ +/* + * 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.DashPathEffect +import android.graphics.Paint +import android.graphics.Path +import android.graphics.PathEffect +import android.graphics.RectF +import android.graphics.drawable.Drawable +import com.facebook.react.uimanager.PixelUtil.dpToPx +import com.facebook.react.uimanager.style.BorderRadiusStyle +import com.facebook.react.uimanager.style.ComputedBorderRadius +import com.facebook.react.uimanager.style.CornerRadii +import com.facebook.react.uimanager.style.OutlineStyle +import kotlin.math.roundToInt +import kotlin.properties.ObservableProperty +import kotlin.properties.ReadWriteProperty +import kotlin.reflect.KProperty + +/** Draws outline https://drafts.csswg.org/css-ui/#outline */ +internal class OutlineDrawable( + private val context: Context, + borderRadius: BorderRadiusStyle? = null, + outlineColor: Int, + outlineOffset: Float, + outlineStyle: OutlineStyle, + outlineWidth: Float, +) : Drawable() { + /** + * 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 fun invalidatingChange(initialValue: T): ReadWriteProperty = + object : ObservableProperty(initialValue) { + override fun afterChange(property: KProperty<*>, oldValue: T, newValue: T) { + if (oldValue != newValue) { + invalidateSelf() + } + } + } + + public var borderRadius: BorderRadiusStyle? by invalidatingChange(borderRadius) + public var outlineOffset: Float by invalidatingChange(outlineOffset) + public var outlineStyle: OutlineStyle = outlineStyle + set(value) { + if (value != field) { + field = value + outlinePaint.pathEffect = getPathEffect(value, outlineWidth) + invalidateSelf() + } + } + + public var outlineColor: Int = outlineColor + set(value) { + if (value != field) { + field = value + outlinePaint.color = value + invalidateSelf() + } + } + + public var outlineWidth: Float = outlineWidth + set(value) { + if (value != field) { + field = value + outlinePaint.strokeWidth = value + outlinePaint.pathEffect = getPathEffect(outlineStyle, value) + invalidateSelf() + } + } + + private val outlinePaint: Paint = + Paint().apply { + style = Paint.Style.STROKE + color = outlineColor + strokeWidth = outlineWidth + pathEffect = getPathEffect(outlineStyle, outlineWidth) + } + + private var computedBorderRadius: ComputedBorderRadius? = null + private var tempRectForOutline = RectF() + + private val pathForOutline = Path() + + override fun setAlpha(alpha: Int) { + outlinePaint.alpha = (((alpha / 255f) * (Color.alpha(outlineColor) / 255f)) * 255f).roundToInt() + invalidateSelf() + } + + override fun setColorFilter(colorFilter: ColorFilter?) { + outlinePaint.colorFilter = colorFilter + invalidateSelf() + } + + override fun getOpacity(): Int = + ((outlinePaint.alpha / 255f) / (Color.alpha(outlineColor) / 255f) * 255f).roundToInt() + + override fun draw(canvas: Canvas) { + pathForOutline.reset() + + computedBorderRadius = + borderRadius?.resolve( + layoutDirection, + context, + bounds.width().toFloat().dpToPx(), + bounds.height().toFloat().dpToPx()) + + updateOutlineRect() + if (computedBorderRadius != null && computedBorderRadius?.hasRoundedBorders() == true) { + drawRoundedOutline(canvas) + } else { + drawRectangularOutline(canvas) + } + } + + private fun updateOutlineRect() { + tempRectForOutline.set(bounds) + + tempRectForOutline.top -= outlineWidth * 0.5f + outlineOffset - gapBetweenPaths + tempRectForOutline.bottom += outlineWidth * 0.5f + outlineOffset - gapBetweenPaths + tempRectForOutline.left -= outlineWidth * 0.5f + outlineOffset - gapBetweenPaths + tempRectForOutline.right += outlineWidth * 0.5f + outlineOffset - gapBetweenPaths + } + + private fun getPathEffect(style: OutlineStyle, outlineWidth: Float): PathEffect? { + return when (style) { + OutlineStyle.SOLID -> null + OutlineStyle.DASHED -> + DashPathEffect( + floatArrayOf(outlineWidth * 3, outlineWidth * 3, outlineWidth * 3, outlineWidth * 3), + 0f) + OutlineStyle.DOTTED -> + DashPathEffect(floatArrayOf(outlineWidth, outlineWidth, outlineWidth, outlineWidth), 0f) + } + } + + private fun calculateRadius(radius: Float, outlineWidth: Float) = + if (radius != 0f) radius + outlineWidth * 0.5f else 0f + + private fun drawRectangularOutline(canvas: Canvas) { + pathForOutline.addRect(tempRectForOutline, Path.Direction.CW) + + canvas.drawPath(pathForOutline, outlinePaint) + } + + private fun drawRoundedOutline(canvas: Canvas) { + val topLeftRadius = computedBorderRadius?.topLeft?.toPixelFromDIP() ?: CornerRadii(0f, 0f) + val topRightRadius = computedBorderRadius?.topRight?.toPixelFromDIP() ?: CornerRadii(0f, 0f) + val bottomLeftRadius = computedBorderRadius?.bottomLeft?.toPixelFromDIP() ?: CornerRadii(0f, 0f) + val bottomRightRadius = + computedBorderRadius?.bottomRight?.toPixelFromDIP() ?: CornerRadii(0f, 0f) + + pathForOutline.addRoundRect( + tempRectForOutline, + floatArrayOf( + calculateRadius(topLeftRadius.horizontal, outlineWidth), + calculateRadius(topLeftRadius.vertical, outlineWidth), + calculateRadius(topRightRadius.horizontal, outlineWidth), + calculateRadius(topRightRadius.vertical, outlineWidth), + calculateRadius(bottomRightRadius.horizontal, outlineWidth), + calculateRadius(bottomRightRadius.vertical, outlineWidth), + calculateRadius(bottomLeftRadius.horizontal, outlineWidth), + calculateRadius(bottomLeftRadius.vertical, outlineWidth), + ), + Path.Direction.CW) + + canvas.drawPath(pathForOutline, outlinePaint) + } +} diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/style/OutlineStyle.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/style/OutlineStyle.kt new file mode 100644 index 00000000000000..25a974c146ef73 --- /dev/null +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/style/OutlineStyle.kt @@ -0,0 +1,26 @@ +/* + * 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.style + +public enum class OutlineStyle { + SOLID, + DASHED, + DOTTED; + + public companion object { + @JvmStatic + public fun fromString(outlineStyle: String): OutlineStyle? { + return when (outlineStyle.lowercase()) { + "solid" -> SOLID + "dashed" -> DASHED + "dotted" -> DOTTED + else -> null + } + } + } +} diff --git a/packages/react-native/ReactCommon/react/renderer/components/view/BaseViewProps.cpp b/packages/react-native/ReactCommon/react/renderer/components/view/BaseViewProps.cpp index 4047aebc6eeecb..32f5f94593e7ab 100644 --- a/packages/react-native/ReactCommon/react/renderer/components/view/BaseViewProps.cpp +++ b/packages/react-native/ReactCommon/react/renderer/components/view/BaseViewProps.cpp @@ -109,6 +109,39 @@ BaseViewProps::BaseViewProps( "Style", sourceProps.borderStyles, {})), + outlineColor( + CoreFeatures::enablePropIteratorSetter ? sourceProps.outlineColor + : convertRawProp( + context, + rawProps, + "outlineColor", + sourceProps.outlineColor, + {})), + outlineOffset( + CoreFeatures::enablePropIteratorSetter + ? sourceProps.outlineOffset + : convertRawProp( + context, + rawProps, + "outlineOffset", + sourceProps.outlineOffset, + {})), + outlineStyle( + CoreFeatures::enablePropIteratorSetter ? sourceProps.outlineStyle + : convertRawProp( + context, + rawProps, + "outlineStyle", + sourceProps.outlineStyle, + {})), + outlineWidth( + CoreFeatures::enablePropIteratorSetter ? sourceProps.outlineWidth + : convertRawProp( + context, + rawProps, + "outlineWidth", + sourceProps.outlineWidth, + {})), shadowColor( CoreFeatures::enablePropIteratorSetter ? sourceProps.shadowColor : convertRawProp( @@ -346,6 +379,10 @@ void BaseViewProps::setProp( RAW_SET_PROP_SWITCH_CASE_BASIC(removeClippedSubviews); RAW_SET_PROP_SWITCH_CASE_BASIC(experimental_layoutConformance); RAW_SET_PROP_SWITCH_CASE_BASIC(cursor); + RAW_SET_PROP_SWITCH_CASE_BASIC(outlineColor); + RAW_SET_PROP_SWITCH_CASE_BASIC(outlineOffset); + RAW_SET_PROP_SWITCH_CASE_BASIC(outlineStyle); + RAW_SET_PROP_SWITCH_CASE_BASIC(outlineWidth); RAW_SET_PROP_SWITCH_CASE(filter, "filter"); RAW_SET_PROP_SWITCH_CASE(boxShadow, "boxShadow"); // events field diff --git a/packages/react-native/ReactCommon/react/renderer/components/view/BaseViewProps.h b/packages/react-native/ReactCommon/react/renderer/components/view/BaseViewProps.h index edc01556c914e0..0d5f605990d97b 100644 --- a/packages/react-native/ReactCommon/react/renderer/components/view/BaseViewProps.h +++ b/packages/react-native/ReactCommon/react/renderer/components/view/BaseViewProps.h @@ -51,6 +51,12 @@ class BaseViewProps : public YogaStylableProps, public AccessibilityProps { CascadedBorderCurves borderCurves{}; // iOS only? CascadedBorderStyles borderStyles{}; + // Outline + SharedColor outlineColor{}; + Float outlineOffset{}; + OutlineStyle outlineStyle{OutlineStyle::Solid}; + Float outlineWidth{}; + // Shadow SharedColor shadowColor{}; Size shadowOffset{0, -3}; diff --git a/packages/react-native/ReactCommon/react/renderer/components/view/ViewShadowNode.cpp b/packages/react-native/ReactCommon/react/renderer/components/view/ViewShadowNode.cpp index ef13a094524f6e..7e819c28e997fb 100644 --- a/packages/react-native/ReactCommon/react/renderer/components/view/ViewShadowNode.cpp +++ b/packages/react-native/ReactCommon/react/renderer/components/view/ViewShadowNode.cpp @@ -70,7 +70,8 @@ void ViewShadowNode::initialize() noexcept { isColorMeaningful(viewProps.backgroundColor) || hasBorder() || !viewProps.testId.empty() || !viewProps.boxShadow.empty() || !viewProps.backgroundImage.empty() || - HostPlatformViewTraitsInitializer::formsView(viewProps); + HostPlatformViewTraitsInitializer::formsView(viewProps) || + viewProps.outlineWidth > 0; if (formsView) { traits_.set(ShadowNodeTraits::Trait::FormsView); diff --git a/packages/react-native/ReactCommon/react/renderer/components/view/conversions.h b/packages/react-native/ReactCommon/react/renderer/components/view/conversions.h index e91ed7246d30a3..8f0bcbb33ea11d 100644 --- a/packages/react-native/ReactCommon/react/renderer/components/view/conversions.h +++ b/packages/react-native/ReactCommon/react/renderer/components/view/conversions.h @@ -755,6 +755,32 @@ inline void fromRawValue( react_native_expect(false); } +inline void fromRawValue( + const PropsParserContext& context, + const RawValue& value, + OutlineStyle& result) { + result = OutlineStyle::Solid; + react_native_expect(value.hasType()); + if (!value.hasType()) { + return; + } + auto stringValue = (std::string)value; + if (stringValue == "solid") { + result = OutlineStyle::Solid; + return; + } + if (stringValue == "dotted") { + result = OutlineStyle::Dotted; + return; + } + if (stringValue == "dashed") { + result = OutlineStyle::Dashed; + return; + } + LOG(ERROR) << "Could not parse OutlineStyle:" << stringValue; + react_native_expect(false); +} + inline void fromRawValue( const PropsParserContext& context, const RawValue& value, diff --git a/packages/react-native/ReactCommon/react/renderer/components/view/primitives.h b/packages/react-native/ReactCommon/react/renderer/components/view/primitives.h index 42f2cbe2d211ed..feb396a0535584 100644 --- a/packages/react-native/ReactCommon/react/renderer/components/view/primitives.h +++ b/packages/react-native/ReactCommon/react/renderer/components/view/primitives.h @@ -92,6 +92,8 @@ enum class BorderCurve : uint8_t { Circular, Continuous }; enum class BorderStyle : uint8_t { Solid, Dotted, Dashed }; +enum class OutlineStyle : uint8_t { Solid, Dotted, Dashed }; + struct CornerRadii { float vertical{0.0f}; float horizontal{0.0f}; diff --git a/packages/rn-tester/js/examples/View/ViewExample.js b/packages/rn-tester/js/examples/View/ViewExample.js index bddc7866765a0f..e93732f21629e2 100644 --- a/packages/rn-tester/js/examples/View/ViewExample.js +++ b/packages/rn-tester/js/examples/View/ViewExample.js @@ -533,6 +533,122 @@ function BoxShadowExample(): React.Node { ); } +function OutlineExample(): React.Node { + const defaultStyleSize = {width: 50, height: 50}; + + return ( + + + + + + + + + + + ); +} + export default ({ title: 'View', documentationURL: 'https://reactnative.dev/docs/view', @@ -1172,5 +1288,11 @@ export default ({ name: 'box-shadow', render: BoxShadowExample, }, + { + title: 'Outline', + name: 'outline', + platform: 'android', + render: OutlineExample, + }, ], }: RNTesterModule);