Skip to content

Commit 1119725

Browse files
committed
[PR] Implement native inverted behaviors for ScrollView (microsoft#8440)
This PR modifies ScrollViewManager and VirtualizedList to work around the limitations of the RN core approach to list inversion. Specifically, the RN core approach to list inversion uses scroll axis transforms to flip the list content. While this creates the visual appearance of an inverted list, scrolling the list via keyboard or mouse wheel results in inverted scroll direction. To accomplish native inverted scroll behaviors, we focused on four expected behaviors of inverted lists: 1. When content loads "above" the view port in an inverted list, the visual content must remain anchored (as if the content renders below it in a non-inverted list). 2. When scrolled to the "start" of an inverted list (in absolute terms, the bottom of the scroll view), content appended to the start of the list must scroll into view synchronously (i.e., no delay between content rendering and view port changing). 3. When content renders on screen, it must render from bottom to top, so render passes that take multiple frames do not appear to scroll to the start of the list. 4. When imperatively scrolling to the "start" of the content, we must always scroll to the latest content, even if the content rendered after the scroll-to-start animation already began. For 1., we leverage the XAML `CanBeScrollAnchor` property on each top-level item in the list view. While this is an imperfect solution (re-rendering of this content while in the view port can result in view port shifts as new content renders above), it is a good trade-off of performance and functionality. For 2., we leverage the XAML `HorizontalAnchorRatio` and `VerticalAnchorRatio` properties. XAML has a special case for inverted lists when setting these property values to `1.0`. It instructs XAML to synchronously scroll to and render new content when scrolled to the bottom edge of the ScrollViewer. For 3., we leverage Yoga's implementation of `flexDirection: column-reverse` and `flexDirection: row-reverse` to ensure content is rendered from bottom to top. For 4., we implemented `ScrollViewViewChanger` to continuously check if the target scroll offset has changed since starting an animated scroll-to-end operation. If the target scroll offset no longer matches the scrollable extent of the ScrollViewer, we update the target offset by calling `ChangeView` again. Fixes microsoft#4098
1 parent f862cca commit 1119725

18 files changed

+428
-25
lines changed

packages/@react-native-windows/virtualized-list/src/VirtualizedList.js

Lines changed: 96 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -384,7 +384,10 @@ class VirtualizedList extends React.PureComponent<Props, State> {
384384
scrollToEnd(params?: ?{animated?: ?boolean, ...}) {
385385
const animated = params ? params.animated : true;
386386
const veryLast = this.props.getItemCount(this.props.data) - 1;
387-
const frame = this._getFrameMetricsApprox(veryLast);
387+
const frame = this._getFrameMetricsApprox(
388+
veryLast,
389+
/*useRawMetrics:*/ true,
390+
);
388391
const offset = Math.max(
389392
0,
390393
frame.offset +
@@ -458,7 +461,7 @@ class VirtualizedList extends React.PureComponent<Props, State> {
458461
});
459462
return;
460463
}
461-
const frame = this._getFrameMetricsApprox(index);
464+
const frame = this._getFrameMetricsApprox(index, /*useRawMetrics:*/ true);
462465
const offset =
463466
Math.max(
464467
0,
@@ -608,7 +611,26 @@ class VirtualizedList extends React.PureComponent<Props, State> {
608611
}
609612

610613
_getScrollMetrics = () => {
611-
return this._scrollMetrics;
614+
// Windows-only: Invert scroll metrics when inverted prop is
615+
// set to retain monotonically increasing layout assumptions
616+
// in the direction of increasing scroll offsets.
617+
let scrollMetrics = this._scrollMetrics;
618+
if (this.props.inverted) {
619+
const {
620+
contentLength,
621+
dOffset,
622+
offset,
623+
velocity,
624+
visibleLength,
625+
} = scrollMetrics;
626+
scrollMetrics = {
627+
...scrollMetrics,
628+
dOffset: dOffset * -1,
629+
offset: contentLength - offset - visibleLength,
630+
velocity: velocity * -1,
631+
};
632+
}
633+
return scrollMetrics;
612634
};
613635

614636
hasMore(): boolean {
@@ -888,6 +910,12 @@ class VirtualizedList extends React.PureComponent<Props, State> {
888910
this.props;
889911
const {data, horizontal} = this.props;
890912
const isVirtualizationDisabled = this._isVirtualizationDisabled();
913+
// Windows-only: Reverse the layout of items via flex
914+
const containerInversionStyle = this.props.inverted
915+
? this.props.horizontal
916+
? styles.horizontallyReversed
917+
: styles.verticallyReversed
918+
: null;
891919
const inversionStyle = this.props.inverted
892920
? horizontalOrDefault(this.props.horizontal)
893921
? styles.horizontallyInverted
@@ -1018,7 +1046,14 @@ class VirtualizedList extends React.PureComponent<Props, State> {
10181046
endFrame.length -
10191047
(lastFrame.offset + lastFrame.length);
10201048
cells.push(
1021-
<View key="$tail_spacer" style={{[spacerKey]: tailSpacerLength}} />,
1049+
<View
1050+
overflowAnchor={this.props.inverted ? 'none' : undefined}
1051+
key="$tail_spacer"
1052+
style={{
1053+
[spacerKey]: tailSpacerLength,
1054+
zIndex: this.props.inverted ? 1e6 : undefined,
1055+
}}
1056+
/>,
10221057
);
10231058
}
10241059
} else if (ListEmptyComponent) {
@@ -1072,6 +1107,11 @@ class VirtualizedList extends React.PureComponent<Props, State> {
10721107
}
10731108
const scrollProps = {
10741109
...this.props,
1110+
// Windows-only: Pass through inverted container styles
1111+
contentContainerStyle: StyleSheet.compose(
1112+
containerInversionStyle,
1113+
this.props.contentContainerStyle,
1114+
),
10751115
onContentSizeChange: this._onContentSizeChange,
10761116
onLayout: this._onLayout,
10771117
onScroll: this._onScroll,
@@ -1229,7 +1269,7 @@ class VirtualizedList extends React.PureComponent<Props, State> {
12291269
this._fillRateHelper.computeBlankness(
12301270
this.props,
12311271
this.state,
1232-
this._scrollMetrics,
1272+
this._getScrollMetrics(),
12331273
);
12341274
}
12351275

@@ -1413,6 +1453,10 @@ class VirtualizedList extends React.PureComponent<Props, State> {
14131453
};
14141454

14151455
_renderDebugOverlay() {
1456+
// Windows-only: this is not implemented for inverted lists
1457+
if (this.props.inverted) {
1458+
console.warn('Debug overlay is not yet supported for inverted lists.');
1459+
}
14161460
const normalize =
14171461
this._scrollMetrics.visibleLength /
14181462
(this._scrollMetrics.contentLength || 1);
@@ -1497,7 +1541,7 @@ class VirtualizedList extends React.PureComponent<Props, State> {
14971541
_maybeCallOnEndReached() {
14981542
const {data, getItemCount, onEndReached, onEndReachedThreshold} =
14991543
this.props;
1500-
const {contentLength, visibleLength, offset} = this._scrollMetrics;
1544+
const {contentLength, visibleLength, offset} = this._getScrollMetrics();
15011545
const distanceFromEnd = contentLength - visibleLength - offset;
15021546
const threshold =
15031547
onEndReachedThreshold != null ? onEndReachedThreshold * visibleLength : 2;
@@ -1632,7 +1676,7 @@ class VirtualizedList extends React.PureComponent<Props, State> {
16321676

16331677
_scheduleCellsToRenderUpdate() {
16341678
const {first, last} = this.state;
1635-
const {offset, visibleLength, velocity} = this._scrollMetrics;
1679+
const {offset, visibleLength, velocity} = this._getScrollMetrics();
16361680
const itemCount = this.props.getItemCount(this.props.data);
16371681
let hiPri = false;
16381682
const onEndReachedThreshold = onEndReachedThresholdOrDefault(
@@ -1734,7 +1778,7 @@ class VirtualizedList extends React.PureComponent<Props, State> {
17341778
}
17351779
this.setState(state => {
17361780
let newState;
1737-
const {contentLength, offset, visibleLength} = this._scrollMetrics;
1781+
const {contentLength, offset, visibleLength} = this._getScrollMetrics();
17381782
if (!isVirtualizationDisabled) {
17391783
// If we run this with bogus data, we'll force-render window {first: 0, last: 0},
17401784
// and wipe out the initialNumToRender rendered elements.
@@ -1745,15 +1789,15 @@ class VirtualizedList extends React.PureComponent<Props, State> {
17451789
// we'll wipe out the initialNumToRender rendered elements starting at initialScrollIndex.
17461790
// So let's wait until we've scrolled the view to the right place. And until then,
17471791
// we will trust the initialScrollIndex suggestion.
1748-
if (!this.props.initialScrollIndex || this._scrollMetrics.offset) {
1792+
if (!this.props.initialScrollIndex || offset) {
17491793
newState = computeWindowedRenderLimits(
17501794
this.props.data,
17511795
this.props.getItemCount,
17521796
maxToRenderPerBatchOrDefault(this.props.maxToRenderPerBatch),
17531797
windowSizeOrDefault(this.props.windowSize),
17541798
state,
17551799
this._getFrameMetricsApprox,
1756-
this._scrollMetrics,
1800+
this._getScrollMetrics(),
17571801
);
17581802
}
17591803
}
@@ -1817,24 +1861,47 @@ class VirtualizedList extends React.PureComponent<Props, State> {
18171861

18181862
_getFrameMetricsApprox = (
18191863
index: number,
1864+
useRawMetrics?: boolean,
18201865
): {
18211866
length: number,
18221867
offset: number,
18231868
...
18241869
} => {
18251870
const frame = this._getFrameMetrics(index);
18261871
if (frame && frame.index === index) {
1827-
// check for invalid frames due to row re-ordering
1828-
return frame;
1872+
// Windows-only: Raw metrics are requested for scroll commands. Metrics
1873+
// returned from _getFrameMetrics are assumed to be inverted. To convert back
1874+
// to raw metrics, subtract the offset and length from the content length.
1875+
return this.props.inverted && useRawMetrics
1876+
? {
1877+
...frame,
1878+
offset: Math.max(
1879+
0,
1880+
this._scrollMetrics.contentLength - frame.offset - frame.length,
1881+
),
1882+
}
1883+
: frame;
18291884
} else {
18301885
const {getItemLayout} = this.props;
18311886
invariant(
18321887
!getItemLayout,
18331888
'Should not have to estimate frames when a measurement metrics function is provided',
18341889
);
1890+
1891+
// Windows-only: Raw metrics are requested for scroll commands. Metrics
1892+
// returned from _getFrameMetrics are assumed to be inverted. To compute
1893+
// approximate raw metrics, subtract the computed average offset from
1894+
// the content length.
1895+
const offset =
1896+
this.props.inverted && useRawMetrics
1897+
? Math.max(
1898+
0,
1899+
this._scrollMetrics - this._averageCellLength * (index + 1),
1900+
)
1901+
: this._averageCellLength * index;
18351902
return {
18361903
length: this._averageCellLength,
1837-
offset: this._averageCellLength * index,
1904+
offset,
18381905
};
18391906
}
18401907
};
@@ -1855,6 +1922,13 @@ class VirtualizedList extends React.PureComponent<Props, State> {
18551922
);
18561923
const item = getItem(data, index);
18571924
let frame = item && this._frames[this._keyExtractor(item, index)];
1925+
// Windows-only: Convert to inverted offsets from raw layout
1926+
if (frame && this.props.inverted) {
1927+
frame = {
1928+
...frame,
1929+
offset: this._scrollMetrics.contentLength - frame.offset - frame.length,
1930+
};
1931+
}
18581932
if (!frame || frame.index !== index) {
18591933
if (getItemLayout) {
18601934
frame = getItemLayout(data, index);
@@ -1872,7 +1946,7 @@ class VirtualizedList extends React.PureComponent<Props, State> {
18721946
this._viewabilityTuples.forEach(tuple => {
18731947
tuple.viewabilityHelper.onUpdate(
18741948
getItemCount(data),
1875-
this._scrollMetrics.offset,
1949+
this._getScrollMetrics().offset,
18761950
this._scrollMetrics.visibleLength,
18771951
this._getFrameMetrics,
18781952
this._createViewToken,
@@ -2109,10 +2183,16 @@ function describeNestedLists(childList: {
21092183

21102184
const styles = StyleSheet.create({
21112185
verticallyInverted: {
2112-
transform: [{scaleY: -1}],
2186+
/* Windows-only: do not use transform-based inversion */
21132187
},
21142188
horizontallyInverted: {
2115-
transform: [{scaleX: -1}],
2189+
/* Windows-only: do not use transform-based inversion */
2190+
},
2191+
verticallyReversed: {
2192+
flexDirection: 'column-reverse',
2193+
},
2194+
horizontallyReversed: {
2195+
flexDirection: 'row-reverse',
21162196
},
21172197
row: {
21182198
flexDirection: 'row',

vnext/Microsoft.ReactNative/Microsoft.ReactNative.vcxproj

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -337,6 +337,7 @@
337337
<ClInclude Include="Views\Image\ReactImage.h" />
338338
<ClInclude Include="Views\Image\ReactImageBrush.h" />
339339
<ClInclude Include="Views\Impl\ScrollViewUWPImplementation.h" />
340+
<ClInclude Include="Views\Impl\ScrollViewViewChanger.h" />
340341
<ClInclude Include="Views\Impl\SnapPointManagingContentControl.h" />
341342
<ClInclude Include="Views\IXamlRootView.h" />
342343
<ClInclude Include="Views\KeyboardEventHandler.h" />
@@ -665,6 +666,7 @@
665666
<ClCompile Include="Views\Image\ReactImage.cpp" />
666667
<ClCompile Include="Views\Image\ReactImageBrush.cpp" />
667668
<ClCompile Include="Views\Impl\ScrollViewUWPImplementation.cpp" />
669+
<ClCompile Include="Views\Impl\ScrollViewViewChanger.cpp" />
668670
<ClCompile Include="Views\Impl\SnapPointManagingContentControl.cpp" />
669671
<ClCompile Include="Views\KeyboardEventHandler.cpp" />
670672
<ClCompile Include="Views\PaperShadowNode.cpp" />

vnext/Microsoft.ReactNative/Microsoft.ReactNative.vcxproj.filters

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,9 @@
176176
<ClCompile Include="Views\Impl\ScrollViewUWPImplementation.cpp">
177177
<Filter>Views\Impl</Filter>
178178
</ClCompile>
179+
<ClCompile Include="Views\Impl\ScrollViewViewChanger.cpp">
180+
<Filter>Views\Impl</Filter>
181+
</ClCompile>
179182
<ClCompile Include="Views\Impl\SnapPointManagingContentControl.cpp">
180183
<Filter>Views\Impl</Filter>
181184
</ClCompile>
@@ -545,6 +548,9 @@
545548
<ClInclude Include="Views\Impl\ScrollViewUWPImplementation.h">
546549
<Filter>Views\Impl</Filter>
547550
</ClInclude>
551+
<ClInclude Include="Views\Impl\ScrollViewViewChanger.h">
552+
<Filter>Views\Impl</Filter>
553+
</ClInclude>
548554
<ClInclude Include="Views\Impl\SnapPointManagingContentControl.h">
549555
<Filter>Views\Impl</Filter>
550556
</ClInclude>

vnext/Microsoft.ReactNative/Views/Impl/ScrollViewUWPImplementation.cpp

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,18 @@ ScrollViewUWPImplementation::ScrollViewUWPImplementation(const winrt::ScrollView
1515
m_scrollViewer = winrt::make_weak(scrollViewer);
1616
}
1717

18+
void ScrollViewUWPImplementation::ContentAnchoringEnabled(bool enabled) {
19+
ScrollViewerSnapPointManager()->ContentAnchoringEnabled(enabled);
20+
}
21+
1822
void ScrollViewUWPImplementation::SetHorizontal(bool horizontal) {
1923
ScrollViewerSnapPointManager()->SetHorizontal(horizontal);
2024
}
2125

26+
void ScrollViewUWPImplementation::SetInverted(bool inverted) {
27+
ScrollViewerSnapPointManager()->SetInverted(inverted);
28+
}
29+
2230
void ScrollViewUWPImplementation::SnapToInterval(float interval) {
2331
ScrollViewerSnapPointManager()->SnapToInterval(interval);
2432
}

vnext/Microsoft.ReactNative/Views/Impl/ScrollViewUWPImplementation.h

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,9 @@ class ScrollViewUWPImplementation {
2323
public:
2424
ScrollViewUWPImplementation(const winrt::ScrollViewer &scrollViewer);
2525

26+
void ContentAnchoringEnabled(bool enabled);
2627
void SetHorizontal(bool isHorizontal);
28+
void SetInverted(bool isInverted);
2729
void SnapToInterval(float interval);
2830
void SnapToStart(bool snapToStart);
2931
void SnapToEnd(bool snapToEnd);

0 commit comments

Comments
 (0)