diff --git a/packages/virtualized-lists/Lists/VirtualizedList.js b/packages/virtualized-lists/Lists/VirtualizedList.js
index a8593403e4c..02d64f9691c 100644
--- a/packages/virtualized-lists/Lists/VirtualizedList.js
+++ b/packages/virtualized-lists/Lists/VirtualizedList.js
@@ -1300,24 +1300,45 @@ class VirtualizedList extends StateSafePureComponent<
JSON.stringify(props.refreshing ?? 'undefined') +
'`',
);
+
+ // When the list is inverted, the scaleY: -1 transform causes the
+ // RefreshControl to appear at the visual bottom instead of the visual
+ // top (see https://github.com/react/react-native/issues/17553).
+ // We use progressViewOffset to reposition the refresh indicator at the
+ // visual top of the list. The offset is the visible height/width of the
+ // scroll view so the indicator moves from the physical top (visual
+ // bottom) to the physical bottom (visual top).
+ const progressViewOffset =
+ props.isInvertedVirtualizedList &&
+ props.progressViewOffset == null &&
+ this._scrollMetrics.visibleLength > 0
+ ? this._scrollMetrics.visibleLength
+ : props.progressViewOffset;
+
+ const refreshControl =
+ props.refreshControl == null ? (
+
+ ) : props.isInvertedVirtualizedList &&
+ // $FlowFixMe[prop-missing] props may not have progressViewOffset
+ props.refreshControl.props?.progressViewOffset == null &&
+ this._scrollMetrics.visibleLength > 0 &&
+ props.progressViewOffset == null ? (
+ cloneElement(props.refreshControl, {
+ progressViewOffset: this._scrollMetrics.visibleLength,
+ })
+ ) : (
+ props.refreshControl
+ );
+
return (
// $FlowFixMe[prop-missing] Invalid prop usage
// $FlowFixMe[incompatible-use]
-
- ) : (
- props.refreshControl
- )
- }
- />
+
);
} else {
// $FlowFixMe[prop-missing] Invalid prop usage
diff --git a/packages/virtualized-lists/Lists/__tests__/VirtualizedList-test.js b/packages/virtualized-lists/Lists/__tests__/VirtualizedList-test.js
index 6547145e920..730def98a06 100644
--- a/packages/virtualized-lists/Lists/__tests__/VirtualizedList-test.js
+++ b/packages/virtualized-lists/Lists/__tests__/VirtualizedList-test.js
@@ -248,6 +248,191 @@ describe('VirtualizedList', () => {
expect(removeOwner(component.toJSON())).toMatchSnapshot();
});
+ it('sets progressViewOffset on RefreshControl when inverted and layout is known', async () => {
+ const ITEM_HEIGHT = 50;
+ const layout = {width: 300, height: 600};
+ let component;
+ await act(() => {
+ component = create(
+ ({id: String(ii)}))}
+ getItem={(data, index) => data[index]}
+ getItemCount={data => data.length}
+ getItemLayout={({index}) => ({
+ length: ITEM_HEIGHT,
+ offset: index * ITEM_HEIGHT,
+ })}
+ inverted={true}
+ keyExtractor={(item, index) => item.id}
+ onRefresh={jest.fn()}
+ refreshing={false}
+ renderItem={({item}) => }
+ />,
+ );
+ });
+
+ const instance = component.getInstance();
+
+ // Simulate layout to set visibleLength
+ await act(() => {
+ instance._onLayout({nativeEvent: {layout, zoomScale: 1}});
+ });
+
+ // Force re-render after layout
+ await act(() => {
+ instance.forceUpdate();
+ });
+
+ const tree = component.toJSON();
+ // The RefreshControl should have progressViewOffset equal to the visible height
+ const refreshControl = tree.props.refreshControl;
+ expect(refreshControl.props.progressViewOffset).toBe(layout.height);
+ });
+
+ it('does not set progressViewOffset when not inverted', async () => {
+ const ITEM_HEIGHT = 50;
+ const layout = {width: 300, height: 600};
+ let component;
+ await act(() => {
+ component = create(
+ ({id: String(ii)}))}
+ getItem={(data, index) => data[index]}
+ getItemCount={data => data.length}
+ getItemLayout={({index}) => ({
+ length: ITEM_HEIGHT,
+ offset: index * ITEM_HEIGHT,
+ })}
+ inverted={false}
+ keyExtractor={(item, index) => item.id}
+ onRefresh={jest.fn()}
+ refreshing={false}
+ renderItem={({item}) => }
+ />,
+ );
+ });
+
+ const instance = component.getInstance();
+
+ await act(() => {
+ instance._onLayout({nativeEvent: {layout, zoomScale: 1}});
+ });
+
+ await act(() => {
+ instance.forceUpdate();
+ });
+
+ const tree = component.toJSON();
+ const refreshControl = tree.props.refreshControl;
+ // progressViewOffset should be undefined when not inverted
+ expect(refreshControl.props.progressViewOffset).toBeUndefined();
+ });
+
+ it('respects user-provided progressViewOffset when inverted', async () => {
+ const ITEM_HEIGHT = 50;
+ const layout = {width: 300, height: 600};
+ const customOffset = 100;
+ let component;
+ await act(() => {
+ component = create(
+ ({id: String(ii)}))}
+ getItem={(data, index) => data[index]}
+ getItemCount={data => data.length}
+ getItemLayout={({index}) => ({
+ length: ITEM_HEIGHT,
+ offset: index * ITEM_HEIGHT,
+ })}
+ inverted={true}
+ keyExtractor={(item, index) => item.id}
+ onRefresh={jest.fn()}
+ refreshing={false}
+ progressViewOffset={customOffset}
+ renderItem={({item}) => }
+ />,
+ );
+ });
+
+ const instance = component.getInstance();
+
+ await act(() => {
+ instance._onLayout({nativeEvent: {layout, zoomScale: 1}});
+ });
+
+ await act(() => {
+ instance.forceUpdate();
+ });
+
+ const tree = component.toJSON();
+ const refreshControl = tree.props.refreshControl;
+ // User-provided progressViewOffset should be respected
+ expect(refreshControl.props.progressViewOffset).toBe(customOffset);
+ });
+
+ it('does not set progressViewOffset before layout when inverted', async () => {
+ let component;
+ await act(() => {
+ component = create(
+ ({id: String(ii)}))}
+ getItem={(data, index) => data[index]}
+ getItemCount={data => data.length}
+ getItemLayout={({index}) => ({length: 50, offset: index * 50})}
+ inverted={true}
+ keyExtractor={(item, index) => item.id}
+ onRefresh={jest.fn()}
+ refreshing={false}
+ renderItem={({item}) => }
+ />,
+ );
+ });
+
+ const tree = component.toJSON();
+ const refreshControl = tree.props.refreshControl;
+ // Before layout, visibleLength is 0, so offset should be undefined to avoid flicker
+ expect(refreshControl.props.progressViewOffset).toBeUndefined();
+ });
+
+ it('clones custom refreshControl with offset when inverted', async () => {
+ const ITEM_HEIGHT = 50;
+ const layout = {width: 300, height: 600};
+ const RefreshControl = require('react-native').RefreshControl;
+ let component;
+ await act(() => {
+ component = create(
+ ({id: String(ii)}))}
+ getItem={(data, index) => data[index]}
+ getItemCount={data => data.length}
+ getItemLayout={({index}) => ({
+ length: ITEM_HEIGHT,
+ offset: index * ITEM_HEIGHT,
+ })}
+ inverted={true}
+ keyExtractor={(item, index) => item.id}
+ onRefresh={jest.fn()}
+ refreshing={false}
+ refreshControl={}
+ renderItem={({item}) => }
+ />,
+ );
+ });
+
+ const instance = component.getInstance();
+
+ await act(() => {
+ instance._onLayout({nativeEvent: {layout, zoomScale: 1}});
+ });
+
+ await act(() => {
+ instance.forceUpdate();
+ });
+
+ const tree = component.toJSON();
+ const refreshControl = tree.props.refreshControl;
+ expect(refreshControl.props.progressViewOffset).toBe(layout.height);
+ });
+
it('test getItem functionality where data is not an Array', async () => {
let component;
await act(() => {