Skip to content

Commit 6c45600

Browse files
committed
feat: add Android support for CSS mask properties
1 parent 71bf85b commit 6c45600

15 files changed

Lines changed: 280 additions & 9 deletions

File tree

packages/react-native/ReactAndroid/api/ReactAndroid.api

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3202,8 +3202,10 @@ public abstract interface class com/facebook/react/turbomodule/core/interfaces/T
32023202

32033203
public final class com/facebook/react/uimanager/BackgroundStyleApplicator {
32043204
public static final field INSTANCE Lcom/facebook/react/uimanager/BackgroundStyleApplicator;
3205+
public static final fun beginMaskedDraw (Landroid/graphics/Canvas;Landroid/view/View;)I
32053206
public static final fun clipToPaddingBox (Landroid/view/View;Landroid/graphics/Canvas;)V
32063207
public static final fun clipToPaddingBoxWithAntiAliasing (Landroid/view/View;Landroid/graphics/Canvas;Lkotlin/jvm/functions/Function0;)V
3208+
public static final fun endMaskedDraw (Landroid/graphics/Canvas;Landroid/view/View;I)V
32073209
public static final fun getBackgroundColor (Landroid/view/View;)Ljava/lang/Integer;
32083210
public static final fun getBorderColor (Landroid/view/View;Lcom/facebook/react/uimanager/style/LogicalEdge;)Ljava/lang/Integer;
32093211
public static final fun getBorderRadius (Landroid/view/View;Lcom/facebook/react/uimanager/style/BorderRadiusProp;)Lcom/facebook/react/uimanager/LengthPercentage;
@@ -3223,6 +3225,10 @@ public final class com/facebook/react/uimanager/BackgroundStyleApplicator {
32233225
public static final fun setBoxShadow (Landroid/view/View;Lcom/facebook/react/bridge/ReadableArray;)V
32243226
public static final fun setBoxShadow (Landroid/view/View;Ljava/util/List;)V
32253227
public static final fun setFeedbackUnderlay (Landroid/view/View;Landroid/graphics/drawable/Drawable;)V
3228+
public static final fun setMaskImage (Landroid/view/View;Lcom/facebook/react/bridge/ReadableArray;)V
3229+
public static final fun setMaskPosition (Landroid/view/View;Lcom/facebook/react/bridge/ReadableArray;)V
3230+
public static final fun setMaskRepeat (Landroid/view/View;Lcom/facebook/react/bridge/ReadableArray;)V
3231+
public static final fun setMaskSize (Landroid/view/View;Lcom/facebook/react/bridge/ReadableArray;)V
32263232
public static final fun setOutlineColor (Landroid/view/View;Ljava/lang/Integer;)V
32273233
public static final fun setOutlineOffset (Landroid/view/View;F)V
32283234
public static final fun setOutlineStyle (Landroid/view/View;Lcom/facebook/react/uimanager/style/OutlineStyle;)V
@@ -3260,6 +3266,10 @@ public abstract class com/facebook/react/uimanager/BaseViewManager : com/faceboo
32603266
public fun setElevation (Landroid/view/View;F)V
32613267
public fun setFilter (Landroid/view/View;Lcom/facebook/react/bridge/ReadableArray;)V
32623268
public fun setImportantForAccessibility (Landroid/view/View;Ljava/lang/String;)V
3269+
public fun setMaskImage (Landroid/view/View;Lcom/facebook/react/bridge/ReadableArray;)V
3270+
public fun setMaskPosition (Landroid/view/View;Lcom/facebook/react/bridge/ReadableArray;)V
3271+
public fun setMaskRepeat (Landroid/view/View;Lcom/facebook/react/bridge/ReadableArray;)V
3272+
public fun setMaskSize (Landroid/view/View;Lcom/facebook/react/bridge/ReadableArray;)V
32633273
public fun setMixBlendMode (Landroid/view/View;Ljava/lang/String;)V
32643274
public fun setMoveShouldSetResponder (Landroid/view/View;Z)V
32653275
public fun setMoveShouldSetResponderCapture (Landroid/view/View;Z)V
@@ -4592,6 +4602,10 @@ public final class com/facebook/react/uimanager/ViewProps {
45924602
public static final field MARGIN_START Ljava/lang/String;
45934603
public static final field MARGIN_TOP Ljava/lang/String;
45944604
public static final field MARGIN_VERTICAL Ljava/lang/String;
4605+
public static final field MASK_IMAGE Ljava/lang/String;
4606+
public static final field MASK_POSITION Ljava/lang/String;
4607+
public static final field MASK_REPEAT Ljava/lang/String;
4608+
public static final field MASK_SIZE Ljava/lang/String;
45954609
public static final field MAX_FONT_SIZE_MULTIPLIER Ljava/lang/String;
45964610
public static final field MAX_HEIGHT Ljava/lang/String;
45974611
public static final field MAX_WIDTH Ljava/lang/String;

packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/BackgroundStyleApplicator.kt

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,14 @@ import com.facebook.react.uimanager.style.OutlineStyle
5656
@OptIn(UnstableReactNativeAPI::class)
5757
public object BackgroundStyleApplicator {
5858

59+
private const val NO_MASK_SAVE_COUNT: Int = -1
60+
61+
private val maskPaint: Paint =
62+
Paint(Paint.ANTI_ALIAS_FLAG).apply {
63+
xfermode = PorterDuffXfermode(PorterDuff.Mode.DST_IN)
64+
isFilterBitmap = true
65+
}
66+
5967
/**
6068
* Sets the background color of the view.
6169
*
@@ -107,6 +115,137 @@ public object BackgroundStyleApplicator {
107115
ensureBackgroundImageDrawable(view).backgroundRepeat = backgroundRepeats
108116
}
109117

118+
/**
119+
* Sets the `mask-image` layers for the view.
120+
*
121+
* `mask-image` accepts the same values as `background-image`, but instead of being painted
122+
* underneath the view it is composited over the view's own rendering with
123+
* [PorterDuff.Mode.DST_IN]: the view is visible where the mask is opaque and hidden where it is
124+
* transparent. Views that support masking call [beginMaskedDraw] and [endMaskedDraw] around their
125+
* drawing.
126+
*
127+
* @param view The view to apply the mask to
128+
* @param maskImage The processed `mask-image` value, or null to remove the mask
129+
* @see <a href="https://www.w3.org/TR/css-masking-1/#the-mask-image">CSS Masking Level 1</a>
130+
*/
131+
@JvmStatic
132+
public fun setMaskImage(view: View, maskImage: ReadableArray?): Unit {
133+
if (maskImage == null || maskImage.size() == 0) {
134+
val composite = getCompositeBackgroundDrawable(view) ?: return
135+
if (composite.mask != null) {
136+
view.background = composite.withNewMask(null)
137+
}
138+
return
139+
}
140+
141+
val layers = ArrayList<BackgroundImageLayer>(maskImage.size())
142+
for (i in 0 until maskImage.size()) {
143+
BackgroundImageLayer.parse(maskImage.getMap(i), view.context)?.let { layers.add(it) }
144+
}
145+
ensureMaskDrawable(view).backgroundImageLayers = layers.ifEmpty { null }
146+
}
147+
148+
/**
149+
* Sets the `mask-size` value for the view. Has no effect until a `mask-image` is set.
150+
*
151+
* @param view The view to apply the mask size to
152+
* @param maskSize The processed `mask-size` value, or null to reset to `auto`
153+
*/
154+
@JvmStatic
155+
public fun setMaskSize(view: View, maskSize: ReadableArray?): Unit {
156+
val sizes =
157+
maskSize?.let { array ->
158+
val parsed = ArrayList<BackgroundSize>(array.size())
159+
for (i in 0 until array.size()) {
160+
BackgroundSize.parse(array.getDynamic(i))?.let { parsed.add(it) }
161+
}
162+
parsed.ifEmpty { null }
163+
}
164+
ensureMaskDrawable(view).backgroundSize = sizes
165+
}
166+
167+
/**
168+
* Sets the `mask-position` value for the view. Has no effect until a `mask-image` is set.
169+
*
170+
* @param view The view to apply the mask position to
171+
* @param maskPosition The processed `mask-position` value, or null to reset to `0% 0%`
172+
*/
173+
@JvmStatic
174+
public fun setMaskPosition(view: View, maskPosition: ReadableArray?): Unit {
175+
val positions =
176+
maskPosition?.let { array ->
177+
val parsed = ArrayList<BackgroundPosition>(array.size())
178+
for (i in 0 until array.size()) {
179+
BackgroundPosition.parse(array.getMap(i))?.let { parsed.add(it) }
180+
}
181+
parsed.ifEmpty { null }
182+
}
183+
ensureMaskDrawable(view).backgroundPosition = positions
184+
}
185+
186+
/**
187+
* Sets the `mask-repeat` value for the view. Has no effect until a `mask-image` is set.
188+
*
189+
* @param view The view to apply the mask repeat to
190+
* @param maskRepeat The processed `mask-repeat` value, or null to reset to `repeat`
191+
*/
192+
@JvmStatic
193+
public fun setMaskRepeat(view: View, maskRepeat: ReadableArray?): Unit {
194+
val repeats =
195+
maskRepeat?.let { array ->
196+
val parsed = ArrayList<BackgroundRepeat>(array.size())
197+
for (i in 0 until array.size()) {
198+
BackgroundRepeat.parse(array.getMap(i))?.let { parsed.add(it) }
199+
}
200+
parsed.ifEmpty { null }
201+
}
202+
ensureMaskDrawable(view).backgroundRepeat = repeats
203+
}
204+
205+
/**
206+
* Starts an offscreen layer that the view's `mask-image` will later be composited into.
207+
*
208+
* Must be paired with [endMaskedDraw]:
209+
* ```
210+
* override fun draw(canvas: Canvas) {
211+
* val saveCount = BackgroundStyleApplicator.beginMaskedDraw(canvas, this)
212+
* super.draw(canvas)
213+
* BackgroundStyleApplicator.endMaskedDraw(canvas, this, saveCount)
214+
* }
215+
* ```
216+
*
217+
* @return the canvas save count to hand back to [endMaskedDraw], or -1 if the view has no mask
218+
*/
219+
@JvmStatic
220+
public fun beginMaskedDraw(canvas: Canvas, view: View): Int {
221+
val mask = getMaskDrawable(view) ?: return NO_MASK_SAVE_COUNT
222+
if (view.width <= 0 || view.height <= 0) {
223+
return NO_MASK_SAVE_COUNT
224+
}
225+
mask.setBounds(0, 0, view.width, view.height)
226+
return canvas.saveLayer(0f, 0f, view.width.toFloat(), view.height.toFloat(), null)
227+
}
228+
229+
/**
230+
* Paints the view's `mask-image` over the layer started by [beginMaskedDraw] and restores the
231+
* canvas. A no-op when [saveCount] is the sentinel returned for an unmasked view.
232+
*/
233+
@JvmStatic
234+
public fun endMaskedDraw(canvas: Canvas, view: View, saveCount: Int): Unit {
235+
if (saveCount == NO_MASK_SAVE_COUNT) {
236+
return
237+
}
238+
// Painting the mask into a nested layer applies DST_IN when that layer is restored, which
239+
// keeps the content drawn since `beginMaskedDraw` only where the mask is opaque.
240+
getMaskDrawable(view)?.let { mask ->
241+
val maskSaveCount =
242+
canvas.saveLayer(0f, 0f, view.width.toFloat(), view.height.toFloat(), maskPaint)
243+
mask.draw(canvas)
244+
canvas.restoreToCount(maskSaveCount)
245+
}
246+
canvas.restoreToCount(saveCount)
247+
}
248+
110249
/**
111250
* Gets the background color of the view.
112251
*
@@ -135,10 +274,12 @@ public object BackgroundStyleApplicator {
135274
ensureBorderDrawable(view).setBorderWidth(edge.toSpacingType(), width?.dpToPx() ?: Float.NaN)
136275
composite.background?.borderInsets = composite.borderInsets
137276
composite.backgroundImage?.borderInsets = composite.borderInsets
277+
composite.mask?.borderInsets = composite.borderInsets
138278
composite.border?.borderInsets = composite.borderInsets
139279

140280
composite.background?.invalidateSelf()
141281
composite.backgroundImage?.invalidateSelf()
282+
composite.mask?.invalidateSelf()
142283
composite.border?.invalidateSelf()
143284

144285
composite.borderInsets = composite.borderInsets ?: BorderInsets()
@@ -217,10 +358,12 @@ public object BackgroundStyleApplicator {
217358
compositeBackgroundDrawable.background?.borderRadius = compositeBackgroundDrawable.borderRadius
218359
compositeBackgroundDrawable.backgroundImage?.borderRadius =
219360
compositeBackgroundDrawable.borderRadius
361+
compositeBackgroundDrawable.mask?.borderRadius = compositeBackgroundDrawable.borderRadius
220362
compositeBackgroundDrawable.border?.borderRadius = compositeBackgroundDrawable.borderRadius
221363

222364
compositeBackgroundDrawable.background?.invalidateSelf()
223365
compositeBackgroundDrawable.backgroundImage?.invalidateSelf()
366+
compositeBackgroundDrawable.mask?.invalidateSelf()
224367
compositeBackgroundDrawable.border?.invalidateSelf()
225368

226369
if (Build.VERSION.SDK_INT >= MIN_OUTSET_BOX_SHADOW_SDK_VERSION) {
@@ -694,6 +837,27 @@ public object BackgroundStyleApplicator {
694837
private fun getBackgroundImage(view: View): BackgroundImageDrawable? =
695838
getCompositeBackgroundDrawable(view)?.backgroundImage
696839

840+
private fun ensureMaskDrawable(view: View): BackgroundImageDrawable {
841+
val composite = ensureCompositeBackgroundDrawable(view)
842+
composite.mask?.let {
843+
return it
844+
}
845+
846+
val mask =
847+
BackgroundImageDrawable(view.context, composite.borderRadius, composite.borderInsets)
848+
view.background = composite.withNewMask(mask)
849+
return mask
850+
}
851+
852+
/**
853+
* The view's mask drawable, but only once it actually has something to paint. `mask-size` and
854+
* friends can arrive before `mask-image`, and an empty mask must not hide the view.
855+
*/
856+
private fun getMaskDrawable(view: View): BackgroundImageDrawable? =
857+
getCompositeBackgroundDrawable(view)?.mask?.takeUnless {
858+
it.backgroundImageLayers.isNullOrEmpty()
859+
}
860+
697861
private fun getBorder(view: View): BorderDrawable? = getCompositeBackgroundDrawable(view)?.border
698862

699863
private fun ensureBorderDrawable(view: View): BorderDrawable {

packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/BaseViewManager.java

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -873,6 +873,26 @@ public void setBoxShadow(T view, @Nullable ReadableArray shadows) {
873873
BackgroundStyleApplicator.setBoxShadow(view, shadows);
874874
}
875875

876+
@ReactProp(name = ViewProps.MASK_IMAGE, customType = "BackgroundImage")
877+
public void setMaskImage(T view, @Nullable ReadableArray images) {
878+
BackgroundStyleApplicator.setMaskImage(view, images);
879+
}
880+
881+
@ReactProp(name = ViewProps.MASK_SIZE, customType = "BackgroundSize")
882+
public void setMaskSize(T view, @Nullable ReadableArray sizes) {
883+
BackgroundStyleApplicator.setMaskSize(view, sizes);
884+
}
885+
886+
@ReactProp(name = ViewProps.MASK_POSITION, customType = "BackgroundPosition")
887+
public void setMaskPosition(T view, @Nullable ReadableArray positions) {
888+
BackgroundStyleApplicator.setMaskPosition(view, positions);
889+
}
890+
891+
@ReactProp(name = ViewProps.MASK_REPEAT, customType = "BackgroundRepeat")
892+
public void setMaskRepeat(T view, @Nullable ReadableArray repeats) {
893+
BackgroundStyleApplicator.setMaskRepeat(view, repeats);
894+
}
895+
876896
private void logUnsupportedPropertyWarning(String propName) {
877897
FLog.w(ReactConstants.TAG, "%s doesn't support property '%s'", getName(), propName);
878898
}

packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/BaseViewManagerDelegate.kt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,11 @@ public abstract class BaseViewManagerDelegate<
8888

8989
ViewProps.MIX_BLEND_MODE -> mViewManager.setMixBlendMode(view, value as String?)
9090

91+
ViewProps.MASK_IMAGE -> mViewManager.setMaskImage(view, value as ReadableArray?)
92+
ViewProps.MASK_SIZE -> mViewManager.setMaskSize(view, value as ReadableArray?)
93+
ViewProps.MASK_POSITION -> mViewManager.setMaskPosition(view, value as ReadableArray?)
94+
ViewProps.MASK_REPEAT -> mViewManager.setMaskRepeat(view, value as ReadableArray?)
95+
9196
ViewProps.SHADOW_COLOR ->
9297
mViewManager.setShadowColor(view, ColorPropConverter.getColor(value, view.context, 0))
9398

packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/ViewProps.kt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,10 @@ public object ViewProps {
8484
public const val BACKGROUND_REPEAT: String = "backgroundRepeat"
8585
// Backwards-compatible alias for the original experimental_ prefixed prop name.
8686
public const val EXPERIMENTAL_BACKGROUND_REPEAT: String = "experimental_backgroundRepeat"
87+
public const val MASK_IMAGE: String = "maskImage"
88+
public const val MASK_SIZE: String = "maskSize"
89+
public const val MASK_POSITION: String = "maskPosition"
90+
public const val MASK_REPEAT: String = "maskRepeat"
8791
public const val FOREGROUND_COLOR: String = "foregroundColor"
8892
public const val COLOR: String = "color"
8993
public const val FONT_SIZE: String = "fontSize"

packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/drawable/CompositeBackgroundDrawable.kt

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,13 @@ internal class CompositeBackgroundDrawable(
5858

5959
// Holder value for currently set border radius
6060
var borderRadius: BorderRadiusStyle? = null,
61+
62+
/**
63+
* `mask-image` rendering layer. Unlike the layers above it is not part of the [LayerDrawable]
64+
* stack: it is painted separately by [BackgroundStyleApplicator.beginMaskedDraw] to composite
65+
* the view's own rendering, not to draw underneath it.
66+
*/
67+
val mask: BackgroundImageDrawable? = null,
6168
) :
6269
LayerDrawable(
6370
createLayersArray(
@@ -77,6 +84,12 @@ internal class CompositeBackgroundDrawable(
7784
// previous ones. E.g. an EditText style may set padding on a TextInput, but we don't want to
7885
// constrain background color to the area inside of the padding.
7986
setPaddingMode(LayerDrawable.PADDING_MODE_STACK)
87+
88+
// The mask is not one of our layers, so LayerDrawable does not adopt it. Routing its
89+
// invalidations through us forwards them to the view, which lets asynchronously loaded
90+
// `url()` masks repaint once they arrive. Re-parented on every copy so that the callback
91+
// always points at the drawable the view actually holds.
92+
mask?.callback = this
8093
}
8194

8295
fun withNewBackgroundImage(
@@ -94,6 +107,7 @@ internal class CompositeBackgroundDrawable(
94107
outline,
95108
borderInsets,
96109
borderRadius,
110+
mask,
97111
)
98112
}
99113

@@ -110,6 +124,7 @@ internal class CompositeBackgroundDrawable(
110124
outline,
111125
borderInsets,
112126
borderRadius,
127+
mask,
113128
)
114129
}
115130

@@ -129,6 +144,7 @@ internal class CompositeBackgroundDrawable(
129144
outline,
130145
borderInsets,
131146
borderRadius,
147+
mask,
132148
)
133149
}
134150

@@ -145,6 +161,7 @@ internal class CompositeBackgroundDrawable(
145161
outline,
146162
borderInsets,
147163
borderRadius,
164+
mask,
148165
)
149166
}
150167

@@ -161,6 +178,24 @@ internal class CompositeBackgroundDrawable(
161178
outline,
162179
borderInsets,
163180
borderRadius,
181+
mask,
182+
)
183+
}
184+
185+
fun withNewMask(mask: BackgroundImageDrawable?): CompositeBackgroundDrawable {
186+
return CompositeBackgroundDrawable(
187+
context,
188+
originalBackground,
189+
outerShadows,
190+
background,
191+
backgroundImage,
192+
border,
193+
feedbackUnderlay,
194+
innerShadows,
195+
outline,
196+
borderInsets,
197+
borderRadius,
198+
mask,
164199
)
165200
}
166201

@@ -177,6 +212,7 @@ internal class CompositeBackgroundDrawable(
177212
outline,
178213
borderInsets,
179214
borderRadius,
215+
mask,
180216
)
181217
}
182218

0 commit comments

Comments
 (0)