Skip to content

Commit e7202bc

Browse files
committed
Fix macOS ScrollView resize and content inset behavior
1 parent e7767ac commit e7202bc

8 files changed

Lines changed: 185 additions & 6 deletions

File tree

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: 73 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,12 @@ - (instancetype)initWithFrame:(CGRect)frame
4141
// because this attribute affects a position of vertical scrollbar; we don't want this
4242
// scrollbar flip because we also flip it with whole `UIScrollView` flip.
4343
self.semanticContentAttribute = UISemanticContentAttributeForceLeftToRight;
44-
#endif // [macOS]
44+
#else // [macOS
45+
self.automaticallyAdjustsContentInsets = NO;
46+
self.hasHorizontalScroller = YES;
47+
self.hasVerticalScroller = YES;
48+
self.autohidesScrollers = YES;
49+
#endif // macOS]
4550

4651
__weak __typeof(self) weakSelf = self;
4752
_delegateSplitter = [[RCTGenericDelegateSplitter alloc] initWithDelegateUpdateBlock:^(id delegate) {
@@ -98,16 +103,83 @@ - (void)setContentOffset:(CGPoint)contentOffset
98103
if (_isSetContentOffsetDisabled) {
99104
return;
100105
}
106+
#if !TARGET_OS_OSX // [macOS]
101107
super.contentOffset = CGPointMake(
102108
RCTSanitizeNaNValue(contentOffset.x, @"scrollView.contentOffset.x"),
103109
RCTSanitizeNaNValue(contentOffset.y, @"scrollView.contentOffset.y"));
110+
#else // [macOS
111+
if (!NSEqualPoints(contentOffset, self.documentVisibleRect.origin)) {
112+
[self.contentView scrollToPoint:contentOffset];
113+
[self reflectScrolledClipView:self.contentView];
114+
}
115+
#endif // macOS]
104116
}
105117

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

112184
- (void)didAddSubview:(RCTPlatformView *)subview // [macOS]
113185
{

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: 60 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -151,7 +151,7 @@ - (instancetype)initWithFrame:(CGRect)frame
151151
_containerView.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight;
152152
[_scrollView setDocumentView:_containerView];
153153
#endif // macOS]
154-
154+
155155
#if !TARGET_OS_OSX // [macOS]
156156
[self.scrollViewDelegateSplitter addDelegate:self];
157157
#endif // [macOS]
@@ -279,6 +279,19 @@ static inline UIViewAnimationOptions animationOptionsWithCurve(UIViewAnimationCu
279279
}
280280
#endif
281281

282+
#if TARGET_OS_OSX // [macOS
283+
- (void)setContentInset:(UIEdgeInsets)contentInset
284+
{
285+
if (UIEdgeInsetsEqualToEdgeInsets(contentInset, _contentInset)) {
286+
return;
287+
}
288+
289+
_contentInset = contentInset;
290+
_scrollView.contentInset = contentInset;
291+
_scrollView.scrollIndicatorInsets = contentInset;
292+
}
293+
#endif // macOS]
294+
282295
- (RCTGenericDelegateSplitter<id<RCTUIScrollViewDelegate>> *)scrollViewDelegateSplitter
283296
{
284297
return ((RCTEnhancedScrollView *)_scrollView).delegateSplitter;
@@ -408,7 +421,11 @@ - (void)updateProps:(const Props::Shared &)props oldProps:(const Props::Shared &
408421
MAP_SCROLL_VIEW_PROP(zoomScale);
409422

410423
if (oldScrollViewProps.contentInset != newScrollViewProps.contentInset) {
424+
#if !TARGET_OS_OSX // [macOS]
411425
_scrollView.contentInset = RCTUIEdgeInsetsFromEdgeInsets(newScrollViewProps.contentInset);
426+
#else // [macOS
427+
self.contentInset = RCTUIEdgeInsetsFromEdgeInsets(newScrollViewProps.contentInset);
428+
#endif // macOS]
412429
}
413430

414431
RCTEnhancedScrollView *scrollView = (RCTEnhancedScrollView *)_scrollView;
@@ -452,7 +469,7 @@ - (void)updateProps:(const Props::Shared &)props oldProps:(const Props::Shared &
452469
_shouldUpdateContentInsetAdjustmentBehavior = NO;
453470
}
454471
#endif // [macOS]
455-
472+
456473
MAP_SCROLL_VIEW_PROP(disableIntervalMomentum);
457474
MAP_SCROLL_VIEW_PROP(snapToInterval);
458475

@@ -673,6 +690,22 @@ - (void)prepareForRecycle
673690
_firstVisibleView = nil;
674691
}
675692

693+
#if TARGET_OS_OSX // [macOS
694+
#pragma mark - NSScrollView scroll notification
695+
696+
- (void)scrollViewDocumentViewBoundsDidChange:(__unused NSNotification *)notification
697+
{
698+
RCTEnhancedScrollView *scrollView = _scrollView;
699+
700+
if (scrollView.centerContent) {
701+
// Update content centering through contentOffset setter
702+
[scrollView setContentOffset:scrollView.contentOffset];
703+
}
704+
705+
[self scrollViewDidScroll:scrollView];
706+
}
707+
#endif // macOS]
708+
676709
#pragma mark - UIScrollViewDelegate
677710

678711
#if !TARGET_OS_OSX // [macOS]
@@ -812,9 +845,25 @@ - (void)didMoveToWindow
812845
[super didMoveToWindow];
813846
#else // [macOS
814847
- (void)viewDidMoveToWindow // [macOS]
848+
#endif // [macOS]
815849
{
816850
[super viewDidMoveToWindow];
817-
#endif // [macOS]
851+
852+
#if TARGET_OS_OSX // [macOS
853+
NSNotificationCenter *defaultCenter = [NSNotificationCenter defaultCenter];
854+
if (self.window == nil) {
855+
// Unregister scrollview's clipview bounds change notifications
856+
[defaultCenter removeObserver:self
857+
name:NSViewBoundsDidChangeNotification
858+
object:_scrollView.contentView];
859+
} else {
860+
// Register for scrollview's clipview bounds change notifications so we can track scrolling
861+
[defaultCenter addObserver:self
862+
selector:@selector(scrollViewDocumentViewBoundsDidChange:)
863+
name:NSViewBoundsDidChangeNotification
864+
object:_scrollView.contentView]; // NSClipView
865+
}
866+
#endif // macOS]
818867

819868
if (!self.window) {
820869
// The view is being removed, ensure that the scroll end event is dispatched
@@ -898,6 +947,8 @@ - (void)flashScrollIndicators
898947
{
899948
#if !TARGET_OS_OSX // [macOS]
900949
[_scrollView flashScrollIndicators];
950+
#else // [macOS
951+
[(RCTEnhancedScrollView *)_scrollView flashScrollers];
901952
#endif // [macOS]
902953
}
903954

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

9981049
[self _forceDispatchNextScrollEvent];
9991050

1051+
#if !TARGET_OS_OSX // [macOS]
10001052
[_scrollView setContentOffset:offset animated:animated];
1053+
#else // [macOS
1054+
[(RCTEnhancedScrollView *)_scrollView setContentOffset:offset animated:animated];
1055+
#endif // macOS]
10011056

10021057
if (!animated) {
10031058
// When not animated, the expected workflow in ``scrollViewDidEndScrollingAnimation`` after scrolling is not going
@@ -1010,6 +1065,8 @@ - (void)zoomToRect:(CGRect)rect animated:(BOOL)animated
10101065
{
10111066
#if !TARGET_OS_OSX // [macOS]
10121067
[_scrollView zoomToRect:rect animated:animated];
1068+
#else // [macOS
1069+
[(RCTEnhancedScrollView *)_scrollView zoomToRect:rect animated:animated];
10131070
#endif // [macOS]
10141071
}
10151072

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];
@@ -1707,7 +1707,6 @@ - (BOOL)didActivateAccessibilityCustomAction:(UIAccessibilityCustomAction *)acti
17071707
}
17081708

17091709
#if TARGET_OS_OSX // [macOS
1710-
17111710
- (void)handleCommand:(const NSString *)commandName args:(const NSArray *)args
17121711
{
17131712
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)