Skip to content

Commit 013bcbf

Browse files
Robert Thachfacebook-github-bot
authored andcommitted
Clip child content to non-uniform border radii
Summary: Fixes react#53877. The Issue Uniform Constraint: Previously, the clipping logic relied on UIKit's layer.cornerRadius property whenever the padding-box feature flag was off. This property only supports applying a single, uniform radius to all four corners of a view. Incorrect Rendering: If a layout called for mixed radii (e.g., borderRadius: 36 36 36 0), UIKit would force all four corners to round to 36, causing intended square corners to render as rounded. Content Spillage: Because layer.cornerRadius cannot inset a clip by an uneven border, child backgrounds would spill out of the padding box and paint directly over the parent's borders. This behavior is documented in Issue react#53877. == The change Geometry-Based Masking: The renderer now dynamically selects the clipping mechanism based on the layout geometry. If the border radii are non-uniform, the view uses a layer.mask with a custom shape layer built from the exact per-corner radii and per-side border widths. Optimized Uniform Routing: If the view utilizes standard uniform radii, it continues to take the cheaper, existing cornerRadius path. Direct Image Masking: Since a UIImageView on iOS does not naturally inherit its parent's corner rounding, the new shape mask is applied directly to the image layer so it respects the same non-uniform clipping path. Recycling Hygiene: Because direct image masks persist, new teardown logic was introduced. When a masked view is returned to the recycle pool, the custom clipping masks are cleared. This prevents recycled views from carrying leftover clipping artifacts into their next mount lifecycle. Differential Revision: D119531761
1 parent e0224fd commit 013bcbf

3 files changed

Lines changed: 375 additions & 18 deletions

File tree

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

Lines changed: 69 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,19 @@
3939
using namespace facebook::react;
4040

4141
const CGFloat BACKGROUND_COLOR_ZPOSITION = -1024.0f;
42+
static NSString *const RCTViewComponentViewClippingMaskName = @"RCTViewComponentViewClippingMask";
43+
44+
static void resetClippingMasksForImageSubviews(UIView *containerView)
45+
{
46+
NSArray<UIView *> *subviews = containerView.subviews;
47+
for (NSUInteger index = 0; index < subviews.count; index++) {
48+
UIView *const subview = subviews[index];
49+
if ([subview isKindOfClass:[UIImageView class]] &&
50+
[subview.layer.mask.name isEqualToString:RCTViewComponentViewClippingMaskName]) {
51+
[subview.layer setMask:nil];
52+
}
53+
}
54+
}
4255

4356
#if !TARGET_OS_TV
4457
// iOS Full Keyboard Access only focuses a view when it is an accessibility
@@ -775,6 +788,23 @@ - (void)prepareForRecycle
775788
_filterLayer = nil;
776789
[self clearExistingBackgroundImageLayers];
777790

791+
// Resolved from the ivars rather than through `currentContainerView`, because that accessor and
792+
// `effectiveContentView` behind it are side-effecting: they create or tear down the container view. Teardown
793+
// must not reshape the view hierarchy on its way out of the pool.
794+
UIView *containerView = self;
795+
if (_containerView != nil) {
796+
containerView = _containerView;
797+
} else if (_swiftUIWrapper != nullptr) {
798+
containerView = _swiftUIWrapper.contentView;
799+
}
800+
// The masks below are the load-bearing part: `invalidateLayer` installs image-subview masks but never removes
801+
// them, so a recycled view remounted with `overflow: visible` would keep clipping its image child. The layer's
802+
// own mask and corner radius are rewritten on every `invalidateLayer`, so clearing them here is belt-and-braces
803+
// that keeps a pooled view from holding a reference to a mask it no longer uses.
804+
resetClippingMasksForImageSubviews(containerView);
805+
containerView.layer.mask = nil;
806+
containerView.layer.cornerRadius = 0;
807+
778808
_propKeysManagedByAnimated_DO_NOT_USE_THIS_IS_BROKEN = nil;
779809
_eventEmitter.reset();
780810
_isJSResponder = NO;
@@ -949,11 +979,18 @@ static RCTBorderStyle RCTBorderStyleFromOutlineStyle(OutlineStyle outlineStyle)
949979
}
950980
}
951981

982+
// Radii that cannot be represented by one circular CALayer corner radius require a custom-rendered border behind
983+
// content, so border-box clipping would let content cover that border.
984+
static bool shouldClipToPaddingBox(const BorderMetrics &borderMetrics)
985+
{
986+
return ReactNativeFeatureFlags::enableIOSViewClipToPaddingBox() || !areBorderRadiiCircular(borderMetrics.borderRadii);
987+
}
988+
952989
- (BOOL)styleWouldClipOverflowInk
953990
{
954991
const auto borderMetrics = _props->resolveBorderMetrics(_layoutMetrics);
955992
BOOL nonZeroBorderWidth = !(borderMetrics.borderWidths.isUniform() && borderMetrics.borderWidths.left == 0);
956-
BOOL clipToPaddingBox = ReactNativeFeatureFlags::enableIOSViewClipToPaddingBox();
993+
BOOL clipToPaddingBox = shouldClipToPaddingBox(borderMetrics);
957994
return _props->getClipsContentToBounds() &&
958995
((!_props->boxShadow.empty() || (clipToPaddingBox && nonZeroBorderWidth)) || _props->outlineWidth != 0);
959996
}
@@ -1366,41 +1403,55 @@ - (void)invalidateLayer
13661403
}
13671404

13681405
// clipping
1369-
self.currentContainerView.layer.mask = nil;
1370-
if (self.currentContainerView.clipsToBounds) {
1371-
BOOL clipToPaddingBox = ReactNativeFeatureFlags::enableIOSViewClipToPaddingBox();
1406+
UIView *containerView = self.currentContainerView;
1407+
containerView.layer.mask = nil;
1408+
if (_containerView != nil) {
1409+
containerView.layer.cornerRadius = 0;
1410+
}
1411+
resetClippingMasksForImageSubviews(containerView);
1412+
if (containerView.clipsToBounds) {
1413+
BOOL clipToPaddingBox = shouldClipToPaddingBox(borderMetrics);
13721414
if (!clipToPaddingBox) {
13731415
if (areBorderRadiiCircular(borderMetrics.borderRadii)) {
1374-
self.currentContainerView.layer.cornerRadius = borderMetrics.borderRadii.topLeft.horizontal;
1416+
containerView.layer.cornerRadius = borderMetrics.borderRadii.topLeft.horizontal;
13751417
} else {
13761418
CALayer *maskLayer =
13771419
[self createMaskLayer:self.bounds
13781420
cornerInsets:RCTGetCornerInsets(
13791421
RCTCornerRadiiFromBorderRadii(borderMetrics.borderRadii), UIEdgeInsetsZero)];
1380-
self.currentContainerView.layer.mask = maskLayer;
1422+
containerView.layer.mask = maskLayer;
13811423
}
1424+
} else if (
1425+
!borderMetrics.borderWidths.isUniform() || borderMetrics.borderWidths.left != 0 ||
1426+
!areBorderRadiiCircular(borderMetrics.borderRadii)) {
1427+
CALayer *maskLayer = [self createMaskLayer:RCTCGRectFromRect(_layoutMetrics.getPaddingFrame())
1428+
cornerInsets:RCTGetCornerInsets(
1429+
RCTCornerRadiiFromBorderRadii(borderMetrics.borderRadii),
1430+
RCTUIEdgeInsetsFromEdgeInsets(borderMetrics.borderWidths))];
1431+
containerView.layer.mask = maskLayer;
1432+
} else {
1433+
containerView.layer.cornerRadius = borderMetrics.borderRadii.topLeft.horizontal;
1434+
}
13821435

1383-
for (UIView *subview in self.currentContainerView.subviews) {
1436+
// Deliberately the raw flag, not `shouldClipToPaddingBox`. This loop used to live inside the
1437+
// `if (!clipToPaddingBox)` branch above, where `clipToPaddingBox` was the flag alone. Now that the predicate
1438+
// also turns on for non-circular radii, keeping the loop nested would stop applying image masks in exactly
1439+
// the case this diff is about. Gating on the flag preserves the previous behaviour everywhere the geometry
1440+
// did not change.
1441+
if (!ReactNativeFeatureFlags::enableIOSViewClipToPaddingBox()) {
1442+
for (UIView *subview in containerView.subviews) {
13841443
if ([subview isKindOfClass:[UIImageView class]]) {
13851444
RCTCornerInsets cornerInsets = RCTGetCornerInsets(
13861445
RCTCornerRadiiFromBorderRadii(borderMetrics.borderRadii),
13871446
RCTUIEdgeInsetsFromEdgeInsets(borderMetrics.borderWidths));
13881447

13891448
// If the subview is an image view, we have to apply the mask directly to the image view's layer,
13901449
// otherwise the image might overflow with the border radius.
1391-
subview.layer.mask = [self createMaskLayer:subview.bounds cornerInsets:cornerInsets];
1450+
CAShapeLayer *maskLayer = [self createMaskLayer:subview.bounds cornerInsets:cornerInsets];
1451+
maskLayer.name = RCTViewComponentViewClippingMaskName;
1452+
subview.layer.mask = maskLayer;
13921453
}
13931454
}
1394-
} else if (
1395-
!borderMetrics.borderWidths.isUniform() || borderMetrics.borderWidths.left != 0 ||
1396-
!areBorderRadiiCircular(borderMetrics.borderRadii)) {
1397-
CALayer *maskLayer = [self createMaskLayer:RCTCGRectFromRect(_layoutMetrics.getPaddingFrame())
1398-
cornerInsets:RCTGetCornerInsets(
1399-
RCTCornerRadiiFromBorderRadii(borderMetrics.borderRadii),
1400-
RCTUIEdgeInsetsFromEdgeInsets(borderMetrics.borderWidths))];
1401-
self.currentContainerView.layer.mask = maskLayer;
1402-
} else {
1403-
self.currentContainerView.layer.cornerRadius = borderMetrics.borderRadii.topLeft.horizontal;
14041455
}
14051456
}
14061457
}

‎packages/react-native/React/Tests/Mounting/RCTViewComponentViewTests.mm‎

Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77

88
#import <React/RCTViewComponentView.h>
99
#import <XCTest/XCTest.h>
10+
#import <react/featureflags/ReactNativeFeatureFlags.h>
11+
#import <react/featureflags/ReactNativeFeatureFlagsDefaults.h>
1012
#import <react/renderer/components/view/ViewProps.h>
1113
#import <react/renderer/components/view/ViewShadowNode.h>
1214
#import <react/renderer/graphics/Color.h>
@@ -238,6 +240,197 @@ - (void)testViewWithoutRoleIsNotKeyboardFocusable
238240
XCTAssertFalse(view.canBecomeFocused);
239241
}
240242

243+
#pragma mark - overflow clipping with border radii
244+
245+
class ViewClippingFeatureFlags final : public ReactNativeFeatureFlagsDefaults {
246+
public:
247+
explicit ViewClippingFeatureFlags(bool clipToPaddingBox) : clipToPaddingBox_(clipToPaddingBox) {}
248+
249+
bool enableIOSViewClipToPaddingBox() override
250+
{
251+
return clipToPaddingBox_;
252+
}
253+
254+
private:
255+
bool clipToPaddingBox_;
256+
};
257+
258+
class ViewClippingFeatureFlagScope final {
259+
public:
260+
explicit ViewClippingFeatureFlagScope(bool clipToPaddingBox)
261+
{
262+
ReactNativeFeatureFlags::dangerouslyForceOverride(std::make_unique<ViewClippingFeatureFlags>(clipToPaddingBox));
263+
}
264+
265+
~ViewClippingFeatureFlagScope()
266+
{
267+
ReactNativeFeatureFlags::dangerouslyReset();
268+
}
269+
};
270+
271+
static std::shared_ptr<ViewProps> makeClippingProps(bool useNonUniformRadii, Float outlineWidth = 0)
272+
{
273+
auto props = std::make_shared<ViewProps>();
274+
props->yogaStyle.setBorder(facebook::yoga::Edge::All, facebook::yoga::StyleLength::points(2));
275+
props->yogaStyle.setOverflow(facebook::yoga::Overflow::Hidden);
276+
props->outlineWidth = outlineWidth;
277+
278+
const ValueUnit radius{36, UnitType::Point};
279+
if (useNonUniformRadii) {
280+
props->borderRadii.topLeft = radius;
281+
props->borderRadii.topRight = radius;
282+
props->borderRadii.bottomLeft = radius;
283+
} else {
284+
props->borderRadii.all = radius;
285+
}
286+
287+
return props;
288+
}
289+
290+
static LayoutMetrics makeClippingLayoutMetrics()
291+
{
292+
LayoutMetrics layoutMetrics;
293+
layoutMetrics.frame = {.origin = {.x = 0, .y = 0}, .size = {.width = 100, .height = 100}};
294+
layoutMetrics.borderWidth = {.left = 2, .top = 2, .right = 2, .bottom = 2};
295+
layoutMetrics.contentInsets = layoutMetrics.borderWidth;
296+
return layoutMetrics;
297+
}
298+
299+
static RCTViewComponentView *makeClippingView(bool useNonUniformRadii, UIView *contentView = nil)
300+
{
301+
RCTViewComponentView *view = [RCTViewComponentView new];
302+
view.contentView = contentView;
303+
304+
auto props = makeClippingProps(useNonUniformRadii);
305+
LayoutMetrics layoutMetrics = makeClippingLayoutMetrics();
306+
307+
[view updateProps:props oldProps:ViewShadowNode::defaultSharedProps()];
308+
[view updateLayoutMetrics:layoutMetrics oldLayoutMetrics:EmptyLayoutMetrics];
309+
[view finalizeUpdates:RNComponentViewUpdateMaskAll];
310+
311+
return view;
312+
}
313+
314+
- (void)testNonUniformBorderRadiiClipContentInsideBorder
315+
{
316+
ViewClippingFeatureFlagScope featureFlags(false);
317+
RCTViewComponentView *view = makeClippingView(true);
318+
UIView *containerView = [view valueForKey:@"_containerView"];
319+
320+
XCTAssertNotNil(containerView);
321+
XCTAssertTrue(containerView.clipsToBounds);
322+
XCTAssertNotNil(containerView.layer.mask);
323+
324+
CGPathRef maskPath = ((CAShapeLayer *)containerView.layer.mask).path;
325+
XCTAssertFalse(CGPathContainsPoint(maskPath, nil, CGPointMake(1, 50), NO));
326+
XCTAssertTrue(CGPathContainsPoint(maskPath, nil, CGPointMake(3, 50), NO));
327+
}
328+
329+
- (void)testUniformBorderRadiiKeepCoreAnimationClipping
330+
{
331+
ViewClippingFeatureFlagScope featureFlags(false);
332+
RCTViewComponentView *view = makeClippingView(false);
333+
334+
XCTAssertNil([view valueForKey:@"_containerView"]);
335+
XCTAssertTrue(view.clipsToBounds);
336+
XCTAssertNil(view.layer.mask);
337+
XCTAssertEqualWithAccuracy(view.layer.cornerRadius, 36, 0.001);
338+
}
339+
340+
- (void)testUniformEllipticalBorderRadiiClipContentInsideBorder
341+
{
342+
ViewClippingFeatureFlagScope featureFlags(false);
343+
RCTViewComponentView *view = [RCTViewComponentView new];
344+
auto props = makeClippingProps(false);
345+
props->borderRadii.all = ValueUnit{50, UnitType::Percent};
346+
LayoutMetrics layoutMetrics = makeClippingLayoutMetrics();
347+
layoutMetrics.frame.size.height = 50;
348+
349+
[view updateProps:props oldProps:ViewShadowNode::defaultSharedProps()];
350+
[view updateLayoutMetrics:layoutMetrics oldLayoutMetrics:EmptyLayoutMetrics];
351+
[view finalizeUpdates:RNComponentViewUpdateMaskAll];
352+
353+
UIView *containerView = [view valueForKey:@"_containerView"];
354+
XCTAssertNotNil(containerView);
355+
XCTAssertNotNil(containerView.layer.mask);
356+
}
357+
358+
- (void)testSwitchingToNonUniformRadiiUpdatesDirectImageMask
359+
{
360+
ViewClippingFeatureFlagScope featureFlags(false);
361+
UIImageView *imageView = [UIImageView new];
362+
RCTViewComponentView *view = makeClippingView(false, imageView);
363+
364+
CGPathRef uniformMaskPath = ((CAShapeLayer *)imageView.layer.mask).path;
365+
XCTAssertNotNil(imageView.layer.mask);
366+
XCTAssertFalse(CGPathContainsPoint(uniformMaskPath, nil, CGPointMake(95, 95), NO));
367+
368+
auto oldProps = makeClippingProps(false);
369+
auto newProps = makeClippingProps(true);
370+
[view updateProps:newProps oldProps:oldProps];
371+
[view finalizeUpdates:RNComponentViewUpdateMaskProps];
372+
373+
UIView *containerView = [view valueForKey:@"_containerView"];
374+
CGPathRef nonUniformMaskPath = ((CAShapeLayer *)imageView.layer.mask).path;
375+
XCTAssertNotNil(containerView.layer.mask);
376+
XCTAssertNotNil(imageView.layer.mask);
377+
XCTAssertTrue(CGPathContainsPoint(nonUniformMaskPath, nil, CGPointMake(95, 95), NO));
378+
}
379+
380+
- (void)testRecycledCustomContainerDoesNotKeepUniformCornerRadius
381+
{
382+
ViewClippingFeatureFlagScope featureFlags(false);
383+
RCTViewComponentView *view = [RCTViewComponentView new];
384+
auto oldProps = makeClippingProps(false, 1);
385+
LayoutMetrics layoutMetrics = makeClippingLayoutMetrics();
386+
387+
[view updateProps:oldProps oldProps:ViewShadowNode::defaultSharedProps()];
388+
[view updateLayoutMetrics:layoutMetrics oldLayoutMetrics:EmptyLayoutMetrics];
389+
[view finalizeUpdates:RNComponentViewUpdateMaskAll];
390+
391+
UIView *containerView = [view valueForKey:@"_containerView"];
392+
XCTAssertNotNil(containerView);
393+
XCTAssertEqualWithAccuracy(containerView.layer.cornerRadius, 36, 0.001);
394+
395+
[view prepareForRecycle];
396+
397+
auto newProps = makeClippingProps(true);
398+
[view updateProps:newProps oldProps:oldProps];
399+
[view updateLayoutMetrics:layoutMetrics oldLayoutMetrics:EmptyLayoutMetrics];
400+
[view finalizeUpdates:RNComponentViewUpdateMaskAll];
401+
402+
XCTAssertEqual(containerView, [view valueForKey:@"_containerView"]);
403+
XCTAssertEqualWithAccuracy(containerView.layer.cornerRadius, 0, 0.001);
404+
XCTAssertNotNil(containerView.layer.mask);
405+
}
406+
407+
- (void)testRecycleClearsDirectImageMask
408+
{
409+
ViewClippingFeatureFlagScope featureFlags(false);
410+
UIImageView *imageView = [UIImageView new];
411+
RCTViewComponentView *view = makeClippingView(false, imageView);
412+
413+
XCTAssertNotNil(imageView.layer.mask);
414+
415+
[view prepareForRecycle];
416+
417+
XCTAssertNil(imageView.layer.mask);
418+
}
419+
420+
// A view with no custom container carries its own rounding on `self.layer`, so recycle has to clear it there too.
421+
- (void)testRecycleClearsCornerRadiusFromViewWithoutCustomContainer
422+
{
423+
ViewClippingFeatureFlagScope featureFlags(false);
424+
RCTViewComponentView *view = makeClippingView(false);
425+
426+
XCTAssertNil([view valueForKey:@"_containerView"]);
427+
XCTAssertEqualWithAccuracy(view.layer.cornerRadius, 36, 0.001);
428+
429+
[view prepareForRecycle];
430+
431+
XCTAssertEqualWithAccuracy(view.layer.cornerRadius, 0, 0.001);
432+
}
433+
241434
#pragma mark - outline style on square corners (#57841)
242435

243436
static RCTViewComponentView *makeViewWithOutlineStyle(OutlineStyle outlineStyle)

0 commit comments

Comments
 (0)