Skip to content

Commit 7c609bd

Browse files
authored
Prepare native ScrollView safe area content padding (#59528)
Summary: Keep the requested content inset separate from UIKit's safe area correction and add native support for `padsContentForSafeArea`, defaulting to false. Existing JavaScript retains the current inset behaviour. The follow-up enables the correction together with content-container padding. Changelog: [Internal] Reviewed By: andrewdacenko Differential Revision: D127790579 Pull Request resolved: #59528
1 parent f94db2a commit 7c609bd

12 files changed

Lines changed: 174 additions & 7 deletions

File tree

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

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,19 @@ NS_ASSUME_NONNULL_BEGIN
5959
*/
6060
@property (nonatomic, assign) BOOL inverted;
6161

62+
/*
63+
* The content inset before the correction across the scrollable axis: what the props and the content centering
64+
* give. While `padsContentForSafeArea` is set, `contentInset` carries it less whatever UIKit adds across that axis
65+
* for the safe area.
66+
*/
67+
@property (nonatomic, assign) UIEdgeInsets baseContentInset;
68+
@property (nonatomic, assign) BOOL horizontal;
69+
70+
/*
71+
* Whether JavaScript pads the content container by the safe area across the scrollable axis. Defaults to NO.
72+
*/
73+
@property (nonatomic, assign) BOOL padsContentForSafeArea;
74+
6275
/*
6376
* Makes `setContentOffset:` method no-op when given `block` is executed.
6477
* The block is being executed synchronously.

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

Lines changed: 74 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,80 @@ - (void)centerContentIfNeeded
111111
if (contentSize.height < boundsSize.height) {
112112
top = (boundsSize.height - contentSize.height) * 0.5f;
113113
}
114-
self.contentInset = UIEdgeInsetsMake(top, left, top, left);
114+
self.baseContentInset = UIEdgeInsetsMake(top, left, top, left);
115+
}
116+
117+
- (void)setBaseContentInset:(UIEdgeInsets)baseContentInset
118+
{
119+
_baseContentInset = baseContentInset;
120+
if (_padsContentForSafeArea) {
121+
[self applyBaseContentInset];
122+
} else {
123+
self.contentInset = baseContentInset;
124+
}
125+
}
126+
127+
- (void)setHorizontal:(BOOL)horizontal
128+
{
129+
_horizontal = horizontal;
130+
if (_padsContentForSafeArea) {
131+
[self applyBaseContentInset];
132+
}
133+
}
134+
135+
- (void)setPadsContentForSafeArea:(BOOL)padsContentForSafeArea
136+
{
137+
if (_padsContentForSafeArea == padsContentForSafeArea) {
138+
return;
139+
}
140+
_padsContentForSafeArea = padsContentForSafeArea;
141+
[self applyBaseContentInset];
142+
}
143+
144+
- (void)adjustedContentInsetDidChange
145+
{
146+
[super adjustedContentInsetDidChange];
147+
if (_padsContentForSafeArea) {
148+
[self applyBaseContentInset];
149+
}
150+
}
151+
152+
/*
153+
* Applies `baseContentInset`, less what UIKit adds across the scrollable axis for the safe area while the content is
154+
* padded for it. `ScrollView` lays the content out clear of the system UI rather than scrolling it under; the
155+
* content inset would apply the safe area a second time.
156+
*/
157+
- (void)applyBaseContentInset
158+
{
159+
UIEdgeInsets contentInset = _baseContentInset;
160+
if (_padsContentForSafeArea) {
161+
UIEdgeInsets adjusted = self.adjustedContentInset;
162+
UIEdgeInsets current = self.contentInset;
163+
if (_horizontal) {
164+
contentInset.top -= adjusted.top - current.top;
165+
contentInset.bottom -= adjusted.bottom - current.bottom;
166+
} else {
167+
contentInset.left -= adjusted.left - current.left;
168+
contentInset.right -= adjusted.right - current.right;
169+
}
170+
}
171+
if (UIEdgeInsetsEqualToEdgeInsets(contentInset, self.contentInset)) {
172+
return;
173+
}
174+
// Content pinned to the leading edge across the scrollable axis stays pinned, as UIKit keeps it when it changes the
175+
// adjusted content inset itself.
176+
UIEdgeInsets before = self.adjustedContentInset;
177+
self.contentInset = contentInset;
178+
UIEdgeInsets after = self.adjustedContentInset;
179+
CGPoint contentOffset = self.contentOffset;
180+
if (_horizontal && contentOffset.y <= -before.top) {
181+
contentOffset.y = -after.top;
182+
} else if (!_horizontal && contentOffset.x <= -before.left) {
183+
contentOffset.x = -after.left;
184+
}
185+
if (!CGPointEqualToPoint(contentOffset, self.contentOffset)) {
186+
self.contentOffset = contentOffset;
187+
}
115188
}
116189

117190
- (void)setContentOffset:(CGPoint)contentOffset

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

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -204,7 +204,9 @@ - (void)_keyboardWillChangeFrame:(NSNotification *)notification
204204
CGPoint absoluteViewOrigin = [self convertPoint:self.bounds.origin toView:nil];
205205
CGFloat scrollViewLowerY = isInverted ? absoluteViewOrigin.y : absoluteViewOrigin.y + self.bounds.size.height;
206206

207-
UIEdgeInsets newEdgeInsets = _scrollView.contentInset;
207+
auto *enhancedScrollView = (RCTEnhancedScrollView *)_scrollView;
208+
UIEdgeInsets newEdgeInsets =
209+
enhancedScrollView.padsContentForSafeArea ? enhancedScrollView.baseContentInset : enhancedScrollView.contentInset;
208210
CGFloat inset = MAX(scrollViewLowerY - keyboardEndFrame.origin.y, 0);
209211
const auto &props = static_cast<const ScrollViewProps &>(*_props);
210212
if (isInverted) {
@@ -257,7 +259,7 @@ - (void)_keyboardWillChangeFrame:(NSNotification *)notification
257259
delay:0.0
258260
options:animationOptionsWithCurve(curve)
259261
animations:^{
260-
self->_scrollView.contentInset = newEdgeInsets;
262+
((RCTEnhancedScrollView *)self->_scrollView).baseContentInset = newEdgeInsets;
261263
self->_scrollView.verticalScrollIndicatorInsets = newEdgeInsets;
262264
[self scrollTo:newContentOffset.x y:newContentOffset.y animated:NO];
263265
}
@@ -345,6 +347,7 @@ - (void)updateProps:(const Props::Shared &)props oldProps:(const Props::Shared &
345347
MAP_SCROLL_VIEW_PROP(bouncesZoom);
346348
MAP_SCROLL_VIEW_PROP(canCancelContentTouches);
347349
MAP_SCROLL_VIEW_PROP(centerContent);
350+
MAP_SCROLL_VIEW_PROP(horizontal);
348351
// MAP_SCROLL_VIEW_PROP(automaticallyAdjustContentInsets);
349352
MAP_SCROLL_VIEW_PROP(decelerationRate);
350353
MAP_SCROLL_VIEW_PROP(directionalLockEnabled);
@@ -396,13 +399,15 @@ - (void)updateProps:(const Props::Shared &)props oldProps:(const Props::Shared &
396399
// When disabling centerContent, reset inset to prop value
397400
// (enabling is handled automatically by the setCenterContent: setter)
398401
if (oldScrollViewProps.centerContent && !newScrollViewProps.centerContent) {
399-
_scrollView.contentInset = RCTUIEdgeInsetsFromEdgeInsets(newScrollViewProps.contentInset);
402+
((RCTEnhancedScrollView *)_scrollView).baseContentInset =
403+
RCTUIEdgeInsetsFromEdgeInsets(newScrollViewProps.contentInset);
400404
}
401405

402406
// Only apply contentInset from props if centerContent is disabled
403407
// When centerContent is enabled, the inset is calculated by centerContentIfNeeded
404408
if (oldScrollViewProps.contentInset != newScrollViewProps.contentInset && !newScrollViewProps.centerContent) {
405-
_scrollView.contentInset = RCTUIEdgeInsetsFromEdgeInsets(newScrollViewProps.contentInset);
409+
((RCTEnhancedScrollView *)_scrollView).baseContentInset =
410+
RCTUIEdgeInsetsFromEdgeInsets(newScrollViewProps.contentInset);
406411
}
407412
if (oldScrollViewProps.contentOffset != newScrollViewProps.contentOffset) {
408413
_scrollView.contentOffset = RCTCGPointFromPoint(newScrollViewProps.contentOffset);
@@ -446,6 +451,7 @@ - (void)updateProps:(const Props::Shared &)props oldProps:(const Props::Shared &
446451
}
447452
_shouldUpdateContentInsetAdjustmentBehavior = NO;
448453
}
454+
scrollView.padsContentForSafeArea = newScrollViewProps.padsContentForSafeArea;
449455

450456
MAP_SCROLL_VIEW_PROP(disableIntervalMomentum);
451457
MAP_SCROLL_VIEW_PROP(snapToInterval);
@@ -596,7 +602,9 @@ - (BOOL)_shouldDisableScrollInteraction
596602
auto metrics = ScrollViewEventEmitter::Metrics{};
597603
metrics.contentSize = RCTSizeFromCGSize(_scrollView.contentSize);
598604
metrics.contentOffset = RCTPointFromCGPoint(_scrollView.contentOffset);
599-
metrics.contentInset = RCTEdgeInsetsFromUIEdgeInsets(_scrollView.contentInset);
605+
auto *scrollView = (RCTEnhancedScrollView *)_scrollView;
606+
metrics.contentInset = RCTEdgeInsetsFromUIEdgeInsets(
607+
scrollView.padsContentForSafeArea ? scrollView.baseContentInset : scrollView.contentInset);
600608
metrics.containerSize = RCTSizeFromCGSize(_scrollView.bounds.size);
601609
metrics.zoomScale = _scrollView.zoomScale;
602610
metrics.timestamp = CACurrentMediaTime();
@@ -700,11 +708,12 @@ - (void)prepareForRecycle
700708
// Invalidate cached content size so that updateState: recalculates the
701709
// container frame after zoomScale reset (which may have mutated it in RTL).
702710
_contentSize = CGSizeZero;
703-
_scrollView.contentInset = RCTUIEdgeInsetsFromEdgeInsets(props.contentInset);
711+
((RCTEnhancedScrollView *)_scrollView).baseContentInset = RCTUIEdgeInsetsFromEdgeInsets(props.contentInset);
704712
// We set the default behavior to "never" so that iOS
705713
// doesn't do weird things to UIScrollView insets automatically
706714
// and keeps it as an opt-in behavior.
707715
_scrollView.contentInsetAdjustmentBehavior = UIScrollViewContentInsetAdjustmentNever;
716+
((RCTEnhancedScrollView *)_scrollView).padsContentForSafeArea = NO;
708717
_shouldUpdateContentInsetAdjustmentBehavior = YES;
709718
_automaticallyAdjustKeyboardInsets = NO;
710719
_isUserTriggeredScrolling = NO;

‎packages/react-native/ReactCommon/react/renderer/components/scrollview/BaseScrollViewProps.cpp‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,12 @@ BaseScrollViewProps::BaseScrollViewProps(
171171
"horizontal",
172172
sourceProps.horizontal,
173173
true)),
174+
padsContentForSafeArea(convertRawProp(
175+
context,
176+
rawProps,
177+
"padsContentForSafeArea",
178+
sourceProps.padsContentForSafeArea,
179+
false)),
174180
scrollEventThrottle(convertRawProp(
175181
context,
176182
rawProps,
@@ -294,6 +300,7 @@ void BaseScrollViewProps::setProp(
294300
RAW_SET_PROP_SWITCH_CASE_BASIC(showsVerticalScrollIndicator);
295301
RAW_SET_PROP_SWITCH_CASE_BASIC(persistentScrollbar);
296302
RAW_SET_PROP_SWITCH_CASE_BASIC(horizontal);
303+
RAW_SET_PROP_SWITCH_CASE_BASIC(padsContentForSafeArea);
297304
RAW_SET_PROP_SWITCH_CASE_BASIC(scrollEventThrottle);
298305
RAW_SET_PROP_SWITCH_CASE_BASIC(zoomScale);
299306
RAW_SET_PROP_SWITCH_CASE_BASIC(contentInset);
@@ -405,6 +412,10 @@ SharedDebugStringConvertibleList BaseScrollViewProps::getDebugProps() const {
405412
defaultScrollViewProps.persistentScrollbar),
406413
debugStringConvertibleItem(
407414
"horizontal", horizontal, defaultScrollViewProps.horizontal),
415+
debugStringConvertibleItem(
416+
"padsContentForSafeArea",
417+
padsContentForSafeArea,
418+
defaultScrollViewProps.padsContentForSafeArea),
408419
debugStringConvertibleItem(
409420
"scrollEventThrottle",
410421
scrollEventThrottle,

‎packages/react-native/ReactCommon/react/renderer/components/scrollview/BaseScrollViewProps.h‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ class BaseScrollViewProps : public ViewProps {
6363
bool showsVerticalScrollIndicator{true};
6464
bool persistentScrollbar{false};
6565
bool horizontal{false};
66+
bool padsContentForSafeArea{false};
6667
Float scrollEventThrottle{};
6768
Float zoomScale{1.0f};
6869
EdgeInsets contentInset{};

‎packages/react-native/ReactCommon/react/renderer/components/scrollview/tests/ScrollViewTest.cpp‎

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,57 @@
55
* LICENSE file in the root directory of this source tree.
66
*/
77

8+
#include <react/renderer/components/scrollview/BaseScrollViewProps.h>
89
#include <react/renderer/components/scrollview/ScrollViewState.h>
10+
#include <react/renderer/core/RawPropsParser.h>
911

1012
#include <gtest/gtest.h>
1113

1214
namespace facebook::react {
1315

16+
namespace {
17+
18+
BaseScrollViewProps parseScrollViewProps(
19+
const folly::dynamic& value,
20+
const BaseScrollViewProps& sourceProps = BaseScrollViewProps{}) {
21+
ContextContainer contextContainer{};
22+
PropsParserContext context{-1, contextContainer};
23+
RawProps raw{value};
24+
RawPropsParser parser{};
25+
parser.prepare<BaseScrollViewProps>();
26+
raw.parse(parser);
27+
return {context, sourceProps, raw};
28+
}
29+
30+
} // namespace
31+
32+
TEST(ScrollViewPropsTest, safeAreaPaddingRequiresExplicitOptIn) {
33+
EXPECT_FALSE(BaseScrollViewProps{}.padsContentForSafeArea);
34+
for (const auto* behavior :
35+
{"never", "automatic", "scrollableAxes", "always"}) {
36+
auto props = parseScrollViewProps(
37+
folly::dynamic::object("contentInsetAdjustmentBehavior", behavior));
38+
EXPECT_FALSE(props.padsContentForSafeArea);
39+
}
40+
}
41+
42+
TEST(ScrollViewPropsTest, safeAreaPaddingCanBeEnabledAndDisabled) {
43+
auto enabled = parseScrollViewProps(
44+
folly::dynamic::object("padsContentForSafeArea", true));
45+
EXPECT_TRUE(enabled.padsContentForSafeArea);
46+
auto disabled = parseScrollViewProps(
47+
folly::dynamic::object("padsContentForSafeArea", false), enabled);
48+
EXPECT_FALSE(disabled.padsContentForSafeArea);
49+
}
50+
51+
TEST(ScrollViewPropsTest, safeAreaPaddingResetsToDefault) {
52+
auto enabled = parseScrollViewProps(
53+
folly::dynamic::object("padsContentForSafeArea", true));
54+
auto reset = parseScrollViewProps(
55+
folly::dynamic::object("padsContentForSafeArea", nullptr), enabled);
56+
EXPECT_FALSE(reset.padsContentForSafeArea);
57+
}
58+
1459
TEST(ScrollViewStateTest, defaultConstructor) {
1560
ScrollViewState state;
1661

‎scripts/cxx-api/api-snapshots/ReactAndroidFrameworksCxx.api‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1416,6 +1416,7 @@ class facebook::react::BaseScrollViewProps : public facebook::react::HostPlatfor
14161416
public bool disableIntervalMomentum;
14171417
public bool horizontal;
14181418
public bool isInvertedVirtualizedList;
1419+
public bool padsContentForSafeArea;
14191420
public bool pagingEnabled;
14201421
public bool persistentScrollbar;
14211422
public bool pinchGestureEnabled;

‎scripts/cxx-api/api-snapshots/ReactAppleDebugCxx.api‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1023,11 +1023,14 @@ interface RCTDivisionAnimatedNode : public RCTValueAnimatedNode {
10231023
interface RCTEnhancedScrollView : public UIScrollView {
10241024
public @property (assign) BOOL centerContent;
10251025
public @property (assign) BOOL disableIntervalMomentum;
1026+
public @property (assign) BOOL horizontal;
10261027
public @property (assign) BOOL inverted;
1028+
public @property (assign) BOOL padsContentForSafeArea;
10271029
public @property (assign) BOOL pinchGestureEnabled;
10281030
public @property (assign) BOOL snapToEnd;
10291031
public @property (assign) BOOL snapToStart;
10301032
public @property (assign) CGFloat snapToInterval;
1033+
public @property (assign) UIEdgeInsets baseContentInset;
10311034
public @property (copy) NSArray<NSNumber*>* snapToOffsets;
10321035
public @property (copy) NSString* snapToAlignment;
10331036
public @property (strong, readonly) RCTGenericDelegateSplitter<id<UIScrollViewDelegate>>* delegateSplitter;

‎scripts/cxx-api/api-snapshots/ReactAppleFrameworksCxx.api‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1032,11 +1032,14 @@ interface RCTDivisionAnimatedNode : public RCTValueAnimatedNode {
10321032
interface RCTEnhancedScrollView : public UIScrollView {
10331033
public @property (assign) BOOL centerContent;
10341034
public @property (assign) BOOL disableIntervalMomentum;
1035+
public @property (assign) BOOL horizontal;
10351036
public @property (assign) BOOL inverted;
1037+
public @property (assign) BOOL padsContentForSafeArea;
10361038
public @property (assign) BOOL pinchGestureEnabled;
10371039
public @property (assign) BOOL snapToEnd;
10381040
public @property (assign) BOOL snapToStart;
10391041
public @property (assign) CGFloat snapToInterval;
1042+
public @property (assign) UIEdgeInsets baseContentInset;
10401043
public @property (copy) NSArray<NSNumber*>* snapToOffsets;
10411044
public @property (copy) NSString* snapToAlignment;
10421045
public @property (strong, readonly) RCTGenericDelegateSplitter<id<UIScrollViewDelegate>>* delegateSplitter;
@@ -3981,6 +3984,7 @@ class facebook::react::BaseScrollViewProps : public facebook::react::HostPlatfor
39813984
public bool disableIntervalMomentum;
39823985
public bool horizontal;
39833986
public bool isInvertedVirtualizedList;
3987+
public bool padsContentForSafeArea;
39843988
public bool pagingEnabled;
39853989
public bool persistentScrollbar;
39863990
public bool pinchGestureEnabled;

‎scripts/cxx-api/api-snapshots/ReactAppleNewarchCxx.api‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1023,11 +1023,14 @@ interface RCTDivisionAnimatedNode : public RCTValueAnimatedNode {
10231023
interface RCTEnhancedScrollView : public UIScrollView {
10241024
public @property (assign) BOOL centerContent;
10251025
public @property (assign) BOOL disableIntervalMomentum;
1026+
public @property (assign) BOOL horizontal;
10261027
public @property (assign) BOOL inverted;
1028+
public @property (assign) BOOL padsContentForSafeArea;
10271029
public @property (assign) BOOL pinchGestureEnabled;
10281030
public @property (assign) BOOL snapToEnd;
10291031
public @property (assign) BOOL snapToStart;
10301032
public @property (assign) CGFloat snapToInterval;
1033+
public @property (assign) UIEdgeInsets baseContentInset;
10311034
public @property (copy) NSArray<NSNumber*>* snapToOffsets;
10321035
public @property (copy) NSString* snapToAlignment;
10331036
public @property (strong, readonly) RCTGenericDelegateSplitter<id<UIScrollViewDelegate>>* delegateSplitter;

0 commit comments

Comments
 (0)