From 38ea9220d4d4f6e35397853421abfe4e75870ac9 Mon Sep 17 00:00:00 2001 From: Peter Abbondanzo Date: Tue, 20 Oct 2026 12:08:52 -0700 Subject: [PATCH] Include window unsafe overlap in view inset events Summary: Correct experimental safe-area inset events for views extending beyond an already-inset native parent. Preserve UIKit's own insets and augment each physical edge only where the view's rendered bounds intersect an unsafe strip of its owning window. Report the added distances in window points, preserving equal measurements for equal rendered bounds after scaling or reflection. Do not treat off-window space on an unreserved edge as system coverage. Keep the existing layout-pass dispatch, threshold suppression and synchronous callback behavior. Standard UIKit `safeAreaInsets` is unchanged; no new public API. Changelog: [iOS][Fixed] - Correct experimental safe-area inset events for overflowing child views Differential Revision: D126122349 --- .../View/RCTViewComponentView.mm | 46 ++++- .../Mounting/RCTViewComponentViewTests.mm | 183 ++++++++++++++++++ 2 files changed, 228 insertions(+), 1 deletion(-) 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 bdd0bb5af23..9c0e44fcf6d 100644 --- a/packages/react-native/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm +++ b/packages/react-native/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm @@ -710,6 +710,10 @@ - (void)updateLayoutMetrics:(const LayoutMetrics &)layoutMetrics auto movedLayoutMetrics = layoutMetrics; movedLayoutMetrics.frame.origin = _layoutMetrics.frame.origin; BOOL const onlyMoved = movedLayoutMetrics == _layoutMetrics; + if ([self _observesSafeAreaInsets] && layoutMetrics.frame != _layoutMetrics.frame) { + [self setNeedsLayout]; + } + _layoutMetrics = layoutMetrics; if (!onlyMoved || _props->cursor == Cursor::Pointer) { _needsInvalidateLayer = YES; @@ -763,6 +767,46 @@ static BOOL RCTEdgeInsetsEqualWithThreshold(UIEdgeInsets lhs, UIEdgeInsets rhs, ABS(lhs.right - rhs.right) <= threshold && ABS(lhs.bottom - rhs.bottom) <= threshold; } +- (UIEdgeInsets)_safeAreaInsetsForEvent +{ + UIEdgeInsets insets = self.safeAreaInsets; + UIWindow *window = self.window; + if (window == nil) { + return insets; + } + + CGRect bounds = [self convertRect:self.bounds toView:window]; + CGRect windowBounds = window.bounds; + UIEdgeInsets windowInsets = window.safeAreaInsets; + CGRect safeBounds = UIEdgeInsetsInsetRect(windowBounds, windowInsets); + CGRect leftStrip = CGRectMake( + CGRectGetMinX(windowBounds), CGRectGetMinY(windowBounds), windowInsets.left, CGRectGetHeight(windowBounds)); + CGRect topStrip = CGRectMake( + CGRectGetMinX(windowBounds), CGRectGetMinY(windowBounds), CGRectGetWidth(windowBounds), windowInsets.top); + CGRect rightStrip = CGRectMake( + CGRectGetMaxX(safeBounds), CGRectGetMinY(windowBounds), windowInsets.right, CGRectGetHeight(windowBounds)); + CGRect bottomStrip = CGRectMake( + CGRectGetMinX(windowBounds), CGRectGetMaxY(safeBounds), CGRectGetWidth(windowBounds), windowInsets.bottom); + auto intersects = [bounds](CGRect strip) { return !CGRectIsEmpty(strip) && CGRectIntersectsRect(bounds, strip); }; + if (intersects(leftStrip)) { + insets.left = + MAX(insets.left, MIN(CGRectGetWidth(bounds), MAX(0, CGRectGetMinX(safeBounds) - CGRectGetMinX(bounds)))); + } + if (intersects(topStrip)) { + insets.top = + MAX(insets.top, MIN(CGRectGetHeight(bounds), MAX(0, CGRectGetMinY(safeBounds) - CGRectGetMinY(bounds)))); + } + if (intersects(rightStrip)) { + insets.right = + MAX(insets.right, MIN(CGRectGetWidth(bounds), MAX(0, CGRectGetMaxX(bounds) - CGRectGetMaxX(safeBounds)))); + } + if (intersects(bottomStrip)) { + insets.bottom = + MAX(insets.bottom, MIN(CGRectGetHeight(bounds), MAX(0, CGRectGetMaxY(bounds) - CGRectGetMaxY(safeBounds)))); + } + return insets; +} + // The event is only ever emitted from `layoutSubviews`; everything that might // have changed the insets merely marks the view as needing layout. This defers // the emit out of arbitrary call contexts — in particular out of @@ -779,7 +823,7 @@ - (void)_safeAreaInsetsMayHaveChanged return; } - UIEdgeInsets insets = self.safeAreaInsets; + UIEdgeInsets insets = [self _safeAreaInsetsForEvent]; NSValue *lastSentSafeAreaInsets = [self _lastSentSafeAreaInsets]; if (lastSentSafeAreaInsets != nil && RCTEdgeInsetsEqualWithThreshold(insets, lastSentSafeAreaInsets.UIEdgeInsetsValue, 1.0 / RCTScreenScale())) { diff --git a/packages/react-native/React/Tests/Mounting/RCTViewComponentViewTests.mm b/packages/react-native/React/Tests/Mounting/RCTViewComponentViewTests.mm index 2828481a744..3e18ef65364 100644 --- a/packages/react-native/React/Tests/Mounting/RCTViewComponentViewTests.mm +++ b/packages/react-native/React/Tests/Mounting/RCTViewComponentViewTests.mm @@ -7,6 +7,7 @@ #import #import +#import #import #import #import @@ -20,11 +21,193 @@ return props; } +@interface RCTViewComponentView (SafeAreaInsetsTests) +- (UIEdgeInsets)_safeAreaInsetsForEvent; +- (NSValue *)_lastSentSafeAreaInsets; +@end + +@interface RCTSafeAreaTestWindow : UIWindow +@property (nonatomic, assign) UIEdgeInsets testInsets; +@end + +@implementation RCTSafeAreaTestWindow +- (UIEdgeInsets)safeAreaInsets +{ + return self.testInsets; +} +@end + +@interface RCTSafeAreaTestView : RCTViewComponentView +@property (nonatomic, assign) UIEdgeInsets testInsets; +@property (nonatomic, assign) NSUInteger layoutRequests; +@end + +struct RCTSafeAreaTestProps : ViewProps {}; + +@implementation RCTSafeAreaTestView +- (instancetype)initWithFrame:(CGRect)frame +{ + if (self = [super initWithFrame:frame]) { + _props = std::make_shared(); + } + return self; +} + +- (void)setNeedsLayout +{ + self.layoutRequests += 1; + [super setNeedsLayout]; +} + +- (UIEdgeInsets)safeAreaInsets +{ + return self.testInsets; +} +@end + @interface RCTViewComponentViewTests : XCTestCase @end @implementation RCTViewComponentViewTests +- (void)testSafeAreaEventIncludesWindowOverlapBeyondInsetParent +{ + RCTSafeAreaTestWindow *window = [[RCTSafeAreaTestWindow alloc] initWithFrame:CGRectMake(0, 0, 466, 678)]; + window.testInsets = UIEdgeInsetsMake(0, 0, 34, 84); + UIView *parent = [[UIView alloc] initWithFrame:CGRectMake(0, 0, 382, 678)]; + [window addSubview:parent]; + RCTSafeAreaTestView *view = [[RCTSafeAreaTestView alloc] initWithFrame:window.bounds]; + view.testInsets = UIEdgeInsetsMake(17, 0, 34, 0); + [parent addSubview:view]; + + XCTAssertEqualWithAccuracy(view.safeAreaInsets.right, 0, 0.001); + UIEdgeInsets insets = [view _safeAreaInsetsForEvent]; + XCTAssertEqualWithAccuracy(insets.right, 84, 0.001); + XCTAssertEqualWithAccuracy(insets.top, 17, 0.001); + XCTAssertEqualWithAccuracy(insets.bottom, 34, 0.001); + + view.frame = parent.bounds; + insets = [view _safeAreaInsetsForEvent]; + XCTAssertEqualWithAccuracy(insets.right, 0, 0.001); +} + +- (void)testSafeAreaEventDoesNotTreatOffWindowSpaceAsSystemInsets +{ + RCTSafeAreaTestWindow *window = [[RCTSafeAreaTestWindow alloc] initWithFrame:CGRectMake(0, 0, 400, 600)]; + RCTSafeAreaTestView *view = [[RCTSafeAreaTestView alloc] initWithFrame:CGRectMake(350, 100, 100, 100)]; + [window addSubview:view]; + XCTAssertTrue(UIEdgeInsetsEqualToEdgeInsets([view _safeAreaInsetsForEvent], UIEdgeInsetsZero)); + view.frame = CGRectMake(450, 100, 100, 100); + XCTAssertTrue(UIEdgeInsetsEqualToEdgeInsets([view _safeAreaInsetsForEvent], UIEdgeInsetsZero)); +} + +- (void)testSafeAreaEventOnlyAddsAnInsetWhereTheReservedStripIntersects +{ + RCTSafeAreaTestWindow *window = [[RCTSafeAreaTestWindow alloc] initWithFrame:CGRectMake(0, 0, 400, 600)]; + window.testInsets = UIEdgeInsetsMake(0, 0, 0, 40); + RCTSafeAreaTestView *view = [[RCTSafeAreaTestView alloc] initWithFrame:CGRectMake(350, 100, 100, 100)]; + [window addSubview:view]; + XCTAssertEqualWithAccuracy([view _safeAreaInsetsForEvent].right, 90, 0.001); + view.frame = CGRectMake(450, 100, 100, 100); + XCTAssertTrue(UIEdgeInsetsEqualToEdgeInsets([view _safeAreaInsetsForEvent], UIEdgeInsetsZero)); + view.frame = CGRectMake(350, 650, 100, 100); + XCTAssertTrue(UIEdgeInsetsEqualToEdgeInsets([view _safeAreaInsetsForEvent], UIEdgeInsetsZero)); +} + +- (void)testSafeAreaEventUsesRenderedBoundsForScaledAndReflectedViews +{ + RCTSafeAreaTestWindow *window = [[RCTSafeAreaTestWindow alloc] initWithFrame:CGRectMake(0, 0, 400, 600)]; + window.testInsets = UIEdgeInsetsMake(20, 10, 30, 40); + RCTSafeAreaTestView *view = [[RCTSafeAreaTestView alloc] initWithFrame:CGRectMake(100, 150, 200, 300)]; + [window addSubview:view]; + view.transform = CGAffineTransformMakeScale(2, 2); + XCTAssertTrue(UIEdgeInsetsEqualToEdgeInsets([view _safeAreaInsetsForEvent], window.testInsets)); + view.transform = CGAffineTransformMakeScale(-2, 2); + XCTAssertTrue(UIEdgeInsetsEqualToEdgeInsets([view _safeAreaInsetsForEvent], window.testInsets)); +} + +- (void)testSafeAreaEventPreservesLocalInsetsWithoutAddingThemTwice +{ + RCTSafeAreaTestWindow *window = [[RCTSafeAreaTestWindow alloc] initWithFrame:CGRectMake(0, 0, 400, 600)]; + window.testInsets = UIEdgeInsetsMake(20, 10, 30, 40); + RCTSafeAreaTestView *view = [[RCTSafeAreaTestView alloc] initWithFrame:window.bounds]; + view.testInsets = UIEdgeInsetsMake(25, 15, 35, 45); + [window addSubview:view]; + + UIEdgeInsets insets = [view _safeAreaInsetsForEvent]; + XCTAssertTrue(UIEdgeInsetsEqualToEdgeInsets(insets, view.testInsets)); +} + +- (void)testSafeAreaEventConvertsWindowEdgesIntoViewCoordinates +{ + RCTSafeAreaTestWindow *window = [[RCTSafeAreaTestWindow alloc] initWithFrame:CGRectMake(0, 0, 400, 600)]; + window.testInsets = UIEdgeInsetsMake(20, 10, 30, 40); + RCTSafeAreaTestView *view = [[RCTSafeAreaTestView alloc] initWithFrame:CGRectMake(5, 10, 385, 580)]; + [window addSubview:view]; + view.bounds = CGRectMake(100, 200, 385, 580); + + UIEdgeInsets insets = [view _safeAreaInsetsForEvent]; + XCTAssertEqualWithAccuracy(insets.left, 5, 0.001); + XCTAssertEqualWithAccuracy(insets.top, 10, 0.001); + XCTAssertEqualWithAccuracy(insets.right, 30, 0.001); + XCTAssertEqualWithAccuracy(insets.bottom, 20, 0.001); +} + +- (void)testSafeAreaEventUpdatesWhenWindowInsetsChangeWithoutViewResize +{ + RCTSafeAreaTestWindow *window = [[RCTSafeAreaTestWindow alloc] initWithFrame:CGRectMake(0, 0, 400, 600)]; + RCTSafeAreaTestView *view = [[RCTSafeAreaTestView alloc] initWithFrame:window.bounds]; + [window addSubview:view]; + XCTAssertTrue(UIEdgeInsetsEqualToEdgeInsets([view _safeAreaInsetsForEvent], UIEdgeInsetsZero)); + window.testInsets = UIEdgeInsetsMake(0, 40, 0, 0); + XCTAssertEqualWithAccuracy([view _safeAreaInsetsForEvent].left, 40, 0.001); +} + +- (void)testSafeAreaEventCacheUpdatesAfterWindowInsetsChange +{ + RCTSafeAreaTestWindow *window = [[RCTSafeAreaTestWindow alloc] initWithFrame:CGRectMake(0, 0, 400, 600)]; + RCTSafeAreaTestView *view = [[RCTSafeAreaTestView alloc] initWithFrame:window.bounds]; + auto props = std::make_shared(); + props->onSafeAreaInsetsChange = true; + [view updateProps:props oldProps:ViewShadowNode::defaultSharedProps()]; + [view updateEventEmitter:std::make_shared(nullptr, EventDispatcher::Weak{})]; + [window addSubview:view]; + [view layoutSubviews]; + XCTAssertTrue(UIEdgeInsetsEqualToEdgeInsets([view _lastSentSafeAreaInsets].UIEdgeInsetsValue, UIEdgeInsetsZero)); + + window.testInsets = UIEdgeInsetsMake(0, 40, 0, 0); + view.layoutRequests = 0; + [view safeAreaInsetsDidChange]; + XCTAssertGreaterThan(view.layoutRequests, 0u); + [view layoutIfNeeded]; + XCTAssertEqualWithAccuracy([view _lastSentSafeAreaInsets].UIEdgeInsetsValue.left, 40, 0.001); +} + +- (void)testObservedOriginChangeSchedulesSafeAreaLayout +{ + RCTSafeAreaTestView *view = [RCTSafeAreaTestView new]; + auto props = std::make_shared(); + props->onSafeAreaInsetsChange = true; + [view updateProps:props oldProps:ViewShadowNode::defaultSharedProps()]; + LayoutMetrics metrics; + metrics.frame = {.origin = {.x = 0, .y = 0}, .size = {.width = 100, .height = 100}}; + [view updateLayoutMetrics:metrics oldLayoutMetrics:LayoutMetrics{}]; + [view layoutIfNeeded]; + view.layoutRequests = 0; + + LayoutMetrics movedMetrics = metrics; + movedMetrics.frame.origin.x = 20; + [view updateLayoutMetrics:movedMetrics oldLayoutMetrics:metrics]; + XCTAssertGreaterThan(view.layoutRequests, 0u); +} + +- (void)testSafeAreaEventKeepsLocalInsetsWhenDetached +{ + RCTSafeAreaTestView *view = [RCTSafeAreaTestView new]; + view.testInsets = UIEdgeInsetsMake(10, 20, 30, 40); + XCTAssertTrue(UIEdgeInsetsEqualToEdgeInsets([view _safeAreaInsetsForEvent], view.testInsets)); +} + #pragma mark - removeClippedSubviews toggle - (void)testToggleRemoveClippedSubviewsOffRemountsClippedChildren