|
25 | 25 | #import <React/RCTLocalizedString.h> |
26 | 26 | #import <React/RCTLog.h> |
27 | 27 | #import <React/RCTRadialGradient.h> |
| 28 | +#import <React/RCTUtils.h> |
28 | 29 | #import <react/featureflags/ReactNativeFeatureFlags.h> |
29 | 30 | #import <react/renderer/components/view/ViewComponentDescriptor.h> |
30 | 31 | #import <react/renderer/components/view/ViewEventEmitter.h> |
@@ -122,6 +123,11 @@ @implementation RCTViewComponentView { |
122 | 123 | NSMutableSet<NSString *> *_accessibilityOrderNativeIDs; |
123 | 124 | RCTSwiftUIContainerViewWrapper *_swiftUIWrapper; |
124 | 125 | BOOL _focusable; |
| 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; |
125 | 131 | } |
126 | 132 |
|
127 | 133 | #ifdef RCT_DYNAMIC_FRAMEWORKS |
@@ -438,6 +444,15 @@ - (void)updateProps:(const Props::Shared &)props oldProps:(const Props::Shared & |
438 | 444 | -newViewProps.hitSlop.right}; |
439 | 445 | } |
440 | 446 |
|
| 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 | + } |
| 455 | + |
441 | 456 | // `overflow` |
442 | 457 | if (oldViewProps.getClipsContentToBounds() != newViewProps.getClipsContentToBounds()) { |
443 | 458 | self.currentContainerView.clipsToBounds = newViewProps.getClipsContentToBounds(); |
@@ -720,6 +735,105 @@ - (void)updateLayoutMetrics:(const LayoutMetrics &)layoutMetrics |
720 | 735 | } |
721 | 736 | } |
722 | 737 |
|
| 738 | +#pragma mark - Safe area insets |
| 739 | + |
| 740 | +// The view controller the view is hosted in, which is the coordinate space |
| 741 | +// `frame` is reported in. Modals and other view controllers are positioned |
| 742 | +// independently of the window, so the window is not a usable reference. |
| 743 | +static UIViewController *RCTParentViewControllerOfView(UIView *view) |
| 744 | +{ |
| 745 | + UIResponder *responder = view.nextResponder; |
| 746 | + while (responder != nil) { |
| 747 | + if ([responder isKindOfClass:[UIViewController class]]) { |
| 748 | + return (UIViewController *)responder; |
| 749 | + } |
| 750 | + responder = responder.nextResponder; |
| 751 | + } |
| 752 | + return nil; |
| 753 | +} |
| 754 | + |
| 755 | +static BOOL RCTEdgeInsetsEqualWithThreshold(UIEdgeInsets lhs, UIEdgeInsets rhs, CGFloat threshold) |
| 756 | +{ |
| 757 | + return ABS(lhs.left - rhs.left) <= threshold && ABS(lhs.top - rhs.top) <= threshold && |
| 758 | + ABS(lhs.right - rhs.right) <= threshold && ABS(lhs.bottom - rhs.bottom) <= threshold; |
| 759 | +} |
| 760 | + |
| 761 | +// The event is only ever emitted from `layoutSubviews`; everything that might |
| 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. |
| 767 | +- (void)_safeAreaInsetsMayHaveChanged |
| 768 | +{ |
| 769 | + if (!_eventEmitter) { |
| 770 | + return; |
| 771 | + } |
| 772 | + |
| 773 | + // The view has not been mounted or laid out yet, so the insets we would |
| 774 | + // compute are not the ones the view ends up with. |
| 775 | + if (self.window == nil || CGSizeEqualToSize(self.bounds.size, CGSizeZero)) { |
| 776 | + return; |
| 777 | + } |
| 778 | + |
| 779 | + // Only a change of the insets triggers an event. The frame is part of the |
| 780 | + // payload but not of the trigger: a view that moves without its overlap with |
| 781 | + // the system UI changing stays silent, which is what makes observing views |
| 782 | + // safe to place inside scroll views. |
| 783 | + UIEdgeInsets insets = self.safeAreaInsets; |
| 784 | + if (_lastSentSafeAreaInsets != nil && |
| 785 | + RCTEdgeInsetsEqualWithThreshold(insets, _lastSentSafeAreaInsets.UIEdgeInsetsValue, 1.0 / RCTScreenScale())) { |
| 786 | + return; |
| 787 | + } |
| 788 | + |
| 789 | + UIView *referenceView = RCTParentViewControllerOfView(self).view ?: self.window; |
| 790 | + CGRect frame = [self convertRect:self.bounds toView:referenceView]; |
| 791 | + |
| 792 | + _lastSentSafeAreaInsets = [NSValue valueWithUIEdgeInsets:insets]; |
| 793 | + |
| 794 | + static_cast<const ViewEventEmitter &>(*_eventEmitter) |
| 795 | + .onSafeAreaInsetsChange( |
| 796 | + EdgeInsets{ |
| 797 | + .left = (Float)insets.left, |
| 798 | + .top = (Float)insets.top, |
| 799 | + .right = (Float)insets.right, |
| 800 | + .bottom = (Float)insets.bottom}, |
| 801 | + RCTRectFromCGRect(frame)); |
| 802 | +} |
| 803 | + |
| 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 | + |
| 811 | +- (void)safeAreaInsetsDidChange |
| 812 | +{ |
| 813 | + [super safeAreaInsetsDidChange]; |
| 814 | + if ([self _observesSafeAreaInsets]) { |
| 815 | + [self setNeedsLayout]; |
| 816 | + } |
| 817 | +} |
| 818 | + |
| 819 | +- (void)didMoveToWindow |
| 820 | +{ |
| 821 | + [super didMoveToWindow]; |
| 822 | + if ([self _observesSafeAreaInsets]) { |
| 823 | + [self setNeedsLayout]; |
| 824 | + } |
| 825 | +} |
| 826 | + |
| 827 | +- (void)layoutSubviews |
| 828 | +{ |
| 829 | + [super layoutSubviews]; |
| 830 | + // Both the insets and the frame depend on where the view sits in the window, |
| 831 | + // so moving or resizing it changes them without UIKit notifying us. |
| 832 | + if ([self _observesSafeAreaInsets]) { |
| 833 | + [self _safeAreaInsetsMayHaveChanged]; |
| 834 | + } |
| 835 | +} |
| 836 | + |
723 | 837 | - (BOOL)isJSResponder |
724 | 838 | { |
725 | 839 | return _isJSResponder; |
@@ -775,6 +889,7 @@ - (void)prepareForRecycle |
775 | 889 | _filterLayer = nil; |
776 | 890 | [self clearExistingBackgroundImageLayers]; |
777 | 891 |
|
| 892 | + _lastSentSafeAreaInsets = nil; |
778 | 893 | _propKeysManagedByAnimated_DO_NOT_USE_THIS_IS_BROKEN = nil; |
779 | 894 | _eventEmitter.reset(); |
780 | 895 | _isJSResponder = NO; |
|
0 commit comments