From 9f13d9f9e6b94342b1ec9fe64616ccead507a1dd Mon Sep 17 00:00:00 2001 From: Rohan Kulkarni Date: Tue, 11 Aug 2026 20:20:44 -0700 Subject: [PATCH] Fix inverted FlatList RefreshControl indicator at visual bottom (#17553) When FlatList is inverted, scaleY: -1 transform causes RefreshControl to appear at visual bottom instead of visual top. This fix auto-calculates progressViewOffset = visibleLength when inverted and no user offset is provided, moving the Android SwipeRefreshLayout indicator to visual top. Also clones custom refreshControl with offset when needed. Fixes #17553 Supersedes #55464 with custom-control handling. [GENERAL] [FIXED] - Fix inverted FlatList RefreshControl indicator appearing at visual bottom instead of visual top (#17553) --- .../Lists/VirtualizedList.js | 51 +++-- .../Lists/__tests__/VirtualizedList-test.js | 185 ++++++++++++++++++ 2 files changed, 221 insertions(+), 15 deletions(-) diff --git a/packages/virtualized-lists/Lists/VirtualizedList.js b/packages/virtualized-lists/Lists/VirtualizedList.js index a8593403e4cd..02d64f9691c2 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 6547145e9201..730def98a06d 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(() => {