diff --git a/packages/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm b/packages/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm index 8c309adba96c..ffd3ac869f03 100644 --- a/packages/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm +++ b/packages/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm @@ -157,7 +157,10 @@ - (instancetype)initWithFrame:(CGRect)frame #if !TARGET_OS_OSX // [macOS] [_scrollView addSubview:_containerView]; #else // [macOS - _containerView.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight; + // Do NOT set autoresizingMask on the documentView. AppKit's autoresizing + // corrupts the documentView frame during tile/resize (adding the clip view's + // size delta to the container, inflating it beyond the actual content size). + // React manages the documentView frame directly via updateState:. [_scrollView setDocumentView:_containerView]; #endif // macOS] @@ -184,6 +187,63 @@ - (void)dealloc #endif // [macOS] } +#if TARGET_OS_OSX // [macOS ++ (void)initialize +{ + if (self == [RCTScrollViewComponentView class]) { + // Pre-warm the cached scrollbar width at class load time, before any + // layout occurs. This ensures the first Yoga layout pass already knows + // the correct scrollbar dimensions — no state round-trip required. + [self _updateCachedScrollbarWidth]; + + // Observe system scrollbar style changes so we can update the cached + // value and trigger re-layout when the preference changes. + [[NSNotificationCenter defaultCenter] + addObserver:self + selector:@selector(_systemScrollerStyleDidChange:) + name:NSPreferredScrollerStyleDidChangeNotification + object:nil]; + } +} + ++ (void)_updateCachedScrollbarWidth +{ + CGFloat width = 0; + if ([NSScroller preferredScrollerStyle] == NSScrollerStyleLegacy) { + width = [NSScroller scrollerWidthForControlSize:NSControlSizeRegular + scrollerStyle:NSScrollerStyleLegacy]; + } + ScrollViewShadowNode::setSystemScrollbarWidth(static_cast(width)); +} + ++ (void)_systemScrollerStyleDidChange:(NSNotification *)notification +{ + [self _updateCachedScrollbarWidth]; +} + +- (void)_preferredScrollerStyleDidChange:(NSNotification *)notification +{ + // Update the native scroll view's scroller style and re-tile so scrollers + // are properly created/removed. + _scrollView.scrollerStyle = [NSScroller preferredScrollerStyle]; + [_scrollView tile]; + + // Force a state update to trigger shadow tree re-clone. The cloned + // ScrollViewShadowNode will read the updated cached scrollbar width + // in applyScrollbarPadding() and re-layout with correct padding. + if (_state) { + _state->updateState( + [](const ScrollViewShadowNode::ConcreteState::Data &oldData) + -> ScrollViewShadowNode::ConcreteState::SharedData { + auto newData = oldData; + // Reset contentBoundingRect to force a state difference + newData.contentBoundingRect = {}; + return std::make_shared(newData); + }); + } +} +#endif // macOS] + #if TARGET_OS_IOS - (void)_registerKeyboardListener { @@ -534,6 +594,11 @@ - (void)updateState:(const State::Shared &)state oldState:(const State::Shared & [self _preserveContentOffsetIfNeededWithBlock:^{ self->_scrollView.contentSize = contentSize; }]; + +#if TARGET_OS_OSX // [macOS + // Force the scroll view to re-evaluate which scrollers should be visible. + [_scrollView tile]; +#endif // macOS] } - (RCTPlatformView *)betterHitTest:(CGPoint)point withEvent:(UIEvent *)event // [macOS] @@ -561,6 +626,21 @@ - (RCTPlatformView *)betterHitTest:(CGPoint)point withEvent:(UIEvent *)event // return nil; } +#if TARGET_OS_OSX // [macOS + // Check if the hit lands on a scrollbar (NSScroller) BEFORE checking content + // subviews. Scrollers are subviews of the NSScrollView, not the documentView + // (_containerView). They must be checked first because content views typically + // fill the entire visible area and would otherwise swallow scroller clicks — + // for both overlay and legacy (always-visible) scrollbar styles. + if (isPointInside) { + NSPoint scrollViewPoint = [_scrollView convertPoint:point fromView:self]; + NSView *scrollViewHit = [_scrollView hitTest:scrollViewPoint]; + if ([scrollViewHit isKindOfClass:[NSScroller class]]) { + return (RCTPlatformView *)scrollViewHit; + } + } +#endif // macOS] + for (RCTPlatformView *subview in [_containerView.subviews reverseObjectEnumerator]) { // [macOS] RCTPlatformView *hitView = RCTUIViewHitTestWithEvent(subview, point, self, event); // [macOS] if (hitView) { @@ -865,12 +945,20 @@ - (void)viewDidMoveToWindow // [macOS] [defaultCenter removeObserver:self name:NSViewBoundsDidChangeNotification object:_scrollView.contentView]; + [defaultCenter removeObserver:self + name:NSPreferredScrollerStyleDidChangeNotification + object:nil]; } else { // Register for scrollview's clipview bounds change notifications so we can track scrolling [defaultCenter addObserver:self selector:@selector(scrollViewDocumentViewBoundsDidChange:) name:NSViewBoundsDidChangeNotification object:_scrollView.contentView]; // NSClipView + // Observe system scrollbar style changes so we can update scrollbar insets for Yoga layout + [defaultCenter addObserver:self + selector:@selector(_preferredScrollerStyleDidChange:) + name:NSPreferredScrollerStyleDidChangeNotification + object:nil]; } #endif // macOS] diff --git a/packages/react-native/React/Views/ScrollView/MacOS/RCTScrollContentLocalData.h b/packages/react-native/React/Views/ScrollView/MacOS/RCTScrollContentLocalData.h deleted file mode 100644 index 3230502b5701..000000000000 --- a/packages/react-native/React/Views/ScrollView/MacOS/RCTScrollContentLocalData.h +++ /dev/null @@ -1,27 +0,0 @@ -/** - * Copyright (c) Facebook, Inc. and its affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */ - -// [macOS] - -#if TARGET_OS_OSX -#import - -NS_ASSUME_NONNULL_BEGIN - -@interface RCTScrollContentLocalData : NSObject - -@property (nonatomic, assign) CGFloat horizontalScrollerHeight; -@property (nonatomic, assign) CGFloat verticalScrollerWidth; - -- (instancetype)initWithVerticalScrollerWidth:(CGFloat)verticalScrollerWidth - horizontalScrollerHeight:(CGFloat)horizontalScrollerHeight; - -@end - -NS_ASSUME_NONNULL_END - -#endif // [macOS] diff --git a/packages/react-native/React/Views/ScrollView/MacOS/RCTScrollContentLocalData.m b/packages/react-native/React/Views/ScrollView/MacOS/RCTScrollContentLocalData.m deleted file mode 100644 index d2da7e0a6071..000000000000 --- a/packages/react-native/React/Views/ScrollView/MacOS/RCTScrollContentLocalData.m +++ /dev/null @@ -1,26 +0,0 @@ -/** - * Copyright (c) Facebook, Inc. and its affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */ - -// [macOS] - -#if TARGET_OS_OSX -#import "RCTScrollContentLocalData.h" - -@implementation RCTScrollContentLocalData - -- (instancetype)initWithVerticalScrollerWidth:(CGFloat)verticalScrollerWidth - horizontalScrollerHeight:(CGFloat)horizontalScrollerHeight; -{ - if (self = [super init]) { - _verticalScrollerWidth = verticalScrollerWidth; - _horizontalScrollerHeight = horizontalScrollerHeight; - } - return self; -} - -@end -#endif \ No newline at end of file diff --git a/packages/react-native/React/Views/ScrollView/RCTScrollContentShadowView.m b/packages/react-native/React/Views/ScrollView/RCTScrollContentShadowView.m index e62bd7287ab5..c892556c6aa1 100644 --- a/packages/react-native/React/Views/ScrollView/RCTScrollContentShadowView.m +++ b/packages/react-native/React/Views/ScrollView/RCTScrollContentShadowView.m @@ -9,28 +9,10 @@ #import -#if TARGET_OS_OSX // [macOS -#import "RCTScrollContentLocalData.h" -#endif // macOS] - #import "RCTUtils.h" @implementation RCTScrollContentShadowView -#if TARGET_OS_OSX // [macOS -- (void)setLocalData:(RCTScrollContentLocalData *)localData -{ - RCTAssert( - [localData isKindOfClass:[RCTScrollContentLocalData class]], - @"Local data object for `RCTScrollContentView` must be `RCTScrollContentLocalData` instance."); - - super.marginEnd = (YGValue){localData.verticalScrollerWidth, YGUnitPoint}; - super.marginBottom = (YGValue){localData.horizontalScrollerHeight, YGUnitPoint}; - - [self didSetProps:@[@"marginEnd", @"marginBottom"]]; -} -#endif // macOS] - - (void)layoutWithMetrics:(RCTLayoutMetrics)layoutMetrics layoutContext:(RCTLayoutContext)layoutContext { if (layoutMetrics.layoutDirection == UIUserInterfaceLayoutDirectionRightToLeft) { diff --git a/packages/react-native/React/Views/ScrollView/RCTScrollContentView.m b/packages/react-native/React/Views/ScrollView/RCTScrollContentView.m index 134d7217f474..8d856848883d 100644 --- a/packages/react-native/React/Views/ScrollView/RCTScrollContentView.m +++ b/packages/react-native/React/Views/ScrollView/RCTScrollContentView.m @@ -10,11 +10,6 @@ #import #import -#if TARGET_OS_OSX // [macOS -#import -#import "RCTScrollContentLocalData.h" -#endif // macOS] - #import "RCTScrollView.h" @implementation RCTScrollContentView @@ -44,22 +39,7 @@ - (void)reactSetFrame:(CGRect)frame [scrollView updateContentSizeIfNeeded]; #if TARGET_OS_OSX // [macOS - // On macOS scroll indicators may float over the content view like they do in iOS - // or depending on system preferences they may be outside of the content view - // which means the clip view will be smaller than the scroll view itself. - // In such cases the content view layout must shrink accordingly otherwise - // the contents will overflow causing the scroll indicators to appear unnecessarily. NSScrollView *platformScrollView = [scrollView scrollView]; - if ([platformScrollView scrollerStyle] == NSScrollerStyleLegacy) { - BOOL contentHasHeight = platformScrollView.contentSize.height > 0; - CGFloat horizontalScrollerHeight = ([platformScrollView hasHorizontalScroller] && contentHasHeight) ? NSHeight([[platformScrollView horizontalScroller] frame]) : 0; - CGFloat verticalScrollerWidth = [platformScrollView hasVerticalScroller] ? NSWidth([[platformScrollView verticalScroller] frame]) : 0; - - RCTScrollContentLocalData *localData = [[RCTScrollContentLocalData alloc] initWithVerticalScrollerWidth:verticalScrollerWidth horizontalScrollerHeight:horizontalScrollerHeight]; - - [[[scrollView bridge] uiManager] setLocalData:localData forView:self]; - } - if ([platformScrollView accessibilityRole] == NSAccessibilityTableRole) { NSMutableArray *subViews = [[NSMutableArray alloc] initWithCapacity:[[self subviews] count]]; for (NSView *view in [self subviews]) { diff --git a/packages/react-native/React/Views/ScrollView/RCTScrollView.m b/packages/react-native/React/Views/ScrollView/RCTScrollView.m index bbf95176f839..37dae46434aa 100644 --- a/packages/react-native/React/Views/ScrollView/RCTScrollView.m +++ b/packages/react-native/React/Views/ScrollView/RCTScrollView.m @@ -1431,6 +1431,14 @@ - (void)keyUp:(NSEvent *)event { - (void)preferredScrollerStyleDidChange:(__unused NSNotification *)notification { RCT_SEND_SCROLL_EVENT(onPreferredScrollerStyleDidChange, (@{ @"preferredScrollerStyle": RCTStringForScrollerStyle([NSScroller preferredScrollerStyle])})); + + // When the system scrollbar style changes, force the scroll view to adopt the + // new style, re-tile, and trigger a content size update. The ScrollView's + // shadow view (RCTScrollViewShadowView) will detect the new scroller style on + // the next layout pass and update its padding accordingly. + _scrollView.scrollerStyle = [NSScroller preferredScrollerStyle]; + [_scrollView tile]; + [self updateContentSizeIfNeeded]; } #endif // macOS] diff --git a/packages/react-native/React/Views/ScrollView/RCTScrollViewManager.m b/packages/react-native/React/Views/ScrollView/RCTScrollViewManager.m index fbd215c2b3d8..dd1c63b8f8d0 100644 --- a/packages/react-native/React/Views/ScrollView/RCTScrollViewManager.m +++ b/packages/react-native/React/Views/ScrollView/RCTScrollViewManager.m @@ -12,6 +12,54 @@ #import "RCTShadowView.h" #import "RCTUIManager.h" +#if TARGET_OS_OSX // [macOS +// Custom shadow view for ScrollView on macOS. Applies paddingEnd to account for +// legacy (always-visible) scrollbar width so Yoga lays out children within the +// actual visible area (clip view), not the full ScrollView frame. +@interface RCTScrollViewShadowView : RCTShadowView +@end + +@implementation RCTScrollViewShadowView + +- (instancetype)init +{ + if (self = [super init]) { + // Set scrollbar padding immediately so it's in place before the first Yoga + // layout pass. Without this, the first pass uses full width, then a second + // pass corrects it — causing a visible flicker. + [self applyScrollbarPadding]; + } + return self; +} + +- (void)applyScrollbarPadding +{ + CGFloat verticalScrollerWidth = 0; + if ([NSScroller preferredScrollerStyle] == NSScrollerStyleLegacy) { + verticalScrollerWidth = [NSScroller scrollerWidthForControlSize:NSControlSizeRegular + scrollerStyle:NSScrollerStyleLegacy]; + } + + YGValue currentPaddingEnd = super.paddingEnd; + BOOL needsUpdate = + (currentPaddingEnd.unit != YGUnitPoint || currentPaddingEnd.value != verticalScrollerWidth); + + if (needsUpdate) { + super.paddingEnd = (YGValue){verticalScrollerWidth, YGUnitPoint}; + [self didSetProps:@[@"paddingEnd"]]; + } +} + +- (void)layoutWithMetrics:(RCTLayoutMetrics)layoutMetrics layoutContext:(RCTLayoutContext)layoutContext +{ + // Re-check on every layout pass in case the system scroller style changed. + [self applyScrollbarPadding]; + [super layoutWithMetrics:layoutMetrics layoutContext:layoutContext]; +} + +@end +#endif // macOS] + #if !TARGET_OS_OSX // [macOS] @implementation RCTConvert (UIScrollView) @@ -62,6 +110,13 @@ - (RCTPlatformView *)view // [macOS] return [[RCTScrollView alloc] initWithEventDispatcher:self.bridge.eventDispatcher]; } +#if TARGET_OS_OSX // [macOS +- (RCTShadowView *)shadowView +{ + return [RCTScrollViewShadowView new]; +} +#endif // macOS] + RCT_EXPORT_VIEW_PROPERTY(alwaysBounceHorizontal, BOOL) RCT_EXPORT_VIEW_PROPERTY(alwaysBounceVertical, BOOL) RCT_EXPORT_NOT_OSX_VIEW_PROPERTY(bounces, BOOL) // [macOS] diff --git a/packages/react-native/ReactCommon/react/renderer/components/scrollview/ScrollViewShadowNode.cpp b/packages/react-native/ReactCommon/react/renderer/components/scrollview/ScrollViewShadowNode.cpp index d0ee75cd73fd..a86c3d996e98 100644 --- a/packages/react-native/ReactCommon/react/renderer/components/scrollview/ScrollViewShadowNode.cpp +++ b/packages/react-native/ReactCommon/react/renderer/components/scrollview/ScrollViewShadowNode.cpp @@ -7,13 +7,89 @@ #include "ScrollViewShadowNode.h" +#include #include +#include #include +#include namespace facebook::react { const char ScrollViewComponentName[] = "ScrollView"; +// [macOS] Cached system scrollbar width, set from native code at startup and +// when the system "Show scroll bars" preference changes. Read synchronously +// during Yoga layout so the first pass is immediately correct. +static std::atomic systemScrollbarWidth_{0.0f}; + +void ScrollViewShadowNode::setSystemScrollbarWidth(Float width) { + systemScrollbarWidth_.store( + static_cast(width), std::memory_order_relaxed); +} + +Float ScrollViewShadowNode::getSystemScrollbarWidth() { + return static_cast( + systemScrollbarWidth_.load(std::memory_order_relaxed)); +} + +ScrollViewShadowNode::ScrollViewShadowNode( + const ShadowNodeFragment& fragment, + const ShadowNodeFamily::Shared& family, + ShadowNodeTraits traits) + : ConcreteViewShadowNode(fragment, family, traits) { + applyScrollbarPadding(); +} + +ScrollViewShadowNode::ScrollViewShadowNode( + const ShadowNode& sourceShadowNode, + const ShadowNodeFragment& fragment) + : ConcreteViewShadowNode(sourceShadowNode, fragment) { + applyScrollbarPadding(); +} + +void ScrollViewShadowNode::applyScrollbarPadding() { + // [macOS] On macOS, legacy (always-visible) scrollbars sit inside the + // NSScrollView frame and reduce the NSClipView's visible area. Without + // adjustment, Yoga lays out children against the full ScrollView width, + // causing content to overflow the visible area. + // + // Read the scrollbar width directly from the cached system value (set by + // native code via setSystemScrollbarWidth). This is synchronous — no state + // round-trip — so the first layout pass is immediately correct. + // + // IMPORTANT: We read the base padding from props (not the current Yoga + // style) to avoid double-counting. When a shadow node is cloned, the Yoga + // style is copied from the source node which may already include scrollbar + // padding from a previous applyScrollbarPadding() call. + Float scrollbarWidth = getSystemScrollbarWidth(); + + const auto& props = static_cast(*props_); + const auto& propsStyle = props.yogaStyle; + + auto style = yogaNode_.style(); + bool changed = false; + + // Compute target right padding: base from props + scrollbar width + { + auto basePadding = propsStyle.padding(yoga::Edge::Right); + Float baseValue = 0; + if (basePadding.isDefined() && basePadding.isPoints()) { + baseValue = basePadding.value().unwrap(); + } + Float targetValue = baseValue + scrollbarWidth; + auto targetPadding = yoga::StyleLength::points(targetValue); + if (targetPadding != style.padding(yoga::Edge::Right)) { + style.setPadding(yoga::Edge::Right, targetPadding); + changed = true; + } + } + + if (changed) { + yogaNode_.setStyle(style); + yogaNode_.setDirty(true); + } +} + void ScrollViewShadowNode::updateStateIfNeeded() { ensureUnsealed(); diff --git a/packages/react-native/ReactCommon/react/renderer/components/scrollview/ScrollViewShadowNode.h b/packages/react-native/ReactCommon/react/renderer/components/scrollview/ScrollViewShadowNode.h index a0fbcf4ca1e9..702622a112ac 100644 --- a/packages/react-native/ReactCommon/react/renderer/components/scrollview/ScrollViewShadowNode.h +++ b/packages/react-native/ReactCommon/react/renderer/components/scrollview/ScrollViewShadowNode.h @@ -27,13 +27,27 @@ class ScrollViewShadowNode final : public ConcreteViewShadowNode< ScrollViewEventEmitter, ScrollViewState> { public: - using ConcreteViewShadowNode::ConcreteViewShadowNode; + ScrollViewShadowNode( + const ShadowNodeFragment& fragment, + const ShadowNodeFamily::Shared& family, + ShadowNodeTraits traits); + ScrollViewShadowNode( + const ShadowNode& sourceShadowNode, + const ShadowNodeFragment& fragment); static ScrollViewState initialStateData( const Props::Shared& props, const ShadowNodeFamily::Shared& family, const ComponentDescriptor& componentDescriptor); + // [macOS] Set the system scrollbar width (e.g. legacy scroller width on + // macOS). Called from native code at startup and when the system "Show scroll + // bars" preference changes. The value is read synchronously during Yoga + // layout, so the first layout pass is immediately correct — no state + // round-trip required. Thread-safe (uses atomic store/load). + static void setSystemScrollbarWidth(Float width); + static Float getSystemScrollbarWidth(); + #pragma mark - LayoutableShadowNode void layout(LayoutContext layoutContext) override; @@ -42,6 +56,7 @@ class ScrollViewShadowNode final : public ConcreteViewShadowNode< private: void updateStateIfNeeded(); void updateScrollContentOffsetIfNeeded(); + void applyScrollbarPadding(); }; } // namespace facebook::react