Skip to content

Commit 7b0a7c4

Browse files
Remove per-view memory cost of safe area observation on iOS
Observing views were tracked with three ivars on RCTViewComponentView (two BOOLs and a UIEdgeInsets), which every view paid for. The flag now derives from the props the view already holds, and the last-sent insets live behind a single pointer ivar that stays nil until an observing view sends its first event — almost no view observes the safe area, so almost every view pays only for the pointer. The keyboard notification observers are removed as well: UIKit never folds the keyboard into safeAreaInsets for UIKit views (the insets validation confirmed this on both a full screen iPhone and iPad windowing), and if it ever did, safeAreaInsetsDidChange would fire anyway.
1 parent f078288 commit 7b0a7c4

1 file changed

Lines changed: 35 additions & 71 deletions

File tree

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

Lines changed: 35 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -123,9 +123,11 @@ @implementation RCTViewComponentView {
123123
NSMutableSet<NSString *> *_accessibilityOrderNativeIDs;
124124
RCTSwiftUIContainerViewWrapper *_swiftUIWrapper;
125125
BOOL _focusable;
126-
BOOL _observesSafeAreaInsets;
127-
BOOL _safeAreaInsetsWereSent;
128-
UIEdgeInsets _lastSafeAreaInsets;
126+
// The insets sent with the last `onSafeAreaInsetsChange` event, or nil if
127+
// none was sent yet. A pointer because almost no view observes the safe
128+
// area: the views that do pay for a small box, every other view only for
129+
// the pointer.
130+
NSValue *_lastSentSafeAreaInsets;
129131
}
130132

131133
#ifdef RCT_DYNAMIC_FRAMEWORKS
@@ -442,10 +444,14 @@ - (void)updateProps:(const Props::Shared &)props oldProps:(const Props::Shared &
442444
-newViewProps.hitSlop.right};
443445
}
444446

445-
// `onSafeAreaInsetsChange`. Compared against the current observation state
446-
// rather than `oldViewProps`: recycled views keep their last props, so the
447-
// old props of a freshly reused view are not a reliable baseline.
448-
[self _setObservesSafeAreaInsets:newViewProps.onSafeAreaInsetsChange];
447+
// `onSafeAreaInsetsChange`. Scheduled whenever the prop is set, not only on
448+
// its transitions: recycled views keep their last props, so `oldViewProps`
449+
// of a freshly reused view is not a reliable baseline.
450+
if (newViewProps.onSafeAreaInsetsChange) {
451+
[self setNeedsLayout];
452+
} else if (oldViewProps.onSafeAreaInsetsChange) {
453+
_lastSentSafeAreaInsets = nil;
454+
}
449455

450456
// `overflow`
451457
if (oldViewProps.getClipsContentToBounds() != newViewProps.getClipsContentToBounds()) {
@@ -731,19 +737,6 @@ - (void)updateLayoutMetrics:(const LayoutMetrics &)layoutMetrics
731737

732738
#pragma mark - Safe area insets
733739

734-
#if !TARGET_OS_TV
735-
static NSArray<NSNotificationName> *RCTSafeAreaInsetsNotificationNames(void)
736-
{
737-
// The keyboard is part of the safe area on iOS, so its appearance changes the
738-
// insets without the view hierarchy itself changing.
739-
return @[
740-
UIKeyboardDidShowNotification,
741-
UIKeyboardDidHideNotification,
742-
UIKeyboardDidChangeFrameNotification,
743-
];
744-
}
745-
#endif
746-
747740
// The view controller the view is hosted in, which is the coordinate space
748741
// `frame` is reported in. Modals and other view controllers are positioned
749742
// independently of the window, so the window is not a usable reference.
@@ -765,47 +758,15 @@ static BOOL RCTEdgeInsetsEqualWithThreshold(UIEdgeInsets lhs, UIEdgeInsets rhs,
765758
ABS(lhs.right - rhs.right) <= threshold && ABS(lhs.bottom - rhs.bottom) <= threshold;
766759
}
767760

768-
- (void)_setObservesSafeAreaInsets:(BOOL)observesSafeAreaInsets
769-
{
770-
if (_observesSafeAreaInsets == observesSafeAreaInsets) {
771-
return;
772-
}
773-
774-
_observesSafeAreaInsets = observesSafeAreaInsets;
775-
_safeAreaInsetsWereSent = NO;
776-
777-
#if !TARGET_OS_TV
778-
for (NSNotificationName name in RCTSafeAreaInsetsNotificationNames()) {
779-
if (observesSafeAreaInsets) {
780-
[NSNotificationCenter.defaultCenter addObserver:self
781-
selector:@selector(_scheduleSafeAreaInsetsCheck)
782-
name:name
783-
object:nil];
784-
} else {
785-
[NSNotificationCenter.defaultCenter removeObserver:self name:name object:nil];
786-
}
787-
}
788-
#endif
789-
790-
if (observesSafeAreaInsets) {
791-
[self _scheduleSafeAreaInsetsCheck];
792-
}
793-
}
794-
795761
// The event is only ever emitted from `layoutSubviews`; everything that might
796-
// have changed the insets funnels through here and merely marks the view as
797-
// needing layout. This defers the emit out of arbitrary call contexts — in
798-
// particular out of `updateProps`, which runs inside the mounting transaction
799-
// where synchronously re-entering React is not safe — while keeping it in the
800-
// same frame: the layout pass runs before the frame is displayed.
801-
- (void)_scheduleSafeAreaInsetsCheck
802-
{
803-
[self setNeedsLayout];
804-
}
805-
762+
// have changed the insets merely marks the view as needing layout. This defers
763+
// the emit out of arbitrary call contexts — in particular out of
764+
// `updateProps`, which runs inside the mounting transaction where
765+
// synchronously re-entering React is not safe — while keeping it in the same
766+
// frame: the layout pass runs before the frame is displayed.
806767
- (void)_safeAreaInsetsMayHaveChanged
807768
{
808-
if (!_observesSafeAreaInsets || !_eventEmitter) {
769+
if (!_eventEmitter) {
809770
return;
810771
}
811772

@@ -820,16 +781,15 @@ - (void)_safeAreaInsetsMayHaveChanged
820781
// the system UI changing stays silent, which is what makes observing views
821782
// safe to place inside scroll views.
822783
UIEdgeInsets insets = self.safeAreaInsets;
823-
if (_safeAreaInsetsWereSent &&
824-
RCTEdgeInsetsEqualWithThreshold(insets, _lastSafeAreaInsets, 1.0 / RCTScreenScale())) {
784+
if (_lastSentSafeAreaInsets != nil &&
785+
RCTEdgeInsetsEqualWithThreshold(insets, _lastSentSafeAreaInsets.UIEdgeInsetsValue, 1.0 / RCTScreenScale())) {
825786
return;
826787
}
827788

828789
UIView *referenceView = RCTParentViewControllerOfView(self).view ?: self.window;
829790
CGRect frame = [self convertRect:self.bounds toView:referenceView];
830791

831-
_safeAreaInsetsWereSent = YES;
832-
_lastSafeAreaInsets = insets;
792+
_lastSentSafeAreaInsets = [NSValue valueWithUIEdgeInsets:insets];
833793

834794
static_cast<const ViewEventEmitter &>(*_eventEmitter)
835795
.onSafeAreaInsetsChange(
@@ -841,21 +801,26 @@ - (void)_safeAreaInsetsMayHaveChanged
841801
RCTRectFromCGRect(frame));
842802
}
843803

804+
// The prop is checked here rather than inside the helper so that views which
805+
// do not use it only pay for a branch on a prop they already have in hand.
806+
- (BOOL)_observesSafeAreaInsets
807+
{
808+
return static_cast<const ViewProps &>(*_props).onSafeAreaInsetsChange;
809+
}
810+
844811
- (void)safeAreaInsetsDidChange
845812
{
846813
[super safeAreaInsetsDidChange];
847-
// The ivar is checked here rather than inside the helpers so that views which
848-
// do not use the prop only pay for a branch.
849-
if (_observesSafeAreaInsets) {
850-
[self _scheduleSafeAreaInsetsCheck];
814+
if ([self _observesSafeAreaInsets]) {
815+
[self setNeedsLayout];
851816
}
852817
}
853818

854819
- (void)didMoveToWindow
855820
{
856821
[super didMoveToWindow];
857-
if (_observesSafeAreaInsets) {
858-
[self _scheduleSafeAreaInsetsCheck];
822+
if ([self _observesSafeAreaInsets]) {
823+
[self setNeedsLayout];
859824
}
860825
}
861826

@@ -864,7 +829,7 @@ - (void)layoutSubviews
864829
[super layoutSubviews];
865830
// Both the insets and the frame depend on where the view sits in the window,
866831
// so moving or resizing it changes them without UIKit notifying us.
867-
if (_observesSafeAreaInsets) {
832+
if ([self _observesSafeAreaInsets]) {
868833
[self _safeAreaInsetsMayHaveChanged];
869834
}
870835
}
@@ -924,8 +889,7 @@ - (void)prepareForRecycle
924889
_filterLayer = nil;
925890
[self clearExistingBackgroundImageLayers];
926891

927-
[self _setObservesSafeAreaInsets:NO];
928-
892+
_lastSentSafeAreaInsets = nil;
929893
_propKeysManagedByAnimated_DO_NOT_USE_THIS_IS_BROKEN = nil;
930894
_eventEmitter.reset();
931895
_isJSResponder = NO;

0 commit comments

Comments
 (0)