Skip to content

Commit 1bd3342

Browse files
committed
Merge branch 'realize-last-focused' of https://github.com/NickGerleman/react-native into realize-last-focused
2 parents 85d0afd + b8a64f0 commit 1bd3342

File tree

3 files changed

+599
-157
lines changed

3 files changed

+599
-157
lines changed

Libraries/Lists/VirtualizedList.js

Lines changed: 71 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -755,7 +755,7 @@ class VirtualizedList extends React.PureComponent<Props, State> {
755755
static _createRenderMask(
756756
props: Props,
757757
cellsAroundViewport: {first: number, last: number},
758-
lastFocusedItem: ?number,
758+
additionalRegions?: ?$ReadOnlyArray<{first: number, last: number}>,
759759
): CellRenderMask {
760760
const itemCount = props.getItemCount(props.data);
761761

@@ -768,17 +768,12 @@ class VirtualizedList extends React.PureComponent<Props, State> {
768768

769769
const renderMask = new CellRenderMask(itemCount);
770770

771-
// Keep the items around the last focused rendered, to allow for keyboard
772-
// navigation
773-
if (lastFocusedItem) {
774-
const first = Math.max(0, lastFocusedItem - 1);
775-
const last = Math.min(itemCount - 1, lastFocusedItem + 1);
776-
renderMask.addCells({first, last});
777-
}
778-
779771
if (itemCount > 0) {
780-
if (cellsAroundViewport.last >= cellsAroundViewport.first) {
781-
renderMask.addCells(cellsAroundViewport);
772+
const allRegions = [cellsAroundViewport, ...(additionalRegions ?? [])];
773+
for (const region of allRegions) {
774+
if (region.last >= region.first) {
775+
renderMask.addCells(region);
776+
}
782777
}
783778

784779
// The initially rendered cells are retained as part of the
@@ -1016,7 +1011,7 @@ class VirtualizedList extends React.PureComponent<Props, State> {
10161011
prevCellKey={prevCellKey}
10171012
onUpdateSeparators={this._onUpdateSeparators}
10181013
onLayout={e => this._onCellLayout(e, key, ii)}
1019-
onFocusCapture={e => this._onCellFocusCapture(ii)}
1014+
onFocusCapture={e => this._onCellFocusCapture(key)}
10201015
onUnmount={this._onCellUnmount}
10211016
parentProps={this.props}
10221017
ref={ref => {
@@ -1364,7 +1359,7 @@ class VirtualizedList extends React.PureComponent<Props, State> {
13641359
_averageCellLength = 0;
13651360
// Maps a cell key to the set of keys for all outermost child lists within that cell
13661361
_cellKeysToChildListKeys: Map<string, Set<string>> = new Map();
1367-
_cellRefs = {};
1362+
_cellRefs: {[string]: ?CellRenderer} = {};
13681363
_fillRateHelper: FillRateHelper;
13691364
_frames = {};
13701365
_footerLength = 0;
@@ -1376,7 +1371,7 @@ class VirtualizedList extends React.PureComponent<Props, State> {
13761371
_hiPriInProgress: boolean = false; // flag to prevent infinite hiPri cell limit update
13771372
_highestMeasuredFrameIndex = 0;
13781373
_indicesToKeys: Map<number, string> = new Map();
1379-
_lastFocusedItem: ?number = null;
1374+
_lastFocusedCellKey: ?string = null;
13801375
_nestedChildLists: Map<
13811376
string,
13821377
{
@@ -1485,12 +1480,12 @@ class VirtualizedList extends React.PureComponent<Props, State> {
14851480
this._updateViewableItems(this.props.data);
14861481
}
14871482

1488-
_onCellFocusCapture(itemIndex: number) {
1489-
this._lastFocusedItem = itemIndex;
1483+
_onCellFocusCapture(cellKey: string) {
1484+
this._lastFocusedCellKey = cellKey;
14901485
const renderMask = VirtualizedList._createRenderMask(
14911486
this.props,
14921487
this.state.cellsAroundViewport,
1493-
this._lastFocusedItem,
1488+
this._getNonViewportRenderRegions(),
14941489
);
14951490

14961491
if (!renderMask.equals(this.state.renderMask)) {
@@ -1847,7 +1842,7 @@ class VirtualizedList extends React.PureComponent<Props, State> {
18471842
}
18481843
// Mark as high priority if we're close to the end of the last item
18491844
// But only if there are items after the last rendered item
1850-
if (last < itemCount - 1) {
1845+
if (last > 0 && last < itemCount - 1) {
18511846
const distBottom =
18521847
this._getFrameMetricsApprox(last).offset - (offset + visibleLength);
18531848
hiPri =
@@ -1926,7 +1921,7 @@ class VirtualizedList extends React.PureComponent<Props, State> {
19261921
const renderMask = VirtualizedList._createRenderMask(
19271922
props,
19281923
cellsAroundViewport,
1929-
this._lastFocusedItem,
1924+
this._getNonViewportRenderRegions(),
19301925
);
19311926

19321927
if (
@@ -1959,7 +1954,11 @@ class VirtualizedList extends React.PureComponent<Props, State> {
19591954
// check for invalid frames due to row re-ordering
19601955
return frame;
19611956
} else {
1962-
const {getItemLayout} = this.props;
1957+
const {data, getItemCount, getItemLayout} = this.props;
1958+
invariant(
1959+
index >= 0 && index < getItemCount(data),
1960+
'Tried to get frame for out of range index ' + index,
1961+
);
19631962
invariant(
19641963
!getItemLayout,
19651964
'Should not have to estimate frames when a measurement metrics function is provided',
@@ -1982,7 +1981,7 @@ class VirtualizedList extends React.PureComponent<Props, State> {
19821981
} => {
19831982
const {data, getItem, getItemCount, getItemLayout} = this.props;
19841983
invariant(
1985-
getItemCount(data) > index,
1984+
index >= 0 && index < getItemCount(data),
19861985
'Tried to get frame for out of range index ' + index,
19871986
);
19881987
const item = getItem(data, index);
@@ -1998,6 +1997,57 @@ class VirtualizedList extends React.PureComponent<Props, State> {
19981997
return frame;
19991998
};
20001999

2000+
_getNonViewportRenderRegions = (): $ReadOnlyArray<{
2001+
first: number,
2002+
last: number,
2003+
}> => {
2004+
// Keep a viewport's worth of content around the last focused cell to allow
2005+
// random navigation around it without any blanking. E.g. tabbing from one
2006+
// focused item out of viewport to another.
2007+
if (
2008+
!(this._lastFocusedCellKey && this._cellRefs[this._lastFocusedCellKey])
2009+
) {
2010+
return [];
2011+
}
2012+
2013+
const lastFocusedCellRenderer = this._cellRefs[this._lastFocusedCellKey];
2014+
const focusedCellIndex = lastFocusedCellRenderer.props.index;
2015+
const itemCount = this.props.getItemCount(this.props.data);
2016+
2017+
// The cell may have been unmounted and have a stale index
2018+
if (
2019+
focusedCellIndex >= itemCount ||
2020+
this._indicesToKeys.get(focusedCellIndex) !== this._lastFocusedCellKey
2021+
) {
2022+
return [];
2023+
}
2024+
2025+
let first = focusedCellIndex;
2026+
let heightOfCellsBeforeFocused = 0;
2027+
for (
2028+
let i = first - 1;
2029+
i >= 0 && heightOfCellsBeforeFocused < this._scrollMetrics.visibleLength;
2030+
i--
2031+
) {
2032+
first--;
2033+
heightOfCellsBeforeFocused += this._getFrameMetricsApprox(i).length;
2034+
}
2035+
2036+
let last = focusedCellIndex;
2037+
let heightOfCellsAfterFocused = 0;
2038+
for (
2039+
let i = last + 1;
2040+
i < itemCount &&
2041+
heightOfCellsAfterFocused < this._scrollMetrics.visibleLength;
2042+
i++
2043+
) {
2044+
last++;
2045+
heightOfCellsAfterFocused += this._getFrameMetricsApprox(i).length;
2046+
}
2047+
2048+
return [{first, last}];
2049+
};
2050+
20012051
_updateViewableItems(data: any) {
20022052
const {getItemCount} = this.props;
20032053

Libraries/Lists/__tests__/VirtualizedList-test.js

Lines changed: 128 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1445,7 +1445,7 @@ it('renders windowSize derived region at bottom', () => {
14451445
expect(component).toMatchSnapshot();
14461446
});
14471447

1448-
it('keeps last focused item rendered', () => {
1448+
it('keeps viewport below last focused rendered', () => {
14491449
const items = generateItems(20);
14501450
const ITEM_HEIGHT = 10;
14511451

@@ -1479,24 +1479,147 @@ it('keeps last focused item rendered', () => {
14791479
performAllBatches();
14801480
});
14811481

1482-
// Cells 1-4 should remain rendered after scrolling to the bottom of the list
1482+
// Cells 1-8 should remain rendered after scrolling to the bottom of the list
14831483
expect(component).toMatchSnapshot();
1484+
});
1485+
1486+
it('virtualizes away last focused item if focus changes to a new cell', () => {
1487+
const items = generateItems(20);
1488+
const ITEM_HEIGHT = 10;
1489+
1490+
let component;
1491+
ReactTestRenderer.act(() => {
1492+
component = ReactTestRenderer.create(
1493+
<VirtualizedList
1494+
initialNumToRender={1}
1495+
windowSize={1}
1496+
{...baseItemProps(items)}
1497+
{...fixedHeightItemLayoutProps(ITEM_HEIGHT)}
1498+
/>,
1499+
);
1500+
});
1501+
1502+
ReactTestRenderer.act(() => {
1503+
simulateLayout(component, {
1504+
viewport: {width: 10, height: 50},
1505+
content: {width: 10, height: 200},
1506+
});
1507+
1508+
performAllBatches();
1509+
});
1510+
1511+
ReactTestRenderer.act(() => {
1512+
component.getInstance()._onCellFocusCapture(3);
1513+
});
1514+
1515+
ReactTestRenderer.act(() => {
1516+
simulateScroll(component, {x: 0, y: 150});
1517+
performAllBatches();
1518+
});
14841519

14851520
ReactTestRenderer.act(() => {
14861521
component.getInstance()._onCellFocusCapture(17);
14871522
});
14881523

1489-
// Cells 2-4 should no longer be rendered after focus is moved to the end of
1524+
// Cells 1-8 should no longer be rendered after focus is moved to the end of
14901525
// the list
14911526
expect(component).toMatchSnapshot();
1527+
});
1528+
1529+
it('keeps viewport above last focused rendered', () => {
1530+
const items = generateItems(20);
1531+
const ITEM_HEIGHT = 10;
1532+
1533+
let component;
1534+
ReactTestRenderer.act(() => {
1535+
component = ReactTestRenderer.create(
1536+
<VirtualizedList
1537+
initialNumToRender={1}
1538+
windowSize={1}
1539+
{...baseItemProps(items)}
1540+
{...fixedHeightItemLayoutProps(ITEM_HEIGHT)}
1541+
/>,
1542+
);
1543+
});
1544+
1545+
ReactTestRenderer.act(() => {
1546+
simulateLayout(component, {
1547+
viewport: {width: 10, height: 50},
1548+
content: {width: 10, height: 200},
1549+
});
1550+
1551+
performAllBatches();
1552+
});
1553+
1554+
ReactTestRenderer.act(() => {
1555+
component.getInstance()._onCellFocusCapture(3);
1556+
});
1557+
1558+
ReactTestRenderer.act(() => {
1559+
simulateScroll(component, {x: 0, y: 150});
1560+
performAllBatches();
1561+
});
1562+
1563+
ReactTestRenderer.act(() => {
1564+
component.getInstance()._onCellFocusCapture(17);
1565+
});
14921566

14931567
ReactTestRenderer.act(() => {
14941568
simulateScroll(component, {x: 0, y: 0});
14951569
performAllBatches();
14961570
});
14971571

1498-
// Cells 16-18 should remain rendered after scrolling back to the top of the
1499-
// list
1572+
// Cells 12-19 should remain rendered after scrolling to the top of the list
1573+
expect(component).toMatchSnapshot();
1574+
});
1575+
1576+
it('virtualizes away last focused index if item removed', () => {
1577+
const items = generateItems(20);
1578+
const ITEM_HEIGHT = 10;
1579+
1580+
let component;
1581+
ReactTestRenderer.act(() => {
1582+
component = ReactTestRenderer.create(
1583+
<VirtualizedList
1584+
initialNumToRender={1}
1585+
windowSize={1}
1586+
{...baseItemProps(items)}
1587+
{...fixedHeightItemLayoutProps(ITEM_HEIGHT)}
1588+
/>,
1589+
);
1590+
});
1591+
1592+
ReactTestRenderer.act(() => {
1593+
simulateLayout(component, {
1594+
viewport: {width: 10, height: 50},
1595+
content: {width: 10, height: 200},
1596+
});
1597+
1598+
performAllBatches();
1599+
});
1600+
1601+
ReactTestRenderer.act(() => {
1602+
component.getInstance()._onCellFocusCapture(3);
1603+
});
1604+
1605+
ReactTestRenderer.act(() => {
1606+
simulateScroll(component, {x: 0, y: 150});
1607+
performAllBatches();
1608+
});
1609+
1610+
const itemsWithoutFocused = [...items.slice(0, 3), ...items.slice(4)];
1611+
ReactTestRenderer.act(() => {
1612+
component.update(
1613+
<VirtualizedList
1614+
initialNumToRender={1}
1615+
windowSize={1}
1616+
{...baseItemProps(itemsWithoutFocused)}
1617+
{...fixedHeightItemLayoutProps(ITEM_HEIGHT)}
1618+
/>,
1619+
);
1620+
});
1621+
1622+
// Cells 1-8 should no longer be rendered
15001623
expect(component).toMatchSnapshot();
15011624
});
15021625

0 commit comments

Comments
 (0)