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(() => {