Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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<UIView *> *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
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -1366,41 +1403,55 @@ - (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),
RCTUIEdgeInsetsFromEdgeInsets(borderMetrics.borderWidths));

// 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;
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@

#import <React/RCTViewComponentView.h>
#import <XCTest/XCTest.h>
#import <react/featureflags/ReactNativeFeatureFlags.h>
#import <react/featureflags/ReactNativeFeatureFlagsDefaults.h>
#import <react/renderer/components/view/ViewProps.h>
#import <react/renderer/components/view/ViewShadowNode.h>
#import <react/renderer/graphics/Color.h>
Expand Down Expand Up @@ -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<ViewClippingFeatureFlags>(clipToPaddingBox));
}

~ViewClippingFeatureFlagScope()
{
ReactNativeFeatureFlags::dangerouslyReset();
}
};

static std::shared_ptr<ViewProps> makeClippingProps(bool useNonUniformRadii, Float outlineWidth = 0)
{
auto props = std::make_shared<ViewProps>();
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)
Expand Down
Loading
Loading