Skip to content

Commit ae2459e

Browse files
committed
feat: add JS support for CSS mask properties
1 parent c22dab4 commit ae2459e

8 files changed

Lines changed: 251 additions & 80 deletions

File tree

packages/react-native/Libraries/Components/View/ReactNativeStyleAttributes.js

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,15 @@ export const backgroundRepeatAttribute: AnyAttributeType = nativeCSSParsing
6060
? true
6161
: {process: processBackgroundRepeat};
6262

63+
// The `mask-*` longhands accept the same values as their `background-*`
64+
// counterparts, so they reuse the same processing.
65+
// https://www.w3.org/TR/css-masking-1/#the-mask-image
66+
export const maskImageAttribute: AnyAttributeType = backgroundImageAttribute;
67+
export const maskSizeAttribute: AnyAttributeType = backgroundSizeAttribute;
68+
export const maskPositionAttribute: AnyAttributeType =
69+
backgroundPositionAttribute;
70+
export const maskRepeatAttribute: AnyAttributeType = backgroundRepeatAttribute;
71+
6372
export const transformAttribute: AnyAttributeType = nativeCSSParsing
6473
? true
6574
: {process: processTransform};
@@ -223,6 +232,14 @@ const ReactNativeStyleAttributes: {[string]: AnyAttributeType, ...} = {
223232
/** @deprecated Use `backgroundRepeat` instead. */
224233
experimental_backgroundRepeat: backgroundRepeatAttribute,
225234

235+
/**
236+
* Mask
237+
*/
238+
maskImage: maskImageAttribute,
239+
maskSize: maskSizeAttribute,
240+
maskPosition: maskPositionAttribute,
241+
maskRepeat: maskRepeatAttribute,
242+
226243
/**
227244
* View
228245
*/
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
/**
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*
7+
* @flow strict-local
8+
* @format
9+
*/
10+
11+
import '@react-native/fantom/src/setUpDefaultReactNativeEnvironment';
12+
13+
import type {ViewStyleProp} from 'react-native/Libraries/StyleSheet/StyleSheet';
14+
15+
import * as Fantom from '@react-native/fantom';
16+
import * as React from 'react';
17+
import {View} from 'react-native';
18+
19+
// `mask-image` reuses the `background-image` value syntax, so these tests check
20+
// that the style plumbing reaches the `maskImage` prop of the mounted view for
21+
// each accepted form. `collapsable={false}` is not needed here: a non-empty
22+
// `mask-image` makes the view form a stacking context on its own.
23+
function mountedMaskImage(style: ViewStyleProp): string {
24+
const root = Fantom.createRoot();
25+
Fantom.runTask(() => {
26+
root.render(<View style={style} />);
27+
});
28+
return root.getRenderedOutput({props: ['maskImage']}).toJSONObject().props
29+
.maskImage;
30+
}
31+
32+
describe('<View> mask-image', () => {
33+
it('accepts a linear-gradient() shorthand', () => {
34+
const maskImage = mountedMaskImage({
35+
maskImage: 'linear-gradient(#e66465, #9198e5)',
36+
});
37+
expect(maskImage).toContain('linear-gradient');
38+
expect(maskImage).toContain('rgba(230, 100, 101, 1)');
39+
});
40+
41+
it('accepts a radial-gradient() shorthand', () => {
42+
expect(
43+
mountedMaskImage({maskImage: 'radial-gradient(circle, black, white)'}),
44+
).toContain('radial-gradient');
45+
});
46+
47+
it('accepts a url() shorthand', () => {
48+
expect(
49+
mountedMaskImage({maskImage: 'url(https://example.com/mask.png)'}),
50+
).toContain('https://example.com/mask.png');
51+
});
52+
53+
it('accepts an array of layers', () => {
54+
const maskImage = mountedMaskImage({
55+
maskImage: [
56+
{type: 'url', uri: 'https://example.com/mask.png'},
57+
{
58+
type: 'linear-gradient',
59+
direction: 'to right',
60+
colorStops: [{color: 'black'}, {color: 'white'}],
61+
},
62+
],
63+
});
64+
expect(maskImage).toContain('https://example.com/mask.png');
65+
expect(maskImage).toContain('linear-gradient');
66+
});
67+
68+
it('is unset when no mask-image is given', () => {
69+
const root = Fantom.createRoot();
70+
Fantom.runTask(() => {
71+
root.render(<View collapsable={false} />);
72+
});
73+
expect(
74+
root.getRenderedOutput({props: ['maskImage']}).toJSONObject().props
75+
.maskImage,
76+
).toBeUndefined();
77+
});
78+
});

packages/react-native/Libraries/NativeComponent/BaseViewConfig.android.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,10 @@ import {
1919
boxShadowAttribute,
2020
colorAttribute,
2121
filterAttribute,
22+
maskImageAttribute,
23+
maskPositionAttribute,
24+
maskRepeatAttribute,
25+
maskSizeAttribute,
2226
} from '../Components/View/ReactNativeStyleAttributes';
2327
import {DynamicallyInjectedByGestureHandler} from './ViewConfigIgnore';
2428

@@ -219,6 +223,10 @@ const validAttributesForNonEventProps = {
219223
experimental_backgroundPosition: backgroundPositionAttribute,
220224
backgroundRepeat: backgroundRepeatAttribute,
221225
experimental_backgroundRepeat: backgroundRepeatAttribute,
226+
maskImage: maskImageAttribute,
227+
maskSize: maskSizeAttribute,
228+
maskPosition: maskPositionAttribute,
229+
maskRepeat: maskRepeatAttribute,
222230
boxShadow: boxShadowAttribute,
223231
filter: filterAttribute,
224232
mixBlendMode: true,

packages/react-native/Libraries/StyleSheet/StyleSheetTypes.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -908,6 +908,10 @@ export type ____ViewStyle_InternalBase = Readonly<{
908908
ReadonlyArray<BackgroundPositionValue> | string,
909909
backgroundRepeat?: ReadonlyArray<BackgroundRepeatValue> | string,
910910
experimental_backgroundRepeat?: ReadonlyArray<BackgroundRepeatValue> | string,
911+
maskImage?: ReadonlyArray<BackgroundImageValue> | string,
912+
maskSize?: ReadonlyArray<BackgroundSizeValue> | string,
913+
maskPosition?: ReadonlyArray<BackgroundPositionValue> | string,
914+
maskRepeat?: ReadonlyArray<BackgroundRepeatValue> | string,
911915
isolation?: 'auto' | 'isolate',
912916
}>;
913917

packages/react-native/ReactCommon/react/renderer/components/view/BaseViewProps.cpp

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,42 @@ BaseViewProps::BaseViewProps(
247247
sourceProps.backgroundRepeat,
248248
{}),
249249
{})),
250+
maskImage(
251+
ReactNativeFeatureFlags::enableCppPropsIteratorSetter()
252+
? sourceProps.maskImage
253+
: convertRawProp(
254+
context,
255+
rawProps,
256+
"maskImage",
257+
sourceProps.maskImage,
258+
{})),
259+
maskSize(
260+
ReactNativeFeatureFlags::enableCppPropsIteratorSetter()
261+
? sourceProps.maskSize
262+
: convertRawProp(
263+
context,
264+
rawProps,
265+
"maskSize",
266+
sourceProps.maskSize,
267+
{})),
268+
maskPosition(
269+
ReactNativeFeatureFlags::enableCppPropsIteratorSetter()
270+
? sourceProps.maskPosition
271+
: convertRawProp(
272+
context,
273+
rawProps,
274+
"maskPosition",
275+
sourceProps.maskPosition,
276+
{})),
277+
maskRepeat(
278+
ReactNativeFeatureFlags::enableCppPropsIteratorSetter()
279+
? sourceProps.maskRepeat
280+
: convertRawProp(
281+
context,
282+
rawProps,
283+
"maskRepeat",
284+
sourceProps.maskRepeat,
285+
{})),
250286
mixBlendMode(convertRawProp(
251287
context,
252288
rawProps,
@@ -384,6 +420,10 @@ void BaseViewProps::setProp(
384420
RAW_SET_PROP_SWITCH_CASE_BASIC(filter);
385421
RAW_SET_PROP_SWITCH_CASE_BASIC(boxShadow);
386422
RAW_SET_PROP_SWITCH_CASE_BASIC(mixBlendMode);
423+
RAW_SET_PROP_SWITCH_CASE_BASIC(maskImage);
424+
RAW_SET_PROP_SWITCH_CASE_BASIC(maskSize);
425+
RAW_SET_PROP_SWITCH_CASE_BASIC(maskPosition);
426+
RAW_SET_PROP_SWITCH_CASE_BASIC(maskRepeat);
387427
// events field
388428
VIEW_EVENT_CASE(PointerEnter);
389429
VIEW_EVENT_CASE(PointerEnterCapture);
@@ -609,6 +649,8 @@ SharedDebugStringConvertibleList BaseViewProps::getDebugProps() const {
609649
"backgroundImage",
610650
backgroundImage,
611651
defaultBaseViewProps.backgroundImage),
652+
debugStringConvertibleItem(
653+
"maskImage", maskImage, defaultBaseViewProps.maskImage),
612654
};
613655
}
614656
#endif

packages/react-native/ReactCommon/react/renderer/components/view/BaseViewProps.h

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,12 @@ class BaseViewProps : public YogaStylableProps, public AccessibilityProps {
8686
// Background Repeat
8787
std::vector<BackgroundRepeat> backgroundRepeat{};
8888

89+
// Mask
90+
std::vector<BackgroundImage> maskImage{};
91+
std::vector<BackgroundSize> maskSize{};
92+
std::vector<BackgroundPosition> maskPosition{};
93+
std::vector<BackgroundRepeat> maskRepeat{};
94+
8995
// MixBlendMode
9096
BlendMode mixBlendMode{BlendMode::Normal};
9197

packages/react-native/ReactCommon/react/renderer/components/view/ViewShadowNode.cpp

Lines changed: 21 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ void ViewShadowNode::initialize() noexcept {
6161
!viewProps.filter.empty() ||
6262
viewProps.mixBlendMode != BlendMode::Normal ||
6363
viewProps.isolation == Isolation::Isolate ||
64+
!viewProps.maskImage.empty() ||
6465
HostPlatformViewTraitsInitializer::formsStackingContext(viewProps) ||
6566
!viewProps.accessibilityOrder.empty();
6667

@@ -105,24 +106,30 @@ void ViewShadowNode::updateStateIfNeeded() {
105106
ensureUnsealed();
106107

107108
const auto& viewProps = static_cast<const ViewProps&>(*props_);
108-
const auto& backgroundImages = viewProps.backgroundImage;
109109

110110
std::vector<BackgroundImageURLRequest> newRequests;
111-
for (const auto& bgImage : backgroundImages) {
112-
if (std::holds_alternative<URLBackgroundImage>(bgImage)) {
113-
const auto& urlBgImage = std::get<URLBackgroundImage>(bgImage);
114-
if (!urlBgImage.uri.empty()) {
115-
BackgroundImageURLRequest request;
116-
request.imageSource.uri = urlBgImage.uri;
117-
if (urlBgImage.uri.find("__packager_asset") != std::string::npos) {
118-
request.imageSource.type = ImageSource::Type::Local;
119-
} else {
120-
request.imageSource.type = ImageSource::Type::Remote;
121-
}
122-
newRequests.push_back(std::move(request));
111+
auto collectRequests = [&](const std::vector<BackgroundImage>& images) {
112+
for (const auto& image : images) {
113+
if (!std::holds_alternative<URLBackgroundImage>(image)) {
114+
continue;
123115
}
116+
const auto& urlImage = std::get<URLBackgroundImage>(image);
117+
if (urlImage.uri.empty()) {
118+
continue;
119+
}
120+
BackgroundImageURLRequest request;
121+
request.imageSource.uri = urlImage.uri;
122+
request.imageSource.type =
123+
urlImage.uri.find("__packager_asset") != std::string::npos
124+
? ImageSource::Type::Local
125+
: ImageSource::Type::Remote;
126+
newRequests.push_back(std::move(request));
124127
}
125-
}
128+
};
129+
130+
// `background-image` and `mask-image` share the same image loading pipeline.
131+
collectRequests(viewProps.backgroundImage);
132+
collectRequests(viewProps.maskImage);
126133

127134
if (newRequests.empty()) {
128135
return;

0 commit comments

Comments
 (0)