Skip to content

Commit 04e8006

Browse files
Saadnajmiclaude
andcommitted
[macOS] Fix ScrollView layout with legacy (always-visible) scrollbars (Fabric)
On macOS, legacy scrollbars sit inside the NSScrollView frame and reduce the NSClipView's visible area. React's layout system measures children against the full frame, causing content to overflow behind the scrollbar. Following the pattern from facebook#53247 (Switch), this fix reads the scrollbar width synchronously during the Yoga layout pass via a cached atomic value — no state round-trip, so the first layout is immediately correct. Key changes: - Add ScrollViewShadowNode::setSystemScrollbarWidth() / getSystemScrollbarWidth() backed by a static atomic, called from native code at class load time - applyScrollbarPadding() reads the cached value directly (not from state) - Remove scrollbarTrailingWidth/scrollbarBottomHeight from ScrollViewState - Remove autoresizingMask from documentView (prevents AppKit frame corruption) - Move NSScroller hit test check before subview loop (fixes scrollbar clicks) - Observe NSPreferredScrollerStyleDidChangeNotification for runtime changes Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent cb0f4ad commit 04e8006

File tree

3 files changed

+181
-2
lines changed

3 files changed

+181
-2
lines changed

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

Lines changed: 89 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -157,7 +157,10 @@ - (instancetype)initWithFrame:(CGRect)frame
157157
#if !TARGET_OS_OSX // [macOS]
158158
[_scrollView addSubview:_containerView];
159159
#else // [macOS
160-
_containerView.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight;
160+
// Do NOT set autoresizingMask on the documentView. AppKit's autoresizing
161+
// corrupts the documentView frame during tile/resize (adding the clip view's
162+
// size delta to the container, inflating it beyond the actual content size).
163+
// React manages the documentView frame directly via updateState:.
161164
[_scrollView setDocumentView:_containerView];
162165
#endif // macOS]
163166

@@ -184,6 +187,63 @@ - (void)dealloc
184187
#endif // [macOS]
185188
}
186189

190+
#if TARGET_OS_OSX // [macOS
191+
+ (void)initialize
192+
{
193+
if (self == [RCTScrollViewComponentView class]) {
194+
// Pre-warm the cached scrollbar width at class load time, before any
195+
// layout occurs. This ensures the first Yoga layout pass already knows
196+
// the correct scrollbar dimensions — no state round-trip required.
197+
[self _updateCachedScrollbarWidth];
198+
199+
// Observe system scrollbar style changes so we can update the cached
200+
// value and trigger re-layout when the preference changes.
201+
[[NSNotificationCenter defaultCenter]
202+
addObserver:self
203+
selector:@selector(_systemScrollerStyleDidChange:)
204+
name:NSPreferredScrollerStyleDidChangeNotification
205+
object:nil];
206+
}
207+
}
208+
209+
+ (void)_updateCachedScrollbarWidth
210+
{
211+
CGFloat width = 0;
212+
if ([NSScroller preferredScrollerStyle] == NSScrollerStyleLegacy) {
213+
width = [NSScroller scrollerWidthForControlSize:NSControlSizeRegular
214+
scrollerStyle:NSScrollerStyleLegacy];
215+
}
216+
ScrollViewShadowNode::setSystemScrollbarWidth(static_cast<Float>(width));
217+
}
218+
219+
+ (void)_systemScrollerStyleDidChange:(NSNotification *)notification
220+
{
221+
[self _updateCachedScrollbarWidth];
222+
}
223+
224+
- (void)_preferredScrollerStyleDidChange:(NSNotification *)notification
225+
{
226+
// Update the native scroll view's scroller style and re-tile so scrollers
227+
// are properly created/removed.
228+
_scrollView.scrollerStyle = [NSScroller preferredScrollerStyle];
229+
[_scrollView tile];
230+
231+
// Force a state update to trigger shadow tree re-clone. The cloned
232+
// ScrollViewShadowNode will read the updated cached scrollbar width
233+
// in applyScrollbarPadding() and re-layout with correct padding.
234+
if (_state) {
235+
_state->updateState(
236+
[](const ScrollViewShadowNode::ConcreteState::Data &oldData)
237+
-> ScrollViewShadowNode::ConcreteState::SharedData {
238+
auto newData = oldData;
239+
// Reset contentBoundingRect to force a state difference
240+
newData.contentBoundingRect = {};
241+
return std::make_shared<const ScrollViewShadowNode::ConcreteState::Data>(newData);
242+
});
243+
}
244+
}
245+
#endif // macOS]
246+
187247
#if TARGET_OS_IOS
188248
- (void)_registerKeyboardListener
189249
{
@@ -534,6 +594,11 @@ - (void)updateState:(const State::Shared &)state oldState:(const State::Shared &
534594
[self _preserveContentOffsetIfNeededWithBlock:^{
535595
self->_scrollView.contentSize = contentSize;
536596
}];
597+
598+
#if TARGET_OS_OSX // [macOS
599+
// Force the scroll view to re-evaluate which scrollers should be visible.
600+
[_scrollView tile];
601+
#endif // macOS]
537602
}
538603

539604
- (RCTPlatformView *)betterHitTest:(CGPoint)point withEvent:(UIEvent *)event // [macOS]
@@ -561,6 +626,21 @@ - (RCTPlatformView *)betterHitTest:(CGPoint)point withEvent:(UIEvent *)event //
561626
return nil;
562627
}
563628

629+
#if TARGET_OS_OSX // [macOS
630+
// Check if the hit lands on a scrollbar (NSScroller) BEFORE checking content
631+
// subviews. Scrollers are subviews of the NSScrollView, not the documentView
632+
// (_containerView). They must be checked first because content views typically
633+
// fill the entire visible area and would otherwise swallow scroller clicks —
634+
// for both overlay and legacy (always-visible) scrollbar styles.
635+
if (isPointInside) {
636+
NSPoint scrollViewPoint = [_scrollView convertPoint:point fromView:self];
637+
NSView *scrollViewHit = [_scrollView hitTest:scrollViewPoint];
638+
if ([scrollViewHit isKindOfClass:[NSScroller class]]) {
639+
return (RCTPlatformView *)scrollViewHit;
640+
}
641+
}
642+
#endif // macOS]
643+
564644
for (RCTPlatformView *subview in [_containerView.subviews reverseObjectEnumerator]) { // [macOS]
565645
RCTPlatformView *hitView = RCTUIViewHitTestWithEvent(subview, point, self, event); // [macOS]
566646
if (hitView) {
@@ -865,12 +945,20 @@ - (void)viewDidMoveToWindow // [macOS]
865945
[defaultCenter removeObserver:self
866946
name:NSViewBoundsDidChangeNotification
867947
object:_scrollView.contentView];
948+
[defaultCenter removeObserver:self
949+
name:NSPreferredScrollerStyleDidChangeNotification
950+
object:nil];
868951
} else {
869952
// Register for scrollview's clipview bounds change notifications so we can track scrolling
870953
[defaultCenter addObserver:self
871954
selector:@selector(scrollViewDocumentViewBoundsDidChange:)
872955
name:NSViewBoundsDidChangeNotification
873956
object:_scrollView.contentView]; // NSClipView
957+
// Observe system scrollbar style changes so we can update scrollbar insets for Yoga layout
958+
[defaultCenter addObserver:self
959+
selector:@selector(_preferredScrollerStyleDidChange:)
960+
name:NSPreferredScrollerStyleDidChangeNotification
961+
object:nil];
874962
}
875963
#endif // macOS]
876964

packages/react-native/ReactCommon/react/renderer/components/scrollview/ScrollViewShadowNode.cpp

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,89 @@
77

88
#include "ScrollViewShadowNode.h"
99

10+
#include <atomic>
1011
#include <react/debug/react_native_assert.h>
12+
#include <react/renderer/components/view/YogaStylableProps.h>
1113
#include <react/renderer/core/LayoutMetrics.h>
14+
#include <react/renderer/graphics/Float.h>
1215

1316
namespace facebook::react {
1417

1518
const char ScrollViewComponentName[] = "ScrollView";
1619

20+
// [macOS] Cached system scrollbar width, set from native code at startup and
21+
// when the system "Show scroll bars" preference changes. Read synchronously
22+
// during Yoga layout so the first pass is immediately correct.
23+
static std::atomic<float> systemScrollbarWidth_{0.0f};
24+
25+
void ScrollViewShadowNode::setSystemScrollbarWidth(Float width) {
26+
systemScrollbarWidth_.store(
27+
static_cast<float>(width), std::memory_order_relaxed);
28+
}
29+
30+
Float ScrollViewShadowNode::getSystemScrollbarWidth() {
31+
return static_cast<Float>(
32+
systemScrollbarWidth_.load(std::memory_order_relaxed));
33+
}
34+
35+
ScrollViewShadowNode::ScrollViewShadowNode(
36+
const ShadowNodeFragment& fragment,
37+
const ShadowNodeFamily::Shared& family,
38+
ShadowNodeTraits traits)
39+
: ConcreteViewShadowNode(fragment, family, traits) {
40+
applyScrollbarPadding();
41+
}
42+
43+
ScrollViewShadowNode::ScrollViewShadowNode(
44+
const ShadowNode& sourceShadowNode,
45+
const ShadowNodeFragment& fragment)
46+
: ConcreteViewShadowNode(sourceShadowNode, fragment) {
47+
applyScrollbarPadding();
48+
}
49+
50+
void ScrollViewShadowNode::applyScrollbarPadding() {
51+
// [macOS] On macOS, legacy (always-visible) scrollbars sit inside the
52+
// NSScrollView frame and reduce the NSClipView's visible area. Without
53+
// adjustment, Yoga lays out children against the full ScrollView width,
54+
// causing content to overflow the visible area.
55+
//
56+
// Read the scrollbar width directly from the cached system value (set by
57+
// native code via setSystemScrollbarWidth). This is synchronous — no state
58+
// round-trip — so the first layout pass is immediately correct.
59+
//
60+
// IMPORTANT: We read the base padding from props (not the current Yoga
61+
// style) to avoid double-counting. When a shadow node is cloned, the Yoga
62+
// style is copied from the source node which may already include scrollbar
63+
// padding from a previous applyScrollbarPadding() call.
64+
Float scrollbarWidth = getSystemScrollbarWidth();
65+
66+
const auto& props = static_cast<const YogaStylableProps&>(*props_);
67+
const auto& propsStyle = props.yogaStyle;
68+
69+
auto style = yogaNode_.style();
70+
bool changed = false;
71+
72+
// Compute target right padding: base from props + scrollbar width
73+
{
74+
auto basePadding = propsStyle.padding(yoga::Edge::Right);
75+
Float baseValue = 0;
76+
if (basePadding.isDefined() && basePadding.isPoints()) {
77+
baseValue = basePadding.value().unwrap();
78+
}
79+
Float targetValue = baseValue + scrollbarWidth;
80+
auto targetPadding = yoga::StyleLength::points(targetValue);
81+
if (targetPadding != style.padding(yoga::Edge::Right)) {
82+
style.setPadding(yoga::Edge::Right, targetPadding);
83+
changed = true;
84+
}
85+
}
86+
87+
if (changed) {
88+
yogaNode_.setStyle(style);
89+
yogaNode_.setDirty(true);
90+
}
91+
}
92+
1793
void ScrollViewShadowNode::updateStateIfNeeded() {
1894
ensureUnsealed();
1995

packages/react-native/ReactCommon/react/renderer/components/scrollview/ScrollViewShadowNode.h

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,13 +27,27 @@ class ScrollViewShadowNode final : public ConcreteViewShadowNode<
2727
ScrollViewEventEmitter,
2828
ScrollViewState> {
2929
public:
30-
using ConcreteViewShadowNode::ConcreteViewShadowNode;
30+
ScrollViewShadowNode(
31+
const ShadowNodeFragment& fragment,
32+
const ShadowNodeFamily::Shared& family,
33+
ShadowNodeTraits traits);
34+
ScrollViewShadowNode(
35+
const ShadowNode& sourceShadowNode,
36+
const ShadowNodeFragment& fragment);
3137

3238
static ScrollViewState initialStateData(
3339
const Props::Shared& props,
3440
const ShadowNodeFamily::Shared& family,
3541
const ComponentDescriptor& componentDescriptor);
3642

43+
// [macOS] Set the system scrollbar width (e.g. legacy scroller width on
44+
// macOS). Called from native code at startup and when the system "Show scroll
45+
// bars" preference changes. The value is read synchronously during Yoga
46+
// layout, so the first layout pass is immediately correct — no state
47+
// round-trip required. Thread-safe (uses atomic store/load).
48+
static void setSystemScrollbarWidth(Float width);
49+
static Float getSystemScrollbarWidth();
50+
3751
#pragma mark - LayoutableShadowNode
3852

3953
void layout(LayoutContext layoutContext) override;
@@ -42,6 +56,7 @@ class ScrollViewShadowNode final : public ConcreteViewShadowNode<
4256
private:
4357
void updateStateIfNeeded();
4458
void updateScrollContentOffsetIfNeeded();
59+
void applyScrollbarPadding();
4560
};
4661

4762
} // namespace facebook::react

0 commit comments

Comments
 (0)