Skip to content

Commit 64f3868

Browse files
committed
Do not virtualize items adjacent to the last focused item
This change also includes the contents of facebook#32638 This change makes VirtualizedList track the last focused cell, through the capture phase of `onFocus`. It will keep the last focus cell, and its neighbors rendered. This allows for some basic keyboard interactions, like tab/up/down when on an item out of viewport. We keep the last focused rendered even if blurred for the scenario of tabbing in and and out of the VirtualizedList. Validated via UT.
1 parent 1cff8dc commit 64f3868

File tree

7 files changed

+889
-5
lines changed

7 files changed

+889
-5
lines changed

Libraries/Components/View/ViewPropTypes.js

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,9 +32,11 @@ import type {
3232
export type ViewLayout = Layout;
3333
export type ViewLayoutEvent = LayoutEvent;
3434

35-
type BubblingEventProps = $ReadOnly<{|
35+
type FocusEventProps = $ReadOnly<{|
3636
onBlur?: ?(event: BlurEvent) => mixed,
37+
onBlurCapture?: ?(event: BlurEvent) => mixed,
3738
onFocus?: ?(event: FocusEvent) => mixed,
39+
onFocusCapture?: ?(event: FocusEvent) => mixed,
3840
|}>;
3941

4042
type DirectEventProps = $ReadOnly<{|
@@ -377,7 +379,7 @@ type IOSViewProps = $ReadOnly<{|
377379
|}>;
378380

379381
export type ViewProps = $ReadOnly<{|
380-
...BubblingEventProps,
382+
...FocusEventProps,
381383
...DirectEventProps,
382384
...GestureResponderEventProps,
383385
...MouseEventProps,

Libraries/Lists/VirtualizedList.js

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ import {
4141
type ChildListState,
4242
type ListDebugInfo,
4343
} from './VirtualizedListContext';
44+
import type {FocusEvent} from '../Types/CoreEventTypes';
4445

4546
import {CellRenderMask} from './CellRenderMask';
4647
import clamp from '../Utilities/clamp';
@@ -314,6 +315,7 @@ let _keylessItemComponentName: string = '';
314315
type State = {
315316
renderMask: CellRenderMask,
316317
cellsAroundViewport: {first: number, last: number},
318+
lastFocusedItem: ?number,
317319
};
318320

319321
/**
@@ -737,6 +739,7 @@ class VirtualizedList extends React.PureComponent<Props, State> {
737739
let initialState: State = {
738740
cellsAroundViewport: initialRenderRegion,
739741
renderMask: VirtualizedList._createRenderMask(props, initialRenderRegion),
742+
lastFocusedItem: null,
740743
};
741744

742745
if (this._isNestedWithSameOrientation()) {
@@ -754,6 +757,7 @@ class VirtualizedList extends React.PureComponent<Props, State> {
754757
static _createRenderMask(
755758
props: Props,
756759
cellsAroundViewport: {first: number, last: number},
760+
lastFocusedItem: ?number,
757761
): CellRenderMask {
758762
const itemCount = props.getItemCount(props.data);
759763

@@ -766,6 +770,14 @@ class VirtualizedList extends React.PureComponent<Props, State> {
766770

767771
const renderMask = new CellRenderMask(itemCount);
768772

773+
// Keep the items around the last focused rendered, to allow for keyboard
774+
// navigation
775+
if (lastFocusedItem) {
776+
const first = Math.max(0, lastFocusedItem - 1);
777+
const last = Math.min(itemCount - 1, lastFocusedItem + 1);
778+
renderMask.addCells({first, last});
779+
}
780+
769781
if (itemCount > 0) {
770782
renderMask.addCells(cellsAroundViewport);
771783

@@ -960,6 +972,7 @@ class VirtualizedList extends React.PureComponent<Props, State> {
960972
return {
961973
cellsAroundViewport: prevState.cellsAroundViewport,
962974
renderMask: VirtualizedList._createRenderMask(newProps, constrainedCells),
975+
lastFocusedItem: prevState.lastFocusedItem,
963976
};
964977
}
965978

@@ -1004,6 +1017,7 @@ class VirtualizedList extends React.PureComponent<Props, State> {
10041017
prevCellKey={prevCellKey}
10051018
onUpdateSeparators={this._onUpdateSeparators}
10061019
onLayout={e => this._onCellLayout(e, key, ii)}
1020+
onFocusCapture={e => this._onCellFocusCapture(ii)}
10071021
onUnmount={this._onCellUnmount}
10081022
parentProps={this.props}
10091023
ref={ref => {
@@ -1471,6 +1485,15 @@ class VirtualizedList extends React.PureComponent<Props, State> {
14711485
this._updateViewableItems(this.props.data);
14721486
}
14731487

1488+
_onCellFocusCapture(itemIndex: number) {
1489+
const renderMask = VirtualizedList._createRenderMask(
1490+
this.props,
1491+
this.state.cellsAroundViewport,
1492+
itemIndex,
1493+
);
1494+
this.setState({...this.state, renderMask, lastFocusedItem: itemIndex});
1495+
}
1496+
14741497
_onCellUnmount = (cellKey: string) => {
14751498
const curr = this._frames[cellKey];
14761499
if (curr) {
@@ -1899,6 +1922,7 @@ class VirtualizedList extends React.PureComponent<Props, State> {
18991922
const renderMask = VirtualizedList._createRenderMask(
19001923
props,
19011924
cellsAroundViewport,
1925+
state.lastFocusedItem,
19021926
);
19031927

19041928
if (
@@ -2018,6 +2042,7 @@ type CellRendererProps = {
20182042
...
20192043
},
20202044
prevCellKey: ?string,
2045+
onFocusCapture: (event: FocusEvent) => mixed,
20212046
...
20222047
};
20232048

@@ -2132,6 +2157,7 @@ class CellRenderer extends React.Component<
21322157
index,
21332158
inversionStyle,
21342159
parentProps,
2160+
onFocusCapture,
21352161
} = this.props;
21362162
const {renderItem, getItemLayout, ListItemComponent} = parentProps;
21372163
const element = this._renderElement(
@@ -2161,18 +2187,23 @@ class CellRenderer extends React.Component<
21612187
? [styles.row, inversionStyle]
21622188
: inversionStyle;
21632189
const result = !CellRendererComponent ? (
2164-
/* $FlowFixMe[incompatible-type-arg] (>=0.89.0 site=react_native_fb) *
2190+
<View
2191+
style={cellStyle}
2192+
onLayout={onLayout}
2193+
onFocusCapture={onFocusCapture}
2194+
/* $FlowFixMe[incompatible-type-arg] (>=0.89.0 site=react_native_fb) *
21652195
This comment suppresses an error found when Flow v0.89 was deployed. *
21662196
To see the error, delete this comment and run Flow. */
2167-
<View style={cellStyle} onLayout={onLayout}>
2197+
>
21682198
{element}
21692199
{itemSeparator}
21702200
</View>
21712201
) : (
21722202
<CellRendererComponent
21732203
{...this.props}
21742204
style={cellStyle}
2175-
onLayout={onLayout}>
2205+
onLayout={onLayout}
2206+
onFocusCapture={onFocusCapture}>
21762207
{element}
21772208
{itemSeparator}
21782209
</CellRendererComponent>

Libraries/Lists/__tests__/VirtualizedList-test.js

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

1448+
it('keeps last focused item rendered', () => {
1449+
const items = generateItems(20);
1450+
const ITEM_HEIGHT = 10;
1451+
1452+
let component;
1453+
ReactTestRenderer.act(() => {
1454+
component = ReactTestRenderer.create(
1455+
<VirtualizedList
1456+
initialNumToRender={1}
1457+
windowSize={1}
1458+
{...baseItemProps(items)}
1459+
{...fixedHeightItemLayoutProps(ITEM_HEIGHT)}
1460+
/>,
1461+
);
1462+
});
1463+
1464+
ReactTestRenderer.act(() => {
1465+
simulateLayout(component, {
1466+
viewport: {width: 10, height: 50},
1467+
content: {width: 10, height: 200},
1468+
});
1469+
1470+
performAllBatches();
1471+
});
1472+
1473+
ReactTestRenderer.act(() => {
1474+
const cell3 = component.root.findByProps({value: 3});
1475+
cell3.parent.props.onFocusCapture(null);
1476+
});
1477+
1478+
ReactTestRenderer.act(() => {
1479+
simulateScroll(component, {x: 0, y: 150});
1480+
performAllBatches();
1481+
});
1482+
1483+
// Cells 1-4 should remain rendered after scrolling to the bottom of the list
1484+
expect(component).toMatchSnapshot();
1485+
1486+
ReactTestRenderer.act(() => {
1487+
const cell17 = component.root.findByProps({value: 17});
1488+
cell17.parent.props.onFocusCapture(null);
1489+
});
1490+
1491+
// Cells 2-4 should no longer be rendered after focus is moved to the end of
1492+
// the list
1493+
expect(component).toMatchSnapshot();
1494+
1495+
ReactTestRenderer.act(() => {
1496+
simulateScroll(component, {x: 0, y: 0});
1497+
performAllBatches();
1498+
});
1499+
1500+
// Cells 16-18 should remain rendered after scrolling back to the top of the
1501+
// list
1502+
expect(component).toMatchSnapshot();
1503+
});
1504+
14481505
function generateItems(count) {
14491506
return Array(count)
14501507
.fill()

Libraries/Lists/__tests__/__snapshots__/FlatList-test.js.snap

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ exports[`FlatList renders all the bells and whistles 1`] = `
5858
<header />
5959
</View>
6060
<View
61+
onFocusCapture={[Function]}
6162
style={null}
6263
>
6364
<View
@@ -77,6 +78,7 @@ exports[`FlatList renders all the bells and whistles 1`] = `
7778
<separator />
7879
</View>
7980
<View
81+
onFocusCapture={[Function]}
8082
style={null}
8183
>
8284
<View
@@ -96,6 +98,7 @@ exports[`FlatList renders all the bells and whistles 1`] = `
9698
<separator />
9799
</View>
98100
<View
101+
onFocusCapture={[Function]}
99102
style={null}
100103
>
101104
<View
@@ -197,6 +200,7 @@ exports[`FlatList renders simple list (multiple columns) 1`] = `
197200
>
198201
<View>
199202
<View
203+
onFocusCapture={[Function]}
200204
onLayout={[Function]}
201205
style={null}
202206
>
@@ -216,6 +220,7 @@ exports[`FlatList renders simple list (multiple columns) 1`] = `
216220
</View>
217221
</View>
218222
<View
223+
onFocusCapture={[Function]}
219224
onLayout={[Function]}
220225
style={null}
221226
>
@@ -268,6 +273,7 @@ exports[`FlatList renders simple list 1`] = `
268273
>
269274
<View>
270275
<View
276+
onFocusCapture={[Function]}
271277
onLayout={[Function]}
272278
style={null}
273279
>
@@ -276,6 +282,7 @@ exports[`FlatList renders simple list 1`] = `
276282
/>
277283
</View>
278284
<View
285+
onFocusCapture={[Function]}
279286
onLayout={[Function]}
280287
style={null}
281288
>
@@ -284,6 +291,7 @@ exports[`FlatList renders simple list 1`] = `
284291
/>
285292
</View>
286293
<View
294+
onFocusCapture={[Function]}
287295
onLayout={[Function]}
288296
style={null}
289297
>
@@ -328,6 +336,7 @@ exports[`FlatList renders simple list using ListItemComponent (multiple columns)
328336
>
329337
<View>
330338
<View
339+
onFocusCapture={[Function]}
331340
onLayout={[Function]}
332341
style={null}
333342
>
@@ -347,6 +356,7 @@ exports[`FlatList renders simple list using ListItemComponent (multiple columns)
347356
</View>
348357
</View>
349358
<View
359+
onFocusCapture={[Function]}
350360
onLayout={[Function]}
351361
style={null}
352362
>
@@ -399,6 +409,7 @@ exports[`FlatList renders simple list using ListItemComponent 1`] = `
399409
>
400410
<View>
401411
<View
412+
onFocusCapture={[Function]}
402413
onLayout={[Function]}
403414
style={null}
404415
>
@@ -407,6 +418,7 @@ exports[`FlatList renders simple list using ListItemComponent 1`] = `
407418
/>
408419
</View>
409420
<View
421+
onFocusCapture={[Function]}
410422
onLayout={[Function]}
411423
style={null}
412424
>
@@ -415,6 +427,7 @@ exports[`FlatList renders simple list using ListItemComponent 1`] = `
415427
/>
416428
</View>
417429
<View
430+
onFocusCapture={[Function]}
418431
onLayout={[Function]}
419432
style={null}
420433
>

0 commit comments

Comments
 (0)