Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 53 additions & 2 deletions packages/virtualized-lists/Lists/VirtualizedList.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,11 @@
* @format
*/

import type {CellMetricProps, ListOrientation} from './ListMetricsAggregator';
import type {
CellMetricProps,
CellMetrics,
ListOrientation,
} from './ListMetricsAggregator';
import type {ViewToken} from './ViewabilityHelper';
import type {
Item,
Expand Down Expand Up @@ -631,7 +635,7 @@ class VirtualizedList extends StateSafePureComponent<
} else {
// If we have a pending scroll update, we should not adjust the render window as it
// might override the correct window.
if (pendingScrollUpdateCount > 0) {
if (pendingScrollUpdateCount > 0 || this._pendingAnchorCorrection) {
return cellsAroundViewport.last >= getItemCount(data)
? VirtualizedList._constrainToItemCount(cellsAroundViewport, props)
: cellsAroundViewport;
Expand Down Expand Up @@ -1242,6 +1246,10 @@ class VirtualizedList extends StateSafePureComponent<
_nestedChildLists: ChildListCollection<VirtualizedList> =
new ChildListCollection();
_offsetFromParentVirtualizedList: number = 0;
// Set when the cell maintainVisibleContentPosition is anchored on moves, until
// the scroll event with native's correction for it. Not state, so that cells
// updates queued by earlier layout events in the same batch see it too.
_pendingAnchorCorrection: boolean = false;
_pendingViewabilityUpdate: boolean = false;
_prevParentOffset: number = 0;
_scrollMetrics: {
Expand Down Expand Up @@ -1328,6 +1336,8 @@ class VirtualizedList extends StateSafePureComponent<
cellKey: string,
cellIndex: number,
): void => {
const anchorMetrics =
this._getMaintainVisibleContentPositionAnchor(cellIndex);
const layoutHasChanged = this._listMetrics.notifyCellLayout({
cellIndex,
cellKey,
Expand All @@ -1336,6 +1346,18 @@ class VirtualizedList extends StateSafePureComponent<
});

if (layoutHasChanged) {
if (
anchorMetrics != null &&
this._listMetrics.getCellMetrics(cellIndex, this.props)?.offset !==
anchorMetrics.offset
) {
// Native maintainVisibleContentPosition shifts the scroll offset by as
// much as this cell moved, but the scroll event reporting that arrives
// after this layout event. Until then the scroll offset is stale
// relative to the cell metrics, and a window computed from the two is
// off by the shift.
this._pendingAnchorCorrection = true;
}
this._scheduleCellsToRenderUpdate();
}

Expand All @@ -1344,6 +1366,34 @@ class VirtualizedList extends StateSafePureComponent<
this._updateViewableItems(this.props, this.state.cellsAroundViewport);
};

/**
* The metrics of the cell at `cellIndex` if native
* `maintainVisibleContentPosition` is anchored on it: a mounted cell laid out
* across the start of the viewport.
*/
_getMaintainVisibleContentPositionAnchor(cellIndex: number): ?CellMetrics {
const {data, getItemCount, getItemLayout, maintainVisibleContentPosition} =
this.props;
if (
maintainVisibleContentPosition == null ||
getItemLayout != null ||
// Native picks its anchor by physical position, not flow-relative, so in
// a horizontal RTL list it isn't this cell.
this._isHorizontalRTL() ||
cellIndex >= getItemCount(data)
) {
return null;
}
const metrics = this._listMetrics.getCellMetrics(cellIndex, this.props);
const {offset} = this._scrollMetrics;
return metrics != null &&
metrics.isMounted &&
metrics.offset <= offset &&
offset < metrics.offset + metrics.length
? metrics
: null;
}

_onCellFocusCapture = (cellKey: string) => {
this._lastFocusedCellKey = cellKey;
if (ReactNativeFeatureFlags.deferFlatListFocusChangeRenderUpdate()) {
Expand Down Expand Up @@ -1783,6 +1833,7 @@ class VirtualizedList extends StateSafePureComponent<
visibleLength,
zoomScale,
};
this._pendingAnchorCorrection = false;
if (this.state.pendingScrollUpdateCount > 0) {
this.setState<'pendingScrollUpdateCount'>({pendingScrollUpdateCount: 0});
}
Expand Down
76 changes: 76 additions & 0 deletions packages/virtualized-lists/Lists/__tests__/VirtualizedList-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -3048,6 +3048,82 @@ it('handles rapid prepends with coalesced scroll event (regression for #53542)',
).toBeGreaterThanOrEqual(0);
});

// Trigger: Content above the cell at the start of the viewport changes size, e.g. cells mounted above it are
// taller than estimated. Native MVCP moves the scroll offset by the same amount, but the layout events reach
// JS before the scroll event, and a cells update runs in between.
// Expected: The render window waits for the corrected offset and keeps the anchor cell rendered.
it('waits for the maintainVisibleContentPosition correction when the anchor cell moves', async () => {
const items = generateItems(40);
const ITEM_HEIGHT = 10;

let component;
await act(() => {
component = create(
<VirtualizedList
initialNumToRender={1}
windowSize={1}
maintainVisibleContentPosition={{minIndexForVisible: 0}}
{...baseItemProps(items)}
/>,
);
});
const instance = component.getInstance();
// Lays out the rendered cells: cell 0 (kept for scroll-to-top) and the
// render window, `shift` further down than their index says.
const layoutRenderedCells = (shift = 0) => {
const {first, last} = instance.state.cellsAroundViewport;
const indices = [0];
for (let i = Math.max(1, first); i <= last; i++) {
indices.push(i);
}
for (const i of indices) {
simulateCellLayout(component, items, i, {
width: 10,
height: ITEM_HEIGHT,
x: 0,
y: i * ITEM_HEIGHT + shift,
});
}
};

await act(() => {
simulateLayout(component, {
viewport: {width: 10, height: 50},
content: {width: 10, height: items.length * ITEM_HEIGHT},
});
layoutRenderedCells();
simulateScroll(component, {x: 0, y: 205});
performAllBatches();
});
await act(() => {
layoutRenderedCells();
performAllBatches();
});

// Cell 20 is laid out across the start of the viewport.
expect(instance.state.cellsAroundViewport).toEqual({first: 20, last: 25});

// Content above the cells grows by 500 (e.g. a header), moving cell 20 down
// by 500. A cells update runs before the scroll event with native's
// correction.
await act(() => {
layoutRenderedCells(500);
performAllBatches();
});

// The offset (205) is now 495 above cell 20. The window waits for the
// correction rather than moving there and unmounting cell 20.
expect(instance.state.cellsAroundViewport).toEqual({first: 20, last: 25});

await act(() => {
simulateScroll(component, {x: 0, y: 705});
performAllBatches();
});

expect(instance.state.cellsAroundViewport.first).toBeLessThanOrEqual(20);
expect(instance.state.cellsAroundViewport.last).toBeGreaterThanOrEqual(20);
});

function generateItems(count, startKey = 0) {
return Array(count)
.fill()
Expand Down
Loading