Skip to content

Commit 888b052

Browse files
authored
fix: macOS ScrollView resize and content inset behavior (#2732)
## Summary - preserve macOS ScrollView position during live resize in root and surface hosting views - align Fabric ScrollView contentInset and contentOffset behavior with the existing macOS scrollview implementation - prevent resize-driven scroll drift in the macOS Paper ScrollView path ## Test Plan - Build RNTester-macOS - Verify RNTester runs normally - Manually resize windows containing ScrollView content and confirm content offset remains stable
1 parent 5ffc39f commit 888b052

File tree

8 files changed

+190
-4
lines changed

8 files changed

+190
-4
lines changed

packages/react-native/React/Base/RCTRootView.m

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,13 @@ - (BOOL)canBecomeFirstResponder
206206
#endif // macOS]
207207
}
208208

209+
#if TARGET_OS_OSX // [macOS
210+
- (void)viewDidEndLiveResize {
211+
[super viewDidEndLiveResize];
212+
[self setNeedsLayout];
213+
}
214+
#endif // macOS]
215+
209216
- (void)setLoadingView:(RCTUIView *)loadingView // [macOS]
210217
{
211218
_loadingView = loadingView;

packages/react-native/React/Base/Surface/SurfaceHostingView/RCTSurfaceHostingView.mm

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,15 @@ - (void)disableActivityIndicatorAutoHide:(BOOL)disabled
144144
_autoHideDisabled = disabled;
145145
}
146146

147+
#pragma mark - NSView
148+
149+
#if TARGET_OS_OSX // [macOS
150+
- (void)viewDidEndLiveResize {
151+
[super viewDidEndLiveResize];
152+
[self setNeedsLayout];
153+
}
154+
#endif // macOS]
155+
147156
#pragma mark - isActivityIndicatorViewVisible
148157

149158
- (void)setIsActivityIndicatorViewVisible:(BOOL)visible

packages/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTEnhancedScrollView.h

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,12 @@ NS_ASSUME_NONNULL_BEGIN
5252
@property (nonatomic, assign) BOOL snapToEnd;
5353
@property (nonatomic, copy) NSArray<NSNumber *> *snapToOffsets;
5454

55+
#if TARGET_OS_OSX // [macOS
56+
- (void)setContentOffset:(CGPoint)contentOffset animated:(BOOL)animated;
57+
- (void)zoomToRect:(CGRect)rect animated:(BOOL)animated;
58+
- (void)flashScrollIndicators;
59+
#endif // macOS]
60+
5561
/*
5662
* Makes `setContentOffset:` method no-op when given `block` is executed.
5763
* The block is being executed synchronously.

packages/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTEnhancedScrollView.mm

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,9 @@ - (instancetype)initWithFrame:(CGRect)frame
4747
// NSScrollView.automaticallyAdjustsContentInsets (default YES) adds contentInset.top to push content below the toolbar.
4848
// However, React Native doesn't know about this native contentInset adjustments,causing some caltulation issues
4949
self.automaticallyAdjustsContentInsets = NO;
50+
self.hasHorizontalScroller = YES;
51+
self.hasVerticalScroller = YES;
52+
self.autohidesScrollers = YES;
5053
#endif // macOS]
5154

5255
__weak __typeof(self) weakSelf = self;
@@ -104,17 +107,84 @@ - (void)setContentOffset:(CGPoint)contentOffset
104107
if (_isSetContentOffsetDisabled) {
105108
return;
106109
}
110+
#if !TARGET_OS_OSX // [macOS]
107111
super.contentOffset = CGPointMake(
108112
RCTSanitizeNaNValue(contentOffset.x, @"scrollView.contentOffset.x"),
109113
RCTSanitizeNaNValue(contentOffset.y, @"scrollView.contentOffset.y"));
114+
#else // [macOS
115+
if (!NSEqualPoints(contentOffset, self.documentVisibleRect.origin)) {
116+
[self.contentView scrollToPoint:contentOffset];
117+
[self reflectScrolledClipView:self.contentView];
118+
}
119+
#endif // macOS]
110120
}
111121

112122
- (void)setFrame:(CGRect)frame
113123
{
124+
#if !TARGET_OS_OSX // [macOS]
114125
[super setFrame:frame];
115126
[self centerContentIfNeeded];
127+
#else // [macOS
128+
// Preserving and revalidating `contentOffset`.
129+
CGPoint originalOffset = self.contentOffset;
130+
131+
[super setFrame:frame];
132+
133+
UIEdgeInsets contentInset = self.contentInset;
134+
CGSize contentSize = self.contentSize;
135+
136+
// If contentSize has not been measured yet we can't check bounds.
137+
if (CGSizeEqualToSize(contentSize, CGSizeZero)) {
138+
self.contentOffset = originalOffset;
139+
} else {
140+
CGSize boundsSize = self.bounds.size;
141+
CGFloat xMaxOffset = contentSize.width - boundsSize.width + contentInset.right;
142+
CGFloat yMaxOffset = contentSize.height - boundsSize.height + contentInset.bottom;
143+
// Make sure offset doesn't exceed bounds. This can happen on screen rotation.
144+
if ((originalOffset.x >= -contentInset.left) && (originalOffset.x <= xMaxOffset) &&
145+
(originalOffset.y >= -contentInset.top) && (originalOffset.y <= yMaxOffset)) {
146+
return;
147+
}
148+
self.contentOffset = CGPointMake(
149+
MAX(-contentInset.left, MIN(xMaxOffset, originalOffset.x)),
150+
MAX(-contentInset.top, MIN(yMaxOffset, originalOffset.y)));
151+
}
152+
#endif // macOS]
153+
}
154+
155+
#if TARGET_OS_OSX // [macOS
156+
- (NSSize)contentSize
157+
{
158+
if (!self.documentView) {
159+
return [super contentSize];
160+
}
161+
162+
return self.documentView.frame.size;
163+
}
164+
165+
- (void)setContentOffset:(CGPoint)contentOffset animated:(BOOL)animated
166+
{
167+
if (animated) {
168+
[NSAnimationContext beginGrouping];
169+
[[NSAnimationContext currentContext] setDuration:0.3];
170+
[[self.contentView animator] setBoundsOrigin:contentOffset];
171+
[NSAnimationContext endGrouping];
172+
} else {
173+
self.contentOffset = contentOffset;
174+
}
116175
}
117176

177+
- (void)zoomToRect:(CGRect)rect animated:(BOOL)animated
178+
{
179+
[self magnifyToFitRect:rect];
180+
}
181+
182+
- (void)flashScrollIndicators
183+
{
184+
[self flashScrollers];
185+
}
186+
#endif // macOS]
187+
118188
- (void)didAddSubview:(RCTPlatformView *)subview // [macOS]
119189
{
120190
[super didAddSubview:subview];

packages/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.h

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,10 @@ NS_ASSUME_NONNULL_BEGIN
5454
@property (nonatomic, strong, readonly)
5555
RCTGenericDelegateSplitter<id<RCTUIScrollViewDelegate>> *scrollViewDelegateSplitter;
5656

57+
#if TARGET_OS_OSX // [macOS
58+
@property (nonatomic, assign) UIEdgeInsets contentInset;
59+
#endif // macOS]
60+
5761
@end
5862

5963
/*

packages/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm

Lines changed: 68 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,15 @@ @interface RCTScrollViewComponentView () <
9595
RCTScrollableProtocol,
9696
RCTEnhancedScrollViewOverridingDelegate>
9797

98+
- (void)_preserveContentOffsetIfNeededWithBlock:(void (^)())block;
99+
- (void)_remountChildrenIfNeeded;
100+
- (void)_remountChildren;
101+
- (void)_forceDispatchNextScrollEvent;
102+
- (void)_handleScrollEndIfNeeded;
103+
- (void)_handleFinishedScrolling:(RCTUIScrollView *)scrollView;
104+
- (void)_prepareForMaintainVisibleScrollPosition;
105+
- (void)_adjustForMaintainVisibleContentPosition;
106+
98107
@end
99108

100109
@implementation RCTScrollViewComponentView {
@@ -151,7 +160,7 @@ - (instancetype)initWithFrame:(CGRect)frame
151160
_containerView.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight;
152161
[_scrollView setDocumentView:_containerView];
153162
#endif // macOS]
154-
163+
155164
#if !TARGET_OS_OSX // [macOS]
156165
[self.scrollViewDelegateSplitter addDelegate:self];
157166
#endif // [macOS]
@@ -279,6 +288,19 @@ static inline UIViewAnimationOptions animationOptionsWithCurve(UIViewAnimationCu
279288
}
280289
#endif
281290

291+
#if TARGET_OS_OSX // [macOS
292+
- (void)setContentInset:(UIEdgeInsets)contentInset
293+
{
294+
if (UIEdgeInsetsEqualToEdgeInsets(contentInset, _contentInset)) {
295+
return;
296+
}
297+
298+
_contentInset = contentInset;
299+
_scrollView.contentInset = contentInset;
300+
_scrollView.scrollIndicatorInsets = contentInset;
301+
}
302+
#endif // macOS]
303+
282304
- (RCTGenericDelegateSplitter<id<RCTUIScrollViewDelegate>> *)scrollViewDelegateSplitter
283305
{
284306
return ((RCTEnhancedScrollView *)_scrollView).delegateSplitter;
@@ -408,7 +430,11 @@ - (void)updateProps:(const Props::Shared &)props oldProps:(const Props::Shared &
408430
MAP_SCROLL_VIEW_PROP(zoomScale);
409431

410432
if (oldScrollViewProps.contentInset != newScrollViewProps.contentInset) {
433+
#if !TARGET_OS_OSX // [macOS]
411434
_scrollView.contentInset = RCTUIEdgeInsetsFromEdgeInsets(newScrollViewProps.contentInset);
435+
#else // [macOS
436+
self.contentInset = RCTUIEdgeInsetsFromEdgeInsets(newScrollViewProps.contentInset);
437+
#endif // macOS]
412438
}
413439

414440
RCTEnhancedScrollView *scrollView = (RCTEnhancedScrollView *)_scrollView;
@@ -452,7 +478,7 @@ - (void)updateProps:(const Props::Shared &)props oldProps:(const Props::Shared &
452478
_shouldUpdateContentInsetAdjustmentBehavior = NO;
453479
}
454480
#endif // [macOS]
455-
481+
456482
MAP_SCROLL_VIEW_PROP(disableIntervalMomentum);
457483
MAP_SCROLL_VIEW_PROP(snapToInterval);
458484

@@ -673,6 +699,22 @@ - (void)prepareForRecycle
673699
_firstVisibleView = nil;
674700
}
675701

702+
#if TARGET_OS_OSX // [macOS
703+
#pragma mark - NSScrollView scroll notification
704+
705+
- (void)scrollViewDocumentViewBoundsDidChange:(__unused NSNotification *)notification
706+
{
707+
RCTEnhancedScrollView *scrollView = _scrollView;
708+
709+
if (scrollView.centerContent) {
710+
// Update content centering through contentOffset setter
711+
[scrollView setContentOffset:scrollView.contentOffset];
712+
}
713+
714+
[self scrollViewDidScroll:scrollView];
715+
}
716+
#endif // macOS]
717+
676718
#pragma mark - UIScrollViewDelegate
677719

678720
#if !TARGET_OS_OSX // [macOS]
@@ -816,6 +858,22 @@ - (void)viewDidMoveToWindow // [macOS]
816858
[super viewDidMoveToWindow];
817859
#endif // [macOS]
818860

861+
#if TARGET_OS_OSX // [macOS
862+
NSNotificationCenter *defaultCenter = [NSNotificationCenter defaultCenter];
863+
if (self.window == nil) {
864+
// Unregister scrollview's clipview bounds change notifications
865+
[defaultCenter removeObserver:self
866+
name:NSViewBoundsDidChangeNotification
867+
object:_scrollView.contentView];
868+
} else {
869+
// Register for scrollview's clipview bounds change notifications so we can track scrolling
870+
[defaultCenter addObserver:self
871+
selector:@selector(scrollViewDocumentViewBoundsDidChange:)
872+
name:NSViewBoundsDidChangeNotification
873+
object:_scrollView.contentView]; // NSClipView
874+
}
875+
#endif // macOS]
876+
819877
if (!self.window) {
820878
// The view is being removed, ensure that the scroll end event is dispatched
821879
[self _handleScrollEndIfNeeded];
@@ -898,6 +956,8 @@ - (void)flashScrollIndicators
898956
{
899957
#if !TARGET_OS_OSX // [macOS]
900958
[_scrollView flashScrollIndicators];
959+
#else // [macOS
960+
[(RCTEnhancedScrollView *)_scrollView flashScrollers];
901961
#endif // [macOS]
902962
}
903963

@@ -997,7 +1057,11 @@ - (void)scrollToOffset:(CGPoint)offset animated:(BOOL)animated
9971057

9981058
[self _forceDispatchNextScrollEvent];
9991059

1060+
#if !TARGET_OS_OSX // [macOS]
10001061
[_scrollView setContentOffset:offset animated:animated];
1062+
#else // [macOS
1063+
[(RCTEnhancedScrollView *)_scrollView setContentOffset:offset animated:animated];
1064+
#endif // macOS]
10011065

10021066
if (!animated) {
10031067
// When not animated, the expected workflow in ``scrollViewDidEndScrollingAnimation`` after scrolling is not going
@@ -1010,6 +1074,8 @@ - (void)zoomToRect:(CGRect)rect animated:(BOOL)animated
10101074
{
10111075
#if !TARGET_OS_OSX // [macOS]
10121076
[_scrollView zoomToRect:rect animated:animated];
1077+
#else // [macOS
1078+
[(RCTEnhancedScrollView *)_scrollView zoomToRect:rect animated:animated];
10131079
#endif // [macOS]
10141080
}
10151081

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

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -167,7 +167,7 @@ - (void)traitCollectionDidChange:(UITraitCollection *)previousTraitCollection
167167
[self invalidateLayer];
168168
}
169169
}
170-
#else // [macOS SAAD
170+
#else // [macOS
171171
- (void)viewDidChangeEffectiveAppearance
172172
{
173173
[super viewDidChangeEffectiveAppearance];
@@ -1716,7 +1716,6 @@ - (BOOL)didActivateAccessibilityCustomAction:(UIAccessibilityCustomAction *)acti
17161716
}
17171717

17181718
#if TARGET_OS_OSX // [macOS
1719-
17201719
- (void)handleCommand:(const NSString *)commandName args:(const NSArray *)args
17211720
{
17221721
if ([commandName isEqualToString:@"focus"]) {

packages/react-native/React/Views/ScrollView/RCTScrollView.m

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -328,6 +328,7 @@ @implementation RCTScrollView {
328328
BOOL _allowNextScrollNoMatterWhat;
329329
#if TARGET_OS_OSX // [macOS
330330
BOOL _notifyDidScroll;
331+
BOOL _disableScrollEvents;
331332
NSPoint _lastScrollPosition;
332333
#endif // macOS]
333334
CGRect _lastClippedToRect;
@@ -570,8 +571,28 @@ - (void)setRemoveClippedSubviews:(__unused BOOL)removeClippedSubviews
570571

571572
- (void)setFrame:(CGRect)frame
572573
{
574+
#if !TARGET_OS_OSX // [macOS]
575+
[super setFrame:frame];
576+
#else // [macOS
577+
/**
578+
* Setting the frame on the scroll view will randomly generate between 0 and 4 scroll events. These events happen
579+
* during the layout phase of the view which generates layout notifications that are sent through the bridge.
580+
* Because the bridge is heavily used, the scroll events are throttled and reach the JS thread with a random delay.
581+
* Because the scroll event stores the clip and content view size, delayed scroll events will submit stale layout
582+
* information that can break virtual list implemenations.
583+
* By disabling scroll events during the execution of the setFrame method and scheduling one notification on
584+
* the next run loop, we can mitigate the delayed scroll event by sending it at a time where the bridge is not busy.
585+
*/
586+
_disableScrollEvents = YES;
573587
[super setFrame:frame];
588+
_disableScrollEvents = NO;
589+
590+
if (self.window != nil && !self.window.inLiveResize) {
591+
[self performSelector:@selector(scrollViewDocumentViewBoundsDidChange:) withObject:nil afterDelay:0];
592+
}
593+
#endif // macOS]
574594
[self centerContentIfNeeded];
595+
575596
}
576597

577598
- (void)insertReactSubview:(RCTPlatformView *)view atIndex:(NSInteger)atIndex // [macOS]
@@ -867,6 +888,10 @@ - (void)flashScrollIndicators
867888
#if TARGET_OS_OSX // [macOS
868889
- (void)scrollViewDocumentViewBoundsDidChange:(__unused NSNotification *)notification
869890
{
891+
if (_disableScrollEvents) {
892+
return;
893+
}
894+
870895
if (_scrollView.centerContent) {
871896
// contentOffset setter dynamically centers content when _centerContent == YES
872897
[_scrollView setContentOffset:_scrollView.contentOffset];

0 commit comments

Comments
 (0)