Skip to content

Commit 9f13d9f

Browse files
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)
1 parent 22cfb5c commit 9f13d9f

2 files changed

Lines changed: 221 additions & 15 deletions

File tree

packages/virtualized-lists/Lists/VirtualizedList.js

Lines changed: 36 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1300,24 +1300,45 @@ class VirtualizedList extends StateSafePureComponent<
13001300
JSON.stringify(props.refreshing ?? 'undefined') +
13011301
'`',
13021302
);
1303+
1304+
// When the list is inverted, the scaleY: -1 transform causes the
1305+
// RefreshControl to appear at the visual bottom instead of the visual
1306+
// top (see https://github.com/react/react-native/issues/17553).
1307+
// We use progressViewOffset to reposition the refresh indicator at the
1308+
// visual top of the list. The offset is the visible height/width of the
1309+
// scroll view so the indicator moves from the physical top (visual
1310+
// bottom) to the physical bottom (visual top).
1311+
const progressViewOffset =
1312+
props.isInvertedVirtualizedList &&
1313+
props.progressViewOffset == null &&
1314+
this._scrollMetrics.visibleLength > 0
1315+
? this._scrollMetrics.visibleLength
1316+
: props.progressViewOffset;
1317+
1318+
const refreshControl =
1319+
props.refreshControl == null ? (
1320+
<RefreshControl
1321+
// $FlowFixMe[incompatible-type]
1322+
refreshing={props.refreshing}
1323+
onRefresh={onRefresh}
1324+
progressViewOffset={progressViewOffset}
1325+
/>
1326+
) : props.isInvertedVirtualizedList &&
1327+
// $FlowFixMe[prop-missing] props may not have progressViewOffset
1328+
props.refreshControl.props?.progressViewOffset == null &&
1329+
this._scrollMetrics.visibleLength > 0 &&
1330+
props.progressViewOffset == null ? (
1331+
cloneElement(props.refreshControl, {
1332+
progressViewOffset: this._scrollMetrics.visibleLength,
1333+
})
1334+
) : (
1335+
props.refreshControl
1336+
);
1337+
13031338
return (
13041339
// $FlowFixMe[prop-missing] Invalid prop usage
13051340
// $FlowFixMe[incompatible-use]
1306-
<ScrollView
1307-
{...props}
1308-
refreshControl={
1309-
props.refreshControl == null ? (
1310-
<RefreshControl
1311-
// $FlowFixMe[incompatible-type]
1312-
refreshing={props.refreshing}
1313-
onRefresh={onRefresh}
1314-
progressViewOffset={props.progressViewOffset}
1315-
/>
1316-
) : (
1317-
props.refreshControl
1318-
)
1319-
}
1320-
/>
1341+
<ScrollView {...props} refreshControl={refreshControl} />
13211342
);
13221343
} else {
13231344
// $FlowFixMe[prop-missing] Invalid prop usage

packages/virtualized-lists/Lists/__tests__/VirtualizedList-test.js

Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -248,6 +248,191 @@ describe('VirtualizedList', () => {
248248
expect(removeOwner(component.toJSON())).toMatchSnapshot();
249249
});
250250

251+
it('sets progressViewOffset on RefreshControl when inverted and layout is known', async () => {
252+
const ITEM_HEIGHT = 50;
253+
const layout = {width: 300, height: 600};
254+
let component;
255+
await act(() => {
256+
component = create(
257+
<VirtualizedList
258+
data={new Array(5).fill().map((_, ii) => ({id: String(ii)}))}
259+
getItem={(data, index) => data[index]}
260+
getItemCount={data => data.length}
261+
getItemLayout={({index}) => ({
262+
length: ITEM_HEIGHT,
263+
offset: index * ITEM_HEIGHT,
264+
})}
265+
inverted={true}
266+
keyExtractor={(item, index) => item.id}
267+
onRefresh={jest.fn()}
268+
refreshing={false}
269+
renderItem={({item}) => <item value={item.id} />}
270+
/>,
271+
);
272+
});
273+
274+
const instance = component.getInstance();
275+
276+
// Simulate layout to set visibleLength
277+
await act(() => {
278+
instance._onLayout({nativeEvent: {layout, zoomScale: 1}});
279+
});
280+
281+
// Force re-render after layout
282+
await act(() => {
283+
instance.forceUpdate();
284+
});
285+
286+
const tree = component.toJSON();
287+
// The RefreshControl should have progressViewOffset equal to the visible height
288+
const refreshControl = tree.props.refreshControl;
289+
expect(refreshControl.props.progressViewOffset).toBe(layout.height);
290+
});
291+
292+
it('does not set progressViewOffset when not inverted', async () => {
293+
const ITEM_HEIGHT = 50;
294+
const layout = {width: 300, height: 600};
295+
let component;
296+
await act(() => {
297+
component = create(
298+
<VirtualizedList
299+
data={new Array(5).fill().map((_, ii) => ({id: String(ii)}))}
300+
getItem={(data, index) => data[index]}
301+
getItemCount={data => data.length}
302+
getItemLayout={({index}) => ({
303+
length: ITEM_HEIGHT,
304+
offset: index * ITEM_HEIGHT,
305+
})}
306+
inverted={false}
307+
keyExtractor={(item, index) => item.id}
308+
onRefresh={jest.fn()}
309+
refreshing={false}
310+
renderItem={({item}) => <item value={item.id} />}
311+
/>,
312+
);
313+
});
314+
315+
const instance = component.getInstance();
316+
317+
await act(() => {
318+
instance._onLayout({nativeEvent: {layout, zoomScale: 1}});
319+
});
320+
321+
await act(() => {
322+
instance.forceUpdate();
323+
});
324+
325+
const tree = component.toJSON();
326+
const refreshControl = tree.props.refreshControl;
327+
// progressViewOffset should be undefined when not inverted
328+
expect(refreshControl.props.progressViewOffset).toBeUndefined();
329+
});
330+
331+
it('respects user-provided progressViewOffset when inverted', async () => {
332+
const ITEM_HEIGHT = 50;
333+
const layout = {width: 300, height: 600};
334+
const customOffset = 100;
335+
let component;
336+
await act(() => {
337+
component = create(
338+
<VirtualizedList
339+
data={new Array(5).fill().map((_, ii) => ({id: String(ii)}))}
340+
getItem={(data, index) => data[index]}
341+
getItemCount={data => data.length}
342+
getItemLayout={({index}) => ({
343+
length: ITEM_HEIGHT,
344+
offset: index * ITEM_HEIGHT,
345+
})}
346+
inverted={true}
347+
keyExtractor={(item, index) => item.id}
348+
onRefresh={jest.fn()}
349+
refreshing={false}
350+
progressViewOffset={customOffset}
351+
renderItem={({item}) => <item value={item.id} />}
352+
/>,
353+
);
354+
});
355+
356+
const instance = component.getInstance();
357+
358+
await act(() => {
359+
instance._onLayout({nativeEvent: {layout, zoomScale: 1}});
360+
});
361+
362+
await act(() => {
363+
instance.forceUpdate();
364+
});
365+
366+
const tree = component.toJSON();
367+
const refreshControl = tree.props.refreshControl;
368+
// User-provided progressViewOffset should be respected
369+
expect(refreshControl.props.progressViewOffset).toBe(customOffset);
370+
});
371+
372+
it('does not set progressViewOffset before layout when inverted', async () => {
373+
let component;
374+
await act(() => {
375+
component = create(
376+
<VirtualizedList
377+
data={new Array(5).fill().map((_, ii) => ({id: String(ii)}))}
378+
getItem={(data, index) => data[index]}
379+
getItemCount={data => data.length}
380+
getItemLayout={({index}) => ({length: 50, offset: index * 50})}
381+
inverted={true}
382+
keyExtractor={(item, index) => item.id}
383+
onRefresh={jest.fn()}
384+
refreshing={false}
385+
renderItem={({item}) => <item value={item.id} />}
386+
/>,
387+
);
388+
});
389+
390+
const tree = component.toJSON();
391+
const refreshControl = tree.props.refreshControl;
392+
// Before layout, visibleLength is 0, so offset should be undefined to avoid flicker
393+
expect(refreshControl.props.progressViewOffset).toBeUndefined();
394+
});
395+
396+
it('clones custom refreshControl with offset when inverted', async () => {
397+
const ITEM_HEIGHT = 50;
398+
const layout = {width: 300, height: 600};
399+
const RefreshControl = require('react-native').RefreshControl;
400+
let component;
401+
await act(() => {
402+
component = create(
403+
<VirtualizedList
404+
data={new Array(5).fill().map((_, ii) => ({id: String(ii)}))}
405+
getItem={(data, index) => data[index]}
406+
getItemCount={data => data.length}
407+
getItemLayout={({index}) => ({
408+
length: ITEM_HEIGHT,
409+
offset: index * ITEM_HEIGHT,
410+
})}
411+
inverted={true}
412+
keyExtractor={(item, index) => item.id}
413+
onRefresh={jest.fn()}
414+
refreshing={false}
415+
refreshControl={<RefreshControl refreshing={false} />}
416+
renderItem={({item}) => <item value={item.id} />}
417+
/>,
418+
);
419+
});
420+
421+
const instance = component.getInstance();
422+
423+
await act(() => {
424+
instance._onLayout({nativeEvent: {layout, zoomScale: 1}});
425+
});
426+
427+
await act(() => {
428+
instance.forceUpdate();
429+
});
430+
431+
const tree = component.toJSON();
432+
const refreshControl = tree.props.refreshControl;
433+
expect(refreshControl.props.progressViewOffset).toBe(layout.height);
434+
});
435+
251436
it('test getItem functionality where data is not an Array', async () => {
252437
let component;
253438
await act(() => {

0 commit comments

Comments
 (0)