From 11b734d663e7299448f1dbc5a04105be62f7baca Mon Sep 17 00:00:00 2001 From: Navjot1806 Date: Sat, 7 Feb 2026 10:11:57 -0800 Subject: [PATCH 1/2] Fix inverted FlatList RefreshControl indicator position (#17553) When using an inverted FlatList with onRefresh/refreshing, the RefreshControl activity indicator appears at the visual bottom of the list instead of the visual top. This is because the scaleY: -1 transform applied to the ScrollView flips the entire view including the RefreshControl. This fix automatically sets progressViewOffset on the RefreshControl when the list is inverted, repositioning the refresh indicator at the visual top of the list. The offset is calculated from the visible height of the scroll view after layout. The user-provided progressViewOffset is respected when explicitly set. Co-Authored-By: Claude Opus 4.6 --- .../Lists/VirtualizedList.js | 16 ++- .../Lists/__tests__/VirtualizedList-test.js | 121 ++++++++++++++++++ 2 files changed, 136 insertions(+), 1 deletion(-) diff --git a/packages/virtualized-lists/Lists/VirtualizedList.js b/packages/virtualized-lists/Lists/VirtualizedList.js index 7a48d91be4d0fc..fdb81ddebab9bc 100644 --- a/packages/virtualized-lists/Lists/VirtualizedList.js +++ b/packages/virtualized-lists/Lists/VirtualizedList.js @@ -1286,6 +1286,20 @@ 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. We use progressViewOffset to reposition the refresh indicator + // at the visual top of the list. The offset is the visible height 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; + return ( // $FlowFixMe[prop-missing] Invalid prop usage // $FlowFixMe[incompatible-use] @@ -1297,7 +1311,7 @@ class VirtualizedList extends StateSafePureComponent< // $FlowFixMe[incompatible-type] refreshing={props.refreshing} onRefresh={onRefresh} - progressViewOffset={props.progressViewOffset} + progressViewOffset={progressViewOffset} /> ) : ( props.refreshControl diff --git a/packages/virtualized-lists/Lists/__tests__/VirtualizedList-test.js b/packages/virtualized-lists/Lists/__tests__/VirtualizedList-test.js index 3407a79260e3f4..d1301a22d49f1d 100644 --- a/packages/virtualized-lists/Lists/__tests__/VirtualizedList-test.js +++ b/packages/virtualized-lists/Lists/__tests__/VirtualizedList-test.js @@ -236,6 +236,127 @@ describe('VirtualizedList', () => { expect(component).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('test getItem functionality where data is not an Array', async () => { let component; await act(() => { From 161575cea2a911f2389eb5fd47986f43b5151ce4 Mon Sep 17 00:00:00 2001 From: Navjot1806 Date: Sat, 7 Feb 2026 10:17:46 -0800 Subject: [PATCH 2/2] Re-trigger CI checks after CLA signing Co-Authored-By: Claude Opus 4.6