diff --git a/packages/react-native/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm b/packages/react-native/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm index 37db047e8b25..8aa3fc050c1b 100644 --- a/packages/react-native/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm +++ b/packages/react-native/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm @@ -39,6 +39,19 @@ using namespace facebook::react; const CGFloat BACKGROUND_COLOR_ZPOSITION = -1024.0f; +static NSString *const RCTViewComponentViewClippingMaskName = @"RCTViewComponentViewClippingMask"; + +static void resetClippingMasksForImageSubviews(UIView *containerView) +{ + NSArray *subviews = containerView.subviews; + for (NSUInteger index = 0; index < subviews.count; index++) { + UIView *const subview = subviews[index]; + if ([subview isKindOfClass:[UIImageView class]] && + [subview.layer.mask.name isEqualToString:RCTViewComponentViewClippingMaskName]) { + [subview.layer setMask:nil]; + } + } +} #if !TARGET_OS_TV // iOS Full Keyboard Access only focuses a view when it is an accessibility @@ -775,6 +788,23 @@ - (void)prepareForRecycle _filterLayer = nil; [self clearExistingBackgroundImageLayers]; + // Resolved from the ivars rather than through `currentContainerView`, because that accessor and + // `effectiveContentView` behind it are side-effecting: they create or tear down the container view. Teardown + // must not reshape the view hierarchy on its way out of the pool. + UIView *containerView = self; + if (_containerView != nil) { + containerView = _containerView; + } else if (_swiftUIWrapper != nullptr) { + containerView = _swiftUIWrapper.contentView; + } + // The masks below are the load-bearing part: `invalidateLayer` installs image-subview masks but never removes + // them, so a recycled view remounted with `overflow: visible` would keep clipping its image child. The layer's + // own mask and corner radius are rewritten on every `invalidateLayer`, so clearing them here is belt-and-braces + // that keeps a pooled view from holding a reference to a mask it no longer uses. + resetClippingMasksForImageSubviews(containerView); + containerView.layer.mask = nil; + containerView.layer.cornerRadius = 0; + _propKeysManagedByAnimated_DO_NOT_USE_THIS_IS_BROKEN = nil; _eventEmitter.reset(); _isJSResponder = NO; @@ -949,11 +979,18 @@ static RCTBorderStyle RCTBorderStyleFromOutlineStyle(OutlineStyle outlineStyle) } } +// Radii that cannot be represented by one circular CALayer corner radius require a custom-rendered border behind +// content, so border-box clipping would let content cover that border. +static bool shouldClipToPaddingBox(const BorderMetrics &borderMetrics) +{ + return ReactNativeFeatureFlags::enableIOSViewClipToPaddingBox() || !areBorderRadiiCircular(borderMetrics.borderRadii); +} + - (BOOL)styleWouldClipOverflowInk { const auto borderMetrics = _props->resolveBorderMetrics(_layoutMetrics); BOOL nonZeroBorderWidth = !(borderMetrics.borderWidths.isUniform() && borderMetrics.borderWidths.left == 0); - BOOL clipToPaddingBox = ReactNativeFeatureFlags::enableIOSViewClipToPaddingBox(); + BOOL clipToPaddingBox = shouldClipToPaddingBox(borderMetrics); return _props->getClipsContentToBounds() && ((!_props->boxShadow.empty() || (clipToPaddingBox && nonZeroBorderWidth)) || _props->outlineWidth != 0); } @@ -1366,21 +1403,43 @@ - (void)invalidateLayer } // clipping - self.currentContainerView.layer.mask = nil; - if (self.currentContainerView.clipsToBounds) { - BOOL clipToPaddingBox = ReactNativeFeatureFlags::enableIOSViewClipToPaddingBox(); + UIView *containerView = self.currentContainerView; + containerView.layer.mask = nil; + if (_containerView != nil) { + containerView.layer.cornerRadius = 0; + } + resetClippingMasksForImageSubviews(containerView); + if (containerView.clipsToBounds) { + BOOL clipToPaddingBox = shouldClipToPaddingBox(borderMetrics); if (!clipToPaddingBox) { if (areBorderRadiiCircular(borderMetrics.borderRadii)) { - self.currentContainerView.layer.cornerRadius = borderMetrics.borderRadii.topLeft.horizontal; + containerView.layer.cornerRadius = borderMetrics.borderRadii.topLeft.horizontal; } else { CALayer *maskLayer = [self createMaskLayer:self.bounds cornerInsets:RCTGetCornerInsets( RCTCornerRadiiFromBorderRadii(borderMetrics.borderRadii), UIEdgeInsetsZero)]; - self.currentContainerView.layer.mask = maskLayer; + containerView.layer.mask = maskLayer; } + } else if ( + !borderMetrics.borderWidths.isUniform() || borderMetrics.borderWidths.left != 0 || + !areBorderRadiiCircular(borderMetrics.borderRadii)) { + CALayer *maskLayer = [self createMaskLayer:RCTCGRectFromRect(_layoutMetrics.getPaddingFrame()) + cornerInsets:RCTGetCornerInsets( + RCTCornerRadiiFromBorderRadii(borderMetrics.borderRadii), + RCTUIEdgeInsetsFromEdgeInsets(borderMetrics.borderWidths))]; + containerView.layer.mask = maskLayer; + } else { + containerView.layer.cornerRadius = borderMetrics.borderRadii.topLeft.horizontal; + } - for (UIView *subview in self.currentContainerView.subviews) { + // Deliberately the raw flag, not `shouldClipToPaddingBox`. This loop used to live inside the + // `if (!clipToPaddingBox)` branch above, where `clipToPaddingBox` was the flag alone. Now that the predicate + // also turns on for non-circular radii, keeping the loop nested would stop applying image masks in exactly + // the case this diff is about. Gating on the flag preserves the previous behaviour everywhere the geometry + // did not change. + if (!ReactNativeFeatureFlags::enableIOSViewClipToPaddingBox()) { + for (UIView *subview in containerView.subviews) { if ([subview isKindOfClass:[UIImageView class]]) { RCTCornerInsets cornerInsets = RCTGetCornerInsets( RCTCornerRadiiFromBorderRadii(borderMetrics.borderRadii), @@ -1388,19 +1447,11 @@ - (void)invalidateLayer // If the subview is an image view, we have to apply the mask directly to the image view's layer, // otherwise the image might overflow with the border radius. - subview.layer.mask = [self createMaskLayer:subview.bounds cornerInsets:cornerInsets]; + CAShapeLayer *maskLayer = [self createMaskLayer:subview.bounds cornerInsets:cornerInsets]; + maskLayer.name = RCTViewComponentViewClippingMaskName; + subview.layer.mask = maskLayer; } } - } else if ( - !borderMetrics.borderWidths.isUniform() || borderMetrics.borderWidths.left != 0 || - !areBorderRadiiCircular(borderMetrics.borderRadii)) { - CALayer *maskLayer = [self createMaskLayer:RCTCGRectFromRect(_layoutMetrics.getPaddingFrame()) - cornerInsets:RCTGetCornerInsets( - RCTCornerRadiiFromBorderRadii(borderMetrics.borderRadii), - RCTUIEdgeInsetsFromEdgeInsets(borderMetrics.borderWidths))]; - self.currentContainerView.layer.mask = maskLayer; - } else { - self.currentContainerView.layer.cornerRadius = borderMetrics.borderRadii.topLeft.horizontal; } } } diff --git a/packages/react-native/React/Tests/Mounting/RCTViewComponentViewTests.mm b/packages/react-native/React/Tests/Mounting/RCTViewComponentViewTests.mm index 91e100d28df1..126a45c915c3 100644 --- a/packages/react-native/React/Tests/Mounting/RCTViewComponentViewTests.mm +++ b/packages/react-native/React/Tests/Mounting/RCTViewComponentViewTests.mm @@ -7,6 +7,8 @@ #import #import +#import +#import #import #import #import @@ -238,6 +240,197 @@ - (void)testViewWithoutRoleIsNotKeyboardFocusable XCTAssertFalse(view.canBecomeFocused); } +#pragma mark - overflow clipping with border radii + +class ViewClippingFeatureFlags final : public ReactNativeFeatureFlagsDefaults { + public: + explicit ViewClippingFeatureFlags(bool clipToPaddingBox) : clipToPaddingBox_(clipToPaddingBox) {} + + bool enableIOSViewClipToPaddingBox() override + { + return clipToPaddingBox_; + } + + private: + bool clipToPaddingBox_; +}; + +class ViewClippingFeatureFlagScope final { + public: + explicit ViewClippingFeatureFlagScope(bool clipToPaddingBox) + { + ReactNativeFeatureFlags::dangerouslyForceOverride(std::make_unique(clipToPaddingBox)); + } + + ~ViewClippingFeatureFlagScope() + { + ReactNativeFeatureFlags::dangerouslyReset(); + } +}; + +static std::shared_ptr makeClippingProps(bool useNonUniformRadii, Float outlineWidth = 0) +{ + auto props = std::make_shared(); + props->yogaStyle.setBorder(facebook::yoga::Edge::All, facebook::yoga::StyleLength::points(2)); + props->yogaStyle.setOverflow(facebook::yoga::Overflow::Hidden); + props->outlineWidth = outlineWidth; + + const ValueUnit radius{36, UnitType::Point}; + if (useNonUniformRadii) { + props->borderRadii.topLeft = radius; + props->borderRadii.topRight = radius; + props->borderRadii.bottomLeft = radius; + } else { + props->borderRadii.all = radius; + } + + return props; +} + +static LayoutMetrics makeClippingLayoutMetrics() +{ + LayoutMetrics layoutMetrics; + layoutMetrics.frame = {.origin = {.x = 0, .y = 0}, .size = {.width = 100, .height = 100}}; + layoutMetrics.borderWidth = {.left = 2, .top = 2, .right = 2, .bottom = 2}; + layoutMetrics.contentInsets = layoutMetrics.borderWidth; + return layoutMetrics; +} + +static RCTViewComponentView *makeClippingView(bool useNonUniformRadii, UIView *contentView = nil) +{ + RCTViewComponentView *view = [RCTViewComponentView new]; + view.contentView = contentView; + + auto props = makeClippingProps(useNonUniformRadii); + LayoutMetrics layoutMetrics = makeClippingLayoutMetrics(); + + [view updateProps:props oldProps:ViewShadowNode::defaultSharedProps()]; + [view updateLayoutMetrics:layoutMetrics oldLayoutMetrics:EmptyLayoutMetrics]; + [view finalizeUpdates:RNComponentViewUpdateMaskAll]; + + return view; +} + +- (void)testNonUniformBorderRadiiClipContentInsideBorder +{ + ViewClippingFeatureFlagScope featureFlags(false); + RCTViewComponentView *view = makeClippingView(true); + UIView *containerView = [view valueForKey:@"_containerView"]; + + XCTAssertNotNil(containerView); + XCTAssertTrue(containerView.clipsToBounds); + XCTAssertNotNil(containerView.layer.mask); + + CGPathRef maskPath = ((CAShapeLayer *)containerView.layer.mask).path; + XCTAssertFalse(CGPathContainsPoint(maskPath, nil, CGPointMake(1, 50), NO)); + XCTAssertTrue(CGPathContainsPoint(maskPath, nil, CGPointMake(3, 50), NO)); +} + +- (void)testUniformBorderRadiiKeepCoreAnimationClipping +{ + ViewClippingFeatureFlagScope featureFlags(false); + RCTViewComponentView *view = makeClippingView(false); + + XCTAssertNil([view valueForKey:@"_containerView"]); + XCTAssertTrue(view.clipsToBounds); + XCTAssertNil(view.layer.mask); + XCTAssertEqualWithAccuracy(view.layer.cornerRadius, 36, 0.001); +} + +- (void)testUniformEllipticalBorderRadiiClipContentInsideBorder +{ + ViewClippingFeatureFlagScope featureFlags(false); + RCTViewComponentView *view = [RCTViewComponentView new]; + auto props = makeClippingProps(false); + props->borderRadii.all = ValueUnit{50, UnitType::Percent}; + LayoutMetrics layoutMetrics = makeClippingLayoutMetrics(); + layoutMetrics.frame.size.height = 50; + + [view updateProps:props oldProps:ViewShadowNode::defaultSharedProps()]; + [view updateLayoutMetrics:layoutMetrics oldLayoutMetrics:EmptyLayoutMetrics]; + [view finalizeUpdates:RNComponentViewUpdateMaskAll]; + + UIView *containerView = [view valueForKey:@"_containerView"]; + XCTAssertNotNil(containerView); + XCTAssertNotNil(containerView.layer.mask); +} + +- (void)testSwitchingToNonUniformRadiiUpdatesDirectImageMask +{ + ViewClippingFeatureFlagScope featureFlags(false); + UIImageView *imageView = [UIImageView new]; + RCTViewComponentView *view = makeClippingView(false, imageView); + + CGPathRef uniformMaskPath = ((CAShapeLayer *)imageView.layer.mask).path; + XCTAssertNotNil(imageView.layer.mask); + XCTAssertFalse(CGPathContainsPoint(uniformMaskPath, nil, CGPointMake(95, 95), NO)); + + auto oldProps = makeClippingProps(false); + auto newProps = makeClippingProps(true); + [view updateProps:newProps oldProps:oldProps]; + [view finalizeUpdates:RNComponentViewUpdateMaskProps]; + + UIView *containerView = [view valueForKey:@"_containerView"]; + CGPathRef nonUniformMaskPath = ((CAShapeLayer *)imageView.layer.mask).path; + XCTAssertNotNil(containerView.layer.mask); + XCTAssertNotNil(imageView.layer.mask); + XCTAssertTrue(CGPathContainsPoint(nonUniformMaskPath, nil, CGPointMake(95, 95), NO)); +} + +- (void)testRecycledCustomContainerDoesNotKeepUniformCornerRadius +{ + ViewClippingFeatureFlagScope featureFlags(false); + RCTViewComponentView *view = [RCTViewComponentView new]; + auto oldProps = makeClippingProps(false, 1); + LayoutMetrics layoutMetrics = makeClippingLayoutMetrics(); + + [view updateProps:oldProps oldProps:ViewShadowNode::defaultSharedProps()]; + [view updateLayoutMetrics:layoutMetrics oldLayoutMetrics:EmptyLayoutMetrics]; + [view finalizeUpdates:RNComponentViewUpdateMaskAll]; + + UIView *containerView = [view valueForKey:@"_containerView"]; + XCTAssertNotNil(containerView); + XCTAssertEqualWithAccuracy(containerView.layer.cornerRadius, 36, 0.001); + + [view prepareForRecycle]; + + auto newProps = makeClippingProps(true); + [view updateProps:newProps oldProps:oldProps]; + [view updateLayoutMetrics:layoutMetrics oldLayoutMetrics:EmptyLayoutMetrics]; + [view finalizeUpdates:RNComponentViewUpdateMaskAll]; + + XCTAssertEqual(containerView, [view valueForKey:@"_containerView"]); + XCTAssertEqualWithAccuracy(containerView.layer.cornerRadius, 0, 0.001); + XCTAssertNotNil(containerView.layer.mask); +} + +- (void)testRecycleClearsDirectImageMask +{ + ViewClippingFeatureFlagScope featureFlags(false); + UIImageView *imageView = [UIImageView new]; + RCTViewComponentView *view = makeClippingView(false, imageView); + + XCTAssertNotNil(imageView.layer.mask); + + [view prepareForRecycle]; + + XCTAssertNil(imageView.layer.mask); +} + +// A view with no custom container carries its own rounding on `self.layer`, so recycle has to clear it there too. +- (void)testRecycleClearsCornerRadiusFromViewWithoutCustomContainer +{ + ViewClippingFeatureFlagScope featureFlags(false); + RCTViewComponentView *view = makeClippingView(false); + + XCTAssertNil([view valueForKey:@"_containerView"]); + XCTAssertEqualWithAccuracy(view.layer.cornerRadius, 36, 0.001); + + [view prepareForRecycle]; + + XCTAssertEqualWithAccuracy(view.layer.cornerRadius, 0, 0.001); +} + #pragma mark - outline style on square corners (#57841) static RCTViewComponentView *makeViewWithOutlineStyle(OutlineStyle outlineStyle) diff --git a/packages/rn-tester/js/examples/Border/BorderExample.js b/packages/rn-tester/js/examples/Border/BorderExample.js index 6b36f32e4432..74a865649fa2 100644 --- a/packages/rn-tester/js/examples/Border/BorderExample.js +++ b/packages/rn-tester/js/examples/Border/BorderExample.js @@ -14,15 +14,37 @@ import type {RNTesterModule} from '../../types/RNTesterTypes'; import hotdog from '../../assets/hotdog.jpg'; import * as React from 'react'; +import {useState} from 'react'; import { + Button, DynamicColorIOS, Image, Platform, PlatformColor, StyleSheet, + Text, View, } from 'react-native'; +component NonUniformRadiusClippingImageExample() { + const [useNonUniformRadii, setUseNonUniformRadii] = useState(false); + const radiusStyle = useNonUniformRadii + ? styles.nonUniformRadiusClippingPartial + : styles.nonUniformRadiusClippingFull; + return ( + + +