Skip to content

Commit 922c862

Browse files
Saadnajmiclaude
andcommitted
[macOS] Report scrollbar insets to Yoga for correct ScrollView child layout
On macOS, legacy (always-visible) scrollbars sit inside the NSScrollView frame and reduce the NSClipView's visible area. React's layout system measures ScrollView children against the full frame, causing content to overflow the visible area when legacy scrollbars are present. This fix feeds the vertical scrollbar width from the native side back to the shadow tree via ScrollViewState, where the ScrollViewShadowNode applies it as additional Yoga right padding. This reduces the available layout space for children to match the actual clip view size — the same approach SwiftUI uses when proposing sizes to ScrollView children. Key changes: - Remove autoresizingMask from documentView to prevent AppKit from corrupting its frame during tile/resize - Detect scrollbar dimensions in setFrame: and send via state update - Observe NSPreferredScrollerStyleDidChangeNotification to react to system preference changes ("Show scroll bars: Always" ↔ "When scrolling") - Move NSScroller hit test check before subview loop so scrollbar clicks aren't swallowed by content views - Only account for vertical scrollbar (horizontal causes feedback loop) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent cb0f4ad commit 922c862

File tree

4 files changed

+188
-2
lines changed

4 files changed

+188
-2
lines changed

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

Lines changed: 93 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,67 @@ - (void)dealloc
184187
#endif // [macOS]
185188
}
186189

190+
#if TARGET_OS_OSX // [macOS
191+
- (void)setFrame:(NSRect)frame
192+
{
193+
[super setFrame:frame];
194+
[self _updateScrollbarInsetsIfNeeded];
195+
}
196+
197+
- (void)_updateScrollbarInsetsIfNeeded
198+
{
199+
if (!_state) {
200+
return;
201+
}
202+
203+
CGFloat verticalScrollerWidth = 0;
204+
205+
// When scrollbars use the legacy (always-visible) style, they sit inside the
206+
// NSScrollView frame and reduce the NSClipView's visible area. Detect the
207+
// scrollbar dimensions so Yoga can account for them when laying out children.
208+
//
209+
// Only the vertical scrollbar is accounted for. Adding bottom padding for the
210+
// horizontal scrollbar creates a feedback loop: the extra padding makes content
211+
// taller, which triggers the horizontal scrollbar, which adds more padding, etc.
212+
//
213+
// Use [NSScroller preferredScrollerStyle] (the system-level preference) rather
214+
// than _scrollView.scrollerStyle, because the scroll view's own property may
215+
// not have updated yet after a system preference change. Use the class method
216+
// +scrollerWidthForControlSize:scrollerStyle: to get dimensions without
217+
// requiring the scroller to be laid out.
218+
NSScrollerStyle preferredStyle = [NSScroller preferredScrollerStyle];
219+
if (preferredStyle == NSScrollerStyleLegacy) {
220+
if (_scrollView.hasVerticalScroller) {
221+
verticalScrollerWidth = [NSScroller
222+
scrollerWidthForControlSize:NSControlSizeRegular
223+
scrollerStyle:NSScrollerStyleLegacy];
224+
}
225+
}
226+
227+
_state->updateState(
228+
[verticalScrollerWidth](
229+
const ScrollViewShadowNode::ConcreteState::Data &oldData)
230+
-> ScrollViewShadowNode::ConcreteState::SharedData {
231+
if (oldData.scrollbarTrailingWidth == static_cast<Float>(verticalScrollerWidth)) {
232+
return nullptr;
233+
}
234+
auto newData = oldData;
235+
newData.scrollbarTrailingWidth = static_cast<Float>(verticalScrollerWidth);
236+
return std::make_shared<const ScrollViewShadowNode::ConcreteState::Data>(newData);
237+
});
238+
}
239+
240+
- (void)_preferredScrollerStyleDidChange:(NSNotification *)notification
241+
{
242+
// When the system scrollbar style changes (e.g., "Show scroll bars: Always" ↔
243+
// "When scrolling"), force the scroll view to adopt the new style and re-tile
244+
// so scrollers are properly created/removed, then update Yoga insets.
245+
_scrollView.scrollerStyle = [NSScroller preferredScrollerStyle];
246+
[_scrollView tile];
247+
[self _updateScrollbarInsetsIfNeeded];
248+
}
249+
#endif // macOS]
250+
187251
#if TARGET_OS_IOS
188252
- (void)_registerKeyboardListener
189253
{
@@ -534,6 +598,11 @@ - (void)updateState:(const State::Shared &)state oldState:(const State::Shared &
534598
[self _preserveContentOffsetIfNeededWithBlock:^{
535599
self->_scrollView.contentSize = contentSize;
536600
}];
601+
602+
#if TARGET_OS_OSX // [macOS
603+
// Force the scroll view to re-evaluate which scrollers should be visible.
604+
[_scrollView tile];
605+
#endif // macOS]
537606
}
538607

539608
- (RCTPlatformView *)betterHitTest:(CGPoint)point withEvent:(UIEvent *)event // [macOS]
@@ -561,6 +630,21 @@ - (RCTPlatformView *)betterHitTest:(CGPoint)point withEvent:(UIEvent *)event //
561630
return nil;
562631
}
563632

633+
#if TARGET_OS_OSX // [macOS
634+
// Check if the hit lands on a scrollbar (NSScroller) BEFORE checking content
635+
// subviews. Scrollers are subviews of the NSScrollView, not the documentView
636+
// (_containerView). They must be checked first because content views typically
637+
// fill the entire visible area and would otherwise swallow scroller clicks —
638+
// for both overlay and legacy (always-visible) scrollbar styles.
639+
if (isPointInside) {
640+
NSPoint scrollViewPoint = [_scrollView convertPoint:point fromView:self];
641+
NSView *scrollViewHit = [_scrollView hitTest:scrollViewPoint];
642+
if ([scrollViewHit isKindOfClass:[NSScroller class]]) {
643+
return (RCTPlatformView *)scrollViewHit;
644+
}
645+
}
646+
#endif // macOS]
647+
564648
for (RCTPlatformView *subview in [_containerView.subviews reverseObjectEnumerator]) { // [macOS]
565649
RCTPlatformView *hitView = RCTUIViewHitTestWithEvent(subview, point, self, event); // [macOS]
566650
if (hitView) {
@@ -865,12 +949,20 @@ - (void)viewDidMoveToWindow // [macOS]
865949
[defaultCenter removeObserver:self
866950
name:NSViewBoundsDidChangeNotification
867951
object:_scrollView.contentView];
952+
[defaultCenter removeObserver:self
953+
name:NSPreferredScrollerStyleDidChangeNotification
954+
object:nil];
868955
} else {
869956
// Register for scrollview's clipview bounds change notifications so we can track scrolling
870957
[defaultCenter addObserver:self
871958
selector:@selector(scrollViewDocumentViewBoundsDidChange:)
872959
name:NSViewBoundsDidChangeNotification
873960
object:_scrollView.contentView]; // NSClipView
961+
// Observe system scrollbar style changes so we can update scrollbar insets for Yoga layout
962+
[defaultCenter addObserver:self
963+
selector:@selector(_preferredScrollerStyleDidChange:)
964+
name:NSPreferredScrollerStyleDidChangeNotification
965+
object:nil];
874966
}
875967
#endif // macOS]
876968

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

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,92 @@
88
#include "ScrollViewShadowNode.h"
99

1010
#include <react/debug/react_native_assert.h>
11+
#include <react/renderer/components/view/YogaStylableProps.h>
1112
#include <react/renderer/core/LayoutMetrics.h>
13+
#include <react/renderer/graphics/Float.h>
1214

1315
namespace facebook::react {
1416

1517
const char ScrollViewComponentName[] = "ScrollView";
1618

19+
ScrollViewShadowNode::ScrollViewShadowNode(
20+
const ShadowNodeFragment& fragment,
21+
const ShadowNodeFamily::Shared& family,
22+
ShadowNodeTraits traits)
23+
: ConcreteViewShadowNode(fragment, family, traits) {
24+
applyScrollbarPadding();
25+
}
26+
27+
ScrollViewShadowNode::ScrollViewShadowNode(
28+
const ShadowNode& sourceShadowNode,
29+
const ShadowNodeFragment& fragment)
30+
: ConcreteViewShadowNode(sourceShadowNode, fragment) {
31+
applyScrollbarPadding();
32+
}
33+
34+
void ScrollViewShadowNode::applyScrollbarPadding() {
35+
// [macOS] On macOS, legacy (always-visible) scrollbars sit inside the
36+
// NSScrollView frame and reduce the NSClipView's visible area. Without
37+
// adjustment, Yoga lays out children against the full ScrollView width,
38+
// causing content to overflow the visible area.
39+
//
40+
// This adds the scrollbar dimensions as extra Yoga padding so children
41+
// are measured against the actual visible area, matching how SwiftUI
42+
// proposes the clip view size to its children.
43+
//
44+
// IMPORTANT: We read the base padding from props (not the current Yoga
45+
// style) to avoid double-counting. When a shadow node is cloned, the Yoga
46+
// style is copied from the source node which may already include scrollbar
47+
// padding from a previous applyScrollbarPadding() call.
48+
const auto& state = getStateData();
49+
50+
// Read base padding from props (the source of truth for user-set values).
51+
// We always read from props, not the Yoga style, because the Yoga style may
52+
// already include scrollbar padding from a previous clone.
53+
const auto& props = static_cast<const YogaStylableProps&>(*props_);
54+
const auto& propsStyle = props.yogaStyle;
55+
56+
auto style = yogaNode_.style();
57+
bool changed = false;
58+
59+
// Compute target right padding: base from props + scrollbar width
60+
{
61+
auto basePadding = propsStyle.padding(yoga::Edge::Right);
62+
Float baseValue = 0;
63+
if (basePadding.isDefined() && basePadding.isPoints()) {
64+
baseValue = basePadding.value().unwrap();
65+
}
66+
Float scrollbarWidth = state.scrollbarTrailingWidth > 0 ? state.scrollbarTrailingWidth : Float{0};
67+
Float targetValue = baseValue + scrollbarWidth;
68+
auto targetPadding = yoga::StyleLength::points(targetValue);
69+
if (targetPadding != style.padding(yoga::Edge::Right)) {
70+
style.setPadding(yoga::Edge::Right, targetPadding);
71+
changed = true;
72+
}
73+
}
74+
75+
// Compute target bottom padding: base from props + scrollbar height
76+
{
77+
auto basePadding = propsStyle.padding(yoga::Edge::Bottom);
78+
Float baseValue = 0;
79+
if (basePadding.isDefined() && basePadding.isPoints()) {
80+
baseValue = basePadding.value().unwrap();
81+
}
82+
Float scrollbarHeight = state.scrollbarBottomHeight > 0 ? state.scrollbarBottomHeight : Float{0};
83+
Float targetValue = baseValue + scrollbarHeight;
84+
auto targetPadding = yoga::StyleLength::points(targetValue);
85+
if (targetPadding != style.padding(yoga::Edge::Bottom)) {
86+
style.setPadding(yoga::Edge::Bottom, targetPadding);
87+
changed = true;
88+
}
89+
}
90+
91+
if (changed) {
92+
yogaNode_.setStyle(style);
93+
yogaNode_.setDirty(true);
94+
}
95+
}
96+
1797
void ScrollViewShadowNode::updateStateIfNeeded() {
1898
ensureUnsealed();
1999

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

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,13 @@ 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,
@@ -42,6 +48,7 @@ class ScrollViewShadowNode final : public ConcreteViewShadowNode<
4248
private:
4349
void updateStateIfNeeded();
4450
void updateScrollContentOffsetIfNeeded();
51+
void applyScrollbarPadding();
4552
};
4653

4754
} // namespace facebook::react

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,13 @@ class ScrollViewState final {
3333
Rect contentBoundingRect;
3434
int scrollAwayPaddingTop;
3535

36+
// [macOS] Space consumed by always-visible (legacy-style) scrollbars.
37+
// When non-zero, the ScrollView shadow node adds this as extra padding so
38+
// Yoga lays out children within the actual visible area (clip view), not the
39+
// full ScrollView frame. Zero when scrollbars are overlay-style.
40+
Float scrollbarTrailingWidth{0};
41+
Float scrollbarBottomHeight{0};
42+
3643
/*
3744
* Returns size of scrollable area.
3845
*/

0 commit comments

Comments
 (0)