Skip to content

Commit 71bf85b

Browse files
committed
feat: add iOS support for CSS mask properties
1 parent ae2459e commit 71bf85b

3 files changed

Lines changed: 507 additions & 3 deletions

File tree

packages/react-native/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm

Lines changed: 114 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,18 @@ static BOOL RCTViewIsInteractiveAccessibilityElement(UIView *view, const ViewPro
104104
}
105105
#endif
106106

107+
// Core Animation supports a single mask per layer, so the border-radius
108+
// clipping shape is nested as the mask of the `mask-image` layer. Nested masks
109+
// multiply, which is exactly the intersection we want.
110+
static CALayer *RCTIntersectMaskLayers(CALayer *maskImageLayer, CALayer *clippingLayer)
111+
{
112+
if (maskImageLayer == nil) {
113+
return clippingLayer;
114+
}
115+
maskImageLayer.mask = clippingLayer;
116+
return maskImageLayer;
117+
}
118+
107119
@implementation RCTViewComponentView {
108120
UIColor *_backgroundColor;
109121
CALayer *_backgroundColorLayer;
@@ -662,6 +674,12 @@ - (void)updateProps:(const Props::Shared &)props oldProps:(const Props::Shared &
662674
needsInvalidateLayer = YES;
663675
}
664676

677+
// `mask`
678+
if (oldViewProps.maskImage != newViewProps.maskImage || oldViewProps.maskSize != newViewProps.maskSize ||
679+
oldViewProps.maskPosition != newViewProps.maskPosition || oldViewProps.maskRepeat != newViewProps.maskRepeat) {
680+
needsInvalidateLayer = YES;
681+
}
682+
665683
// `boxShadow`
666684
if (oldViewProps.boxShadow != newViewProps.boxShadow) {
667685
needsInvalidateLayer = YES;
@@ -1396,7 +1414,8 @@ - (void)invalidateLayer
13961414
}
13971415

13981416
// clipping
1399-
self.currentContainerView.layer.mask = nil;
1417+
CALayer *maskImageLayer = [self createMaskImageLayer];
1418+
self.currentContainerView.layer.mask = maskImageLayer;
14001419
if (self.currentContainerView.clipsToBounds) {
14011420
BOOL clipToPaddingBox = ReactNativeFeatureFlags::enableIOSViewClipToPaddingBox();
14021421
if (!clipToPaddingBox) {
@@ -1407,7 +1426,7 @@ - (void)invalidateLayer
14071426
[self createMaskLayer:self.bounds
14081427
cornerInsets:RCTGetCornerInsets(
14091428
RCTCornerRadiiFromBorderRadii(borderMetrics.borderRadii), UIEdgeInsetsZero)];
1410-
self.currentContainerView.layer.mask = maskLayer;
1429+
self.currentContainerView.layer.mask = RCTIntersectMaskLayers(maskImageLayer, maskLayer);
14111430
}
14121431

14131432
for (UIView *subview in self.currentContainerView.subviews) {
@@ -1428,7 +1447,7 @@ - (void)invalidateLayer
14281447
cornerInsets:RCTGetCornerInsets(
14291448
RCTCornerRadiiFromBorderRadii(borderMetrics.borderRadii),
14301449
RCTUIEdgeInsetsFromEdgeInsets(borderMetrics.borderWidths))];
1431-
self.currentContainerView.layer.mask = maskLayer;
1450+
self.currentContainerView.layer.mask = RCTIntersectMaskLayers(maskImageLayer, maskLayer);
14321451
} else {
14331452
self.currentContainerView.layer.cornerRadius = borderMetrics.borderRadii.topLeft.horizontal;
14341453
}
@@ -1464,6 +1483,98 @@ - (CAShapeLayer *)createMaskLayer:(CGRect)bounds cornerInsets:(RCTCornerInsets)c
14641483
return maskLayer;
14651484
}
14661485

1486+
// Builds the layer used as `self.currentContainerView.layer.mask` for the
1487+
// `mask-image` style, or nil when no mask is set. Each mask layer is painted
1488+
// into a shared container so that multiple mask images composite together,
1489+
// matching how `background-image` stacks its layers.
1490+
// https://www.w3.org/TR/css-masking-1/#the-mask-image
1491+
- (nullable CALayer *)createMaskImageLayer
1492+
{
1493+
if (_props->maskImage.empty()) {
1494+
return nil;
1495+
}
1496+
1497+
// mask-origin: padding-box
1498+
CGRect positioningArea = RCTCGRectFromRect(_layoutMetrics.getPaddingFrame());
1499+
// mask-clip: border-box
1500+
CGRect paintingArea = self.layer.bounds;
1501+
1502+
CALayer *containerLayer = [CALayer layer];
1503+
containerLayer.frame = paintingArea;
1504+
1505+
size_t imageIndex = _props->maskImage.size() - 1;
1506+
// iterate in reverse to match CSS specification
1507+
for (const auto &maskImage : std::ranges::reverse_view(_props->maskImage)) {
1508+
BackgroundSize maskSize = BackgroundSizeLengthPercentage{};
1509+
if (!_props->maskSize.empty()) {
1510+
maskSize = _props->maskSize[imageIndex % _props->maskSize.size()];
1511+
}
1512+
1513+
BackgroundPosition maskPosition;
1514+
if (!_props->maskPosition.empty()) {
1515+
maskPosition = _props->maskPosition[imageIndex % _props->maskPosition.size()];
1516+
}
1517+
1518+
BackgroundRepeat maskRepeat;
1519+
if (!_props->maskRepeat.empty()) {
1520+
maskRepeat = _props->maskRepeat[imageIndex % _props->maskRepeat.size()];
1521+
}
1522+
1523+
CALayer *itemLayer = nil;
1524+
1525+
if (std::holds_alternative<LinearGradient>(maskImage)) {
1526+
CGSize itemSize = [RCTBackgroundImageUtils calculateBackgroundImageSize:positioningArea
1527+
itemIntrinsicSize:positioningArea.size
1528+
backgroundSize:maskSize
1529+
backgroundRepeat:maskRepeat];
1530+
itemLayer = [RCTLinearGradient gradientLayerWithSize:itemSize gradient:std::get<LinearGradient>(maskImage)];
1531+
} else if (std::holds_alternative<RadialGradient>(maskImage)) {
1532+
CGSize itemSize = [RCTBackgroundImageUtils calculateBackgroundImageSize:positioningArea
1533+
itemIntrinsicSize:positioningArea.size
1534+
backgroundSize:maskSize
1535+
backgroundRepeat:maskRepeat];
1536+
itemLayer = [RCTRadialGradient gradientLayerWithSize:itemSize gradient:std::get<RadialGradient>(maskImage)];
1537+
} else if (std::holds_alternative<URLBackgroundImage>(maskImage)) {
1538+
const auto &urlMaskImage = std::get<URLBackgroundImage>(maskImage);
1539+
NSString *uri = RCTNSStringFromString(urlMaskImage.uri);
1540+
// Loaded asynchronously by `RCTBackgroundImageURLLoader`, which triggers
1541+
// another `invalidateLayer` once the image is available.
1542+
UIImage *loadedImage = [_backgroundImageLoader loadedImageForUri:uri];
1543+
if (loadedImage != nil) {
1544+
CGSize itemSize = [RCTBackgroundImageUtils calculateBackgroundImageSize:positioningArea
1545+
itemIntrinsicSize:loadedImage.size
1546+
backgroundSize:maskSize
1547+
backgroundRepeat:maskRepeat];
1548+
CALayer *imageLayer = [CALayer layer];
1549+
imageLayer.frame = CGRectMake(0, 0, itemSize.width, itemSize.height);
1550+
imageLayer.contents = (__bridge id)loadedImage.CGImage;
1551+
// `itemSize` already resolves `mask-size`, so the image stretches to
1552+
// fill it rather than preserving its intrinsic aspect ratio.
1553+
imageLayer.contentsGravity = kCAGravityResize;
1554+
itemLayer = imageLayer;
1555+
}
1556+
}
1557+
1558+
if (itemLayer != nil) {
1559+
CALayer *maskImageLayer = [RCTBackgroundImageUtils createBackgroundImageLayerWithSize:positioningArea
1560+
paintingArea:paintingArea
1561+
itemSize:itemLayer.frame.size
1562+
backgroundPosition:maskPosition
1563+
backgroundRepeat:maskRepeat
1564+
itemLayer:itemLayer];
1565+
// The helper leaves the returned layer unpositioned; sizing it to the
1566+
// painting area is what applies `mask-clip: border-box`.
1567+
maskImageLayer.frame = paintingArea;
1568+
maskImageLayer.masksToBounds = YES;
1569+
[containerLayer addSublayer:maskImageLayer];
1570+
}
1571+
1572+
imageIndex--;
1573+
}
1574+
1575+
return containerLayer;
1576+
}
1577+
14671578
- (void)clearExistingBackgroundImageLayers
14681579
{
14691580
if (_backgroundImageLayers == nil) {

0 commit comments

Comments
 (0)