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