Skip to content

Commit d5e5bd0

Browse files
committed
[ScrollView][iOS] Fix contentInsetAdjustmentBehavior for the horizontal safe area (iPhone Duo)
1 parent a8491cc commit d5e5bd0

28 files changed

Lines changed: 849 additions & 73 deletions

‎packages/react-native/Libraries/Components/ScrollView/ScrollView.js‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -386,8 +386,13 @@ export type ScrollViewPropsIOS = Readonly<{
386386
*/
387387
zoomScale?: ?number,
388388
/**
389-
* This property specifies how the safe area insets are used to modify the
390-
* content area of the scroll view.
389+
* How the safe area insets modify the content area of the scroll view.
390+
*
391+
* Along the scrollable axis, the insets become content insets, so content
392+
* scrolls under the system UI. Across it, every value except `'never'` pads
393+
* the content container by the insets, so content is laid out clear of the
394+
* system UI, such as the rail on iPhone Duo or camera cutout in landscape,
395+
* while the container's background still extends edge to edge.
391396
*
392397
* @default `'never'`
393398
* @platform ios

‎packages/react-native/Libraries/Components/ScrollView/ScrollViewNativeComponent.js‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,7 @@ export const __INTERNAL_VIEW_CONFIG: PartialViewConfig =
124124
endDraggingSensitivityMultiplier: true,
125125
directionalLockEnabled: true,
126126
disableIntervalMomentum: true,
127+
horizontal: true,
127128
indicatorStyle: true,
128129
inverted: true,
129130
keyboardDismissMode: true,

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ Class<RCTComponentViewProtocol> RCTInputAccessoryCls(void) __attribute__((used))
3535
Class<RCTComponentViewProtocol> RCTParagraphCls(void) __attribute__((used));
3636
Class<RCTComponentViewProtocol> RCTPullToRefreshViewCls(void) __attribute__((used));
3737
Class<RCTComponentViewProtocol> RCTSafeAreaViewCls(void) __attribute__((used));
38+
Class<RCTComponentViewProtocol> RCTScrollContentViewCls(void) __attribute__((used));
3839
Class<RCTComponentViewProtocol> RCTScrollViewCls(void) __attribute__((used));
3940
Class<RCTComponentViewProtocol> RCTSwitchCls(void) __attribute__((used));
4041
Class<RCTComponentViewProtocol> RCTTextInputCls(void) __attribute__((used));

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
{"Paragraph", RCTParagraphCls},
2525
{"PullToRefreshView", RCTPullToRefreshViewCls},
2626
{"SafeAreaView", RCTSafeAreaViewCls},
27+
{"ScrollContentView", RCTScrollContentViewCls},
2728
{"ScrollView", RCTScrollViewCls},
2829
{"Switch", RCTSwitchCls},
2930
{"TextInput", RCTTextInputCls},

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,9 @@ NS_ASSUME_NONNULL_BEGIN
6868
@property (nonatomic, assign) BOOL horizontal;
6969

7070
/*
71-
* Whether JavaScript pads the content container by the safe area across the scrollable axis. Defaults to NO.
71+
* Whether `ScrollView` pads the content container by the safe area across the scrollable axis, which it does when
72+
* its `contentInsetAdjustmentBehavior` prop is not `never`. UIKit's own `contentInsetAdjustmentBehavior` can differ,
73+
* as react-native-screens sets it natively on the first scroll view of a screen.
7274
*/
7375
@property (nonatomic, assign) BOOL padsContentForSafeArea;
7476

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
/*
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*/
7+
8+
#import <UIKit/UIKit.h>
9+
10+
#import <React/RCTViewComponentView.h>
11+
12+
NS_ASSUME_NONNULL_BEGIN
13+
14+
/*
15+
* UIView class for the <ScrollContentView> component, the content container of a <ScrollView>.
16+
*/
17+
@interface RCTScrollContentViewComponentView : RCTViewComponentView
18+
19+
/*
20+
* The safe area the content is laid out clear of, as padding on top of the content container's own. The enclosing
21+
* scroll view sets the pair of insets across its scrollable axis, and zero while its `contentInsetAdjustmentBehavior`
22+
* is `never`.
23+
*/
24+
@property (nonatomic, assign) UIEdgeInsets safeAreaPadding;
25+
26+
@end
27+
28+
NS_ASSUME_NONNULL_END
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
/*
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*/
7+
8+
#import "RCTScrollContentViewComponentView.h"
9+
10+
#import <React/RCTI18nUtil.h>
11+
#import <React/RCTUtils.h>
12+
#import <react/renderer/components/scrollview/ScrollContentViewComponentDescriptor.h>
13+
#import <react/renderer/components/scrollview/ScrollContentViewState.h>
14+
#import "RCTConversions.h"
15+
#import "RCTFabricComponentsPlugins.h"
16+
17+
using namespace facebook::react;
18+
19+
@implementation RCTScrollContentViewComponentView {
20+
ScrollContentViewShadowNode::ConcreteState::Shared _state;
21+
ScrollContentViewState _lastStateData;
22+
}
23+
24+
- (instancetype)initWithFrame:(CGRect)frame
25+
{
26+
if (self = [super initWithFrame:frame]) {
27+
_props = ScrollContentViewShadowNode::defaultSharedProps();
28+
}
29+
30+
return self;
31+
}
32+
33+
- (void)setSafeAreaPadding:(UIEdgeInsets)safeAreaPadding
34+
{
35+
_safeAreaPadding = safeAreaPadding;
36+
[self _updateStateIfNecessary];
37+
}
38+
39+
- (void)_updateStateIfNecessary
40+
{
41+
if (!_state) {
42+
return;
43+
}
44+
45+
UIEdgeInsets insets = _safeAreaPadding;
46+
insets.left = RCTRoundPixelValue(insets.left);
47+
insets.top = RCTRoundPixelValue(insets.top);
48+
insets.right = RCTRoundPixelValue(insets.right);
49+
insets.bottom = RCTRoundPixelValue(insets.bottom);
50+
51+
RCTI18nUtil *i18nUtil = [RCTI18nUtil sharedInstance];
52+
ScrollContentViewState newData;
53+
newData.safeAreaPadding = RCTEdgeInsetsFromUIEdgeInsets(insets);
54+
newData.swapLeftAndRightInRTL = i18nUtil.isRTL && i18nUtil.doLeftAndRightSwapInRTL;
55+
if (newData == _lastStateData) {
56+
return;
57+
}
58+
59+
_lastStateData = newData;
60+
_state->updateState(
61+
[newData](const ScrollContentViewShadowNode::ConcreteState::Data &)
62+
-> ScrollContentViewShadowNode::ConcreteState::SharedData {
63+
return std::make_shared<const ScrollContentViewShadowNode::ConcreteState::Data>(newData);
64+
});
65+
}
66+
67+
#pragma mark - RCTComponentViewProtocol
68+
69+
+ (ComponentDescriptorProvider)componentDescriptorProvider
70+
{
71+
return concreteComponentDescriptorProvider<ScrollContentViewComponentDescriptor>();
72+
}
73+
74+
- (void)updateState:(const facebook::react::State::Shared &)state
75+
oldState:(const facebook::react::State::Shared &)oldState
76+
{
77+
_state = std::static_pointer_cast<const ScrollContentViewShadowNode::ConcreteState>(state);
78+
_lastStateData = _state->getData();
79+
}
80+
81+
- (void)finalizeUpdates:(RNComponentViewUpdateMask)updateMask
82+
{
83+
[super finalizeUpdates:updateMask];
84+
[self _updateStateIfNecessary];
85+
}
86+
87+
- (void)prepareForRecycle
88+
{
89+
[super prepareForRecycle];
90+
_state.reset();
91+
_lastStateData = {};
92+
_safeAreaPadding = UIEdgeInsetsZero;
93+
}
94+
95+
@end
96+
97+
Class<RCTComponentViewProtocol> RCTScrollContentViewCls(void)
98+
{
99+
return RCTScrollContentViewComponentView.class;
100+
}

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

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
#import "RCTCustomPullToRefreshViewProtocol.h"
2424
#import "RCTEnhancedScrollView.h"
2525
#import "RCTFabricComponentsPlugins.h"
26+
#import "RCTScrollContentViewComponentView.h"
2627
#import "RCTVirtualViewContainerState.h"
2728

2829
using namespace facebook::react;
@@ -449,9 +450,10 @@ - (void)updateProps:(const Props::Shared &)props oldProps:(const Props::Shared &
449450
} else if (contentInsetAdjustmentBehavior == ContentInsetAdjustmentBehavior::Always) {
450451
scrollView.contentInsetAdjustmentBehavior = UIScrollViewContentInsetAdjustmentAlways;
451452
}
453+
((RCTEnhancedScrollView *)_scrollView).padsContentForSafeArea =
454+
contentInsetAdjustmentBehavior != ContentInsetAdjustmentBehavior::Never;
452455
_shouldUpdateContentInsetAdjustmentBehavior = NO;
453456
}
454-
scrollView.padsContentForSafeArea = newScrollViewProps.padsContentForSafeArea;
455457

456458
MAP_SCROLL_VIEW_PROP(disableIntervalMomentum);
457459
MAP_SCROLL_VIEW_PROP(snapToInterval);
@@ -464,6 +466,7 @@ - (void)updateProps:(const Props::Shared &)props oldProps:(const Props::Shared &
464466

465467
// Must run after `super updateProps:` so it is based on the latest calculated view.
466468
((RCTEnhancedScrollView *)_scrollView).inverted = [self isInverted];
469+
[self _updateContentViewSafeAreaPadding];
467470
}
468471

469472
- (void)updateState:(const State::Shared &)state oldState:(const State::Shared &)oldState
@@ -563,6 +566,7 @@ - (void)mountChildComponentView:(UIView<RCTComponentViewProtocol> *)childCompone
563566
[_containerView insertSubview:childComponentView atIndex:index];
564567
if (![childComponentView conformsToProtocol:@protocol(RCTCustomPullToRefreshViewProtocol)]) {
565568
_contentView = childComponentView;
569+
[self _updateContentViewSafeAreaPadding];
566570
}
567571
}
568572

@@ -867,6 +871,46 @@ - (void)didMoveToWindow
867871
// The view is being removed, ensure that the scroll end event is dispatched
868872
[self _handleScrollEndIfNeeded];
869873
}
874+
[self _updateContentViewSafeAreaPadding];
875+
}
876+
877+
- (void)safeAreaInsetsDidChange
878+
{
879+
[super safeAreaInsetsDidChange];
880+
[self _updateContentViewSafeAreaPadding];
881+
}
882+
883+
- (void)layoutSubviews
884+
{
885+
[super layoutSubviews];
886+
// Moving or resizing the view changes its insets without `safeAreaInsetsDidChange` firing; that only reports
887+
// window-level changes.
888+
[self _updateContentViewSafeAreaPadding];
889+
}
890+
891+
/*
892+
* Hands the content container the safe area to pad its content by: the pair of insets across the scrollable axis,
893+
* unless `contentInsetAdjustmentBehavior` is `never`. Along the scrollable axis UIKit turns the safe area into content
894+
* insets instead, so content scrolls under the system UI.
895+
*/
896+
- (void)_updateContentViewSafeAreaPadding
897+
{
898+
if (![_contentView isKindOfClass:[RCTScrollContentViewComponentView class]]) {
899+
return;
900+
}
901+
const auto &props = static_cast<const ScrollViewProps &>(*_props);
902+
UIEdgeInsets padding = UIEdgeInsetsZero;
903+
if (props.contentInsetAdjustmentBehavior != ContentInsetAdjustmentBehavior::Never) {
904+
UIEdgeInsets insets = self.safeAreaInsets;
905+
if (props.horizontal) {
906+
padding.top = insets.top;
907+
padding.bottom = insets.bottom;
908+
} else {
909+
padding.left = insets.left;
910+
padding.right = insets.right;
911+
}
912+
}
913+
((RCTScrollContentViewComponentView *)_contentView).safeAreaPadding = padding;
870914
}
871915

872916
- (void)_handleScrollEndIfNeeded

‎packages/react-native/ReactCommon/react/renderer/componentregistry/componentNameByReactViewName.cpp‎

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -51,13 +51,6 @@ std::string componentNameByReactViewName(std::string viewName) {
5151
return "PullToRefreshView";
5252
}
5353

54-
// We need this temporarily for testing purposes until we have proper
55-
// implementation of core components.
56-
// iOS-only
57-
if (viewName == "ScrollContentView") {
58-
return "View";
59-
}
60-
6154
// iOS-only
6255
if (viewName == "MultilineTextInputView" ||
6356
viewName == "SinglelineTextInputView") {

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

Lines changed: 0 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -171,12 +171,6 @@ BaseScrollViewProps::BaseScrollViewProps(
171171
"horizontal",
172172
sourceProps.horizontal,
173173
true)),
174-
padsContentForSafeArea(convertRawProp(
175-
context,
176-
rawProps,
177-
"padsContentForSafeArea",
178-
sourceProps.padsContentForSafeArea,
179-
false)),
180174
scrollEventThrottle(convertRawProp(
181175
context,
182176
rawProps,
@@ -300,7 +294,6 @@ void BaseScrollViewProps::setProp(
300294
RAW_SET_PROP_SWITCH_CASE_BASIC(showsVerticalScrollIndicator);
301295
RAW_SET_PROP_SWITCH_CASE_BASIC(persistentScrollbar);
302296
RAW_SET_PROP_SWITCH_CASE_BASIC(horizontal);
303-
RAW_SET_PROP_SWITCH_CASE_BASIC(padsContentForSafeArea);
304297
RAW_SET_PROP_SWITCH_CASE_BASIC(scrollEventThrottle);
305298
RAW_SET_PROP_SWITCH_CASE_BASIC(zoomScale);
306299
RAW_SET_PROP_SWITCH_CASE_BASIC(contentInset);
@@ -412,10 +405,6 @@ SharedDebugStringConvertibleList BaseScrollViewProps::getDebugProps() const {
412405
defaultScrollViewProps.persistentScrollbar),
413406
debugStringConvertibleItem(
414407
"horizontal", horizontal, defaultScrollViewProps.horizontal),
415-
debugStringConvertibleItem(
416-
"padsContentForSafeArea",
417-
padsContentForSafeArea,
418-
defaultScrollViewProps.padsContentForSafeArea),
419408
debugStringConvertibleItem(
420409
"scrollEventThrottle",
421410
scrollEventThrottle,

0 commit comments

Comments
 (0)