@@ -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