Skip to content

Commit 2856e4c

Browse files
appdenchristophpurrer
authored andcommitted
Support inverted ScrollView on macOS
Allow to render a scrollView's content in inverted order which is especially helpful in messaging applications. We can't rely on -1 scale hacks on macOS because of inverse issues with trackpad/scrollwheel, dragging scrollbars, tracking hovers, etc. Hence we added 'native' support for inverted views
1 parent 10f4c5a commit 2856e4c

File tree

11 files changed

+107
-10
lines changed

11 files changed

+107
-10
lines changed

Libraries/Components/ScrollView/ScrollView.js

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -499,6 +499,10 @@ export type Props = $ReadOnly<{|
499499
* ScrollView. This is usually used with inverted ScrollViews.
500500
*/
501501
invertStickyHeaders?: ?boolean,
502+
/**
503+
* Reverses the direction of scroll. Uses native inversion on macOS and scale transforms of -1 elsewhere
504+
*/
505+
inverted?: ?boolean,
502506
/**
503507
* Determines whether the keyboard gets dismissed in response to a drag.
504508
*
@@ -1192,6 +1196,11 @@ class ScrollView extends React.Component<Props, State> {
11921196
this.setState({contentKey: this.state.contentKey + 1});
11931197
}; // ]TODO(macOS GH#774)
11941198

1199+
// [TODO(macOS GH#774)
1200+
_handleInvertedDidChange = () => {
1201+
this.setState({contentKey: this.state.contentKey + 1});
1202+
}; // ]TODO(macOS GH#774)
1203+
11951204
_handleScroll = (e: ScrollEvent) => {
11961205
if (__DEV__) {
11971206
if (
@@ -1716,6 +1725,7 @@ class ScrollView extends React.Component<Props, State> {
17161725
: this.props.removeClippedSubviews
17171726
}
17181727
key={this.state.contentKey} // TODO(macOS GH#774)
1728+
inverted={this.props.inverted} // TODO(macOS GH#774)
17191729
collapsable={false}>
17201730
{children}
17211731
</NativeDirectionalScrollContentView>
@@ -1743,6 +1753,7 @@ class ScrollView extends React.Component<Props, State> {
17431753
// Override the onContentSizeChange from props, since this event can
17441754
// bubble up from TextInputs
17451755
onContentSizeChange: null,
1756+
onInvertedDidChange: this._handleInvertedDidChange, // TODO macOS GH#774
17461757
onPreferredScrollerStyleDidChange:
17471758
this._handlePreferredScrollerStyleDidChange, // TODO(macOS GH#774)
17481759
onLayout: this._handleLayout,

Libraries/Components/View/ViewPropTypes.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,11 @@ type DirectEventProps = $ReadOnly<{|
7070
*/
7171
onDoubleClick?: ?(event: SyntheticEvent<{}>) => mixed, // TODO(macOS GH#774)
7272

73+
/**
74+
* This event is fired when the scrollView's inverted property changes.
75+
*/
76+
onInvertedDidChange?: ?() => mixed, // TODO(macOS GH#774)
77+
7378
/**
7479
* This event is fired when the system's preferred scroller style changes.
7580
* The `preferredScrollerStyle` key will be `legacy` or `overlay`.

Libraries/Lists/FlatList.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,7 @@ type OptionalProps<ItemT> = {|
136136
initialSelectedIndex?: ?number,
137137
// ]TODO(macOS GH#774)
138138
/**
139-
* Reverses the direction of scroll. Uses scale transforms of -1.
139+
* Reverses the direction of scroll. Uses native inversion on macOS and scale transforms of -1 elsewhere
140140
*/
141141
inverted?: ?boolean,
142142
/**

Libraries/Lists/VirtualizedList.js

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -365,6 +365,7 @@ let _keylessItemComponentName: string = '';
365365
type State = {
366366
first: number,
367367
last: number,
368+
contentKey: number, // TODO(macOS GH#774)
368369
selectedRowIndex: number, // TODO(macOS GH#774)
369370
...
370371
};
@@ -800,6 +801,7 @@ class VirtualizedList extends React.PureComponent<Props, State> {
800801
(this.props.initialScrollIndex || 0) +
801802
initialNumToRenderOrDefault(this.props.initialNumToRender),
802803
) - 1,
804+
contentKey: 1, // TODO(macOS GH#774)
803805
selectedRowIndex: this.props.initialSelectedIndex || -1, // TODO(macOS GH#774)
804806
};
805807

@@ -862,6 +864,7 @@ class VirtualizedList extends React.PureComponent<Props, State> {
862864
Math.min(prevState.first, getItemCount(data) - 1 - maxToRenderPerBatch),
863865
),
864866
last: Math.max(0, Math.min(prevState.last, getItemCount(data) - 1)),
867+
contentKey: prevState.contentKey,
865868
selectedRowIndex: Math.max(
866869
-1, // Used to indicate no row is selected
867870
Math.min(prevState.selectedRowIndex, getItemCount(data)),
@@ -979,11 +982,13 @@ class VirtualizedList extends React.PureComponent<Props, State> {
979982
this.props;
980983
const {data, horizontal} = this.props;
981984
const isVirtualizationDisabled = this._isVirtualizationDisabled();
982-
const inversionStyle = this.props.inverted
983-
? horizontalOrDefault(this.props.horizontal)
984-
? styles.horizontallyInverted
985-
: styles.verticallyInverted
986-
: null;
985+
// macOS natively supports inverted lists, thus not needing an inversion style
986+
const inversionStyle =
987+
this.props.inverted && Platform.OS !== 'macos' // TODO(macOS GH#774)
988+
? horizontalOrDefault(this.props.horizontal)
989+
? styles.horizontallyInverted
990+
: styles.verticallyInverted
991+
: null;
987992
const cells = [];
988993
const stickyIndicesFromProps = new Set(this.props.stickyHeaderIndices);
989994
const stickyHeaderIndices = [];
@@ -1330,6 +1335,7 @@ class VirtualizedList extends React.PureComponent<Props, State> {
13301335
// [TODO(macOS GH#774)
13311336
const preferredScrollerStyleDidChangeHandler =
13321337
this.props.onPreferredScrollerStyleDidChange;
1338+
const invertedDidChange = this.props.onInvertedDidChange;
13331339

13341340
const keyboardNavigationProps = {
13351341
focusable: true,
@@ -1354,6 +1360,7 @@ class VirtualizedList extends React.PureComponent<Props, State> {
13541360
{...props}
13551361
// [TODO(macOS GH#774)
13561362
{...(props.enableSelectionOnKeyPress && keyboardNavigationProps)}
1363+
onInvertedDidChange={invertedDidChange}
13571364
onPreferredScrollerStyleDidChange={
13581365
preferredScrollerStyleDidChangeHandler
13591366
} // TODO(macOS GH#774)]
@@ -1377,6 +1384,7 @@ class VirtualizedList extends React.PureComponent<Props, State> {
13771384
{...props}
13781385
// [TODO(macOS GH#774)
13791386
{...(props.enableSelectionOnKeyPress && keyboardNavigationProps)}
1387+
onInvertedDidChange={invertedDidChange}
13801388
onPreferredScrollerStyleDidChange={
13811389
preferredScrollerStyleDidChangeHandler
13821390
} // TODO(macOS GH#774)]

React/Views/ScrollView/RCTScrollContentView.h

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,4 +11,8 @@
1111

1212
@interface RCTScrollContentView : RCTView
1313

14+
#if TARGET_OS_OSX // [TODO(macOS GH#774)
15+
@property (nonatomic, assign, getter=isInverted) BOOL inverted;
16+
#endif // ]TODO(macOS GH#774)
17+
1418
@end

React/Views/ScrollView/RCTScrollContentView.m

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,12 @@
1818
#import "RCTScrollView.h"
1919

2020
@implementation RCTScrollContentView
21+
#if TARGET_OS_OSX // [TODO(macOS GH#774)
22+
- (BOOL)isFlipped
23+
{
24+
return !self.inverted;
25+
}
26+
#endif // ]TODO(macOS GH#774)
2127

2228
- (void)reactSetFrame:(CGRect)frame
2329
{

React/Views/ScrollView/RCTScrollContentViewManager.m

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ @implementation RCTScrollContentViewManager
1414

1515
RCT_EXPORT_MODULE()
1616

17+
RCT_EXPORT_OSX_VIEW_PROPERTY(inverted, BOOL) // TODO(macOS GH#774)
18+
1719
- (RCTScrollContentView *)view
1820
{
1921
return [RCTScrollContentView new];

React/Views/ScrollView/RCTScrollView.h

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,8 @@
6767
@property (nonatomic, copy) RCTDirectEventBlock onMomentumScrollEnd;
6868
@property (nonatomic, copy) RCTDirectEventBlock onPreferredScrollerStyleDidChange; // TODO(macOS GH#774)
6969

70+
@property (nonatomic, copy) RCTDirectEventBlock onInvertedDidChange; // TODO(macOS GH#774)
71+
7072
- (void)flashScrollIndicators; // TODO(macOS GH#774)
7173

7274
@end

React/Views/ScrollView/RCTScrollView.m

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ @interface RCTCustomScrollView :
4545
@property (nonatomic, assign) BOOL pinchGestureEnabled;
4646
#else // [TODO(macOS GH#774)
4747
+ (BOOL)isCompatibleWithResponsiveScrolling;
48+
@property (nonatomic, assign, getter=isInverted) BOOL inverted;
4849
@property (nonatomic, assign, getter=isScrollEnabled) BOOL scrollEnabled;
4950
@property (nonatomic, strong) NSPanGestureRecognizer *panGestureRecognizer;
5051
#endif // ]TODO(macOS GH#774)
@@ -108,6 +109,11 @@ + (BOOL)isCompatibleWithResponsiveScrolling
108109
return YES;
109110
}
110111

112+
- (BOOL)isFlipped
113+
{
114+
return !self.inverted;
115+
}
116+
111117
- (void)scrollWheel:(NSEvent *)theEvent
112118
{
113119
if (!self.scrollEnabled) {
@@ -556,6 +562,15 @@ - (void)setAccessibilityRole:(NSAccessibilityRole)accessibilityRole
556562
{
557563
[_scrollView setAccessibilityRole:accessibilityRole];
558564
}
565+
566+
- (void)setInverted:(BOOL)inverted
567+
{
568+
BOOL changed = _inverted != inverted;
569+
_inverted = inverted;
570+
if (changed && _onInvertedDidChange) {
571+
_onInvertedDidChange(@{});
572+
}
573+
}
559574
#endif // ]TODO(macOS GH#774)
560575

561576
RCT_NOT_IMPLEMENTED(-(instancetype)initWithFrame : (CGRect)frame)
@@ -1207,14 +1222,14 @@ - (void)uiManagerWillPerformMounting:(RCTUIManager *)manager
12071222
RCTUIView *subview = self.contentView.subviews[ii]; // TODO(OSS Candidate ISS#2710739) use property instead of ivar for mac and TODO(macOS ISS#3536887)
12081223
BOOL hasNewView = NO;
12091224
if (horz) {
1210-
CGFloat leftInset = self.inverted ? self->_scrollView.contentInset.right : self->_scrollView.contentInset.left;
1225+
CGFloat leftInset = self.inverted ? self->_scrollView.contentInset.left : self->_scrollView.contentInset.right;
12111226
CGFloat x = self->_scrollView.contentOffset.x + leftInset;
1212-
hasNewView = subview.frame.origin.x > x;
1227+
hasNewView = subview.frame.origin.x >= x;
12131228
} else {
12141229
CGFloat bottomInset =
1215-
self.inverted ? self->_scrollView.contentInset.top : self->_scrollView.contentInset.bottom;
1230+
self.inverted ? self->_scrollView.contentInset.bottom : self->_scrollView.contentInset.top;
12161231
CGFloat y = self->_scrollView.contentOffset.y + bottomInset;
1217-
hasNewView = subview.frame.origin.y > y;
1232+
hasNewView = subview.frame.origin.y >= y;
12181233
}
12191234
if (hasNewView || ii == self.contentView.subviews.count - 1) { // TODO(OSS Candidate ISS#2710739) use property instead of ivar for mac
12201235
self->_prevFirstVisibleFrame = subview.frame;

React/Views/ScrollView/RCTScrollViewManager.m

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@ - (RCTPlatformView *)view // TODO(macOS GH#774)
9999
RCT_EXPORT_VIEW_PROPERTY(onScrollEndDrag, RCTDirectEventBlock)
100100
RCT_EXPORT_VIEW_PROPERTY(onMomentumScrollBegin, RCTDirectEventBlock)
101101
RCT_EXPORT_VIEW_PROPERTY(onMomentumScrollEnd, RCTDirectEventBlock)
102+
RCT_EXPORT_OSX_VIEW_PROPERTY(onInvertedDidChange, RCTDirectEventBlock) // TODO(macOS GH#774)
102103
RCT_EXPORT_OSX_VIEW_PROPERTY(onPreferredScrollerStyleDidChange, RCTDirectEventBlock) // TODO(macOS GH#774)
103104
RCT_EXPORT_VIEW_PROPERTY(inverted, BOOL)
104105
#if defined(__IPHONE_OS_VERSION_MAX_ALLOWED) && __IPHONE_OS_VERSION_MAX_ALLOWED >= 130000 /* __IPHONE_13_0 */

packages/rn-tester/js/examples/ScrollView/ScrollViewExample.js

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -491,6 +491,49 @@ if (Platform.OS === 'ios') {
491491
}
492492
exports.examples = examples;
493493

494+
// TODO [(macOS GH#774)
495+
if (Platform.OS === 'macos') {
496+
examples.push({
497+
title: '<ScrollView> (inverted = true)\n',
498+
description:
499+
"You can display <ScrollView>'s child components in inverted order",
500+
render: function (): React.Node {
501+
return <InvertedContentExample />;
502+
},
503+
});
504+
}
505+
506+
const InvertedContentExample = () => {
507+
const [inverted, setInverted] = useState(true);
508+
const [items, setItems] = useState(ITEMS);
509+
return (
510+
<>
511+
<ScrollView
512+
style={[styles.scrollView, {height: 200}]}
513+
inverted={inverted}>
514+
{items.map(createItemRow)}
515+
</ScrollView>
516+
<Text style={{paddingTop: 10, paddingBottom: 10}}>
517+
Same example as above, but with the opposite inverted option. Inverted
518+
= {inverted.toString()}
519+
</Text>
520+
<ScrollView
521+
style={[styles.scrollView, {height: 200}]}
522+
inverted={!inverted}>
523+
{items.map(createItemRow)}
524+
</ScrollView>
525+
<Button
526+
label={'toggle inverted'}
527+
onPress={() => {
528+
setInverted(!inverted);
529+
setItems([...Array(14)].map((_, i) => `Item ${i}`));
530+
}}
531+
/>
532+
</>
533+
);
534+
};
535+
// ]TODO(macOS GH#774)
536+
494537
const AndroidScrollBarOptions = () => {
495538
const [persistentScrollBar, setPersistentScrollBar] = useState(false);
496539
return (

0 commit comments

Comments
 (0)