Skip to content

Commit

Permalink
Do not virtualize items adjacent to the last focused item
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
NickGerleman committed Nov 23, 2021
1 parent 1cff8dc commit 64f3868
Show file tree
Hide file tree
Showing 7 changed files with 889 additions and 5 deletions.
6 changes: 4 additions & 2 deletions Libraries/Components/View/ViewPropTypes.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,11 @@ import type {
export type ViewLayout = Layout;
export type ViewLayoutEvent = LayoutEvent;

type BubblingEventProps = $ReadOnly<{|
type FocusEventProps = $ReadOnly<{|
onBlur?: ?(event: BlurEvent) => mixed,
onBlurCapture?: ?(event: BlurEvent) => mixed,
onFocus?: ?(event: FocusEvent) => mixed,
onFocusCapture?: ?(event: FocusEvent) => mixed,
|}>;

type DirectEventProps = $ReadOnly<{|
Expand Down Expand Up @@ -377,7 +379,7 @@ type IOSViewProps = $ReadOnly<{|
|}>;

export type ViewProps = $ReadOnly<{|
...BubblingEventProps,
...FocusEventProps,
...DirectEventProps,
...GestureResponderEventProps,
...MouseEventProps,
Expand Down
37 changes: 34 additions & 3 deletions Libraries/Lists/VirtualizedList.js
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import {
type ChildListState,
type ListDebugInfo,
} from './VirtualizedListContext';
import type {FocusEvent} from '../Types/CoreEventTypes';

import {CellRenderMask} from './CellRenderMask';
import clamp from '../Utilities/clamp';
Expand Down Expand Up @@ -314,6 +315,7 @@ let _keylessItemComponentName: string = '';
type State = {
renderMask: CellRenderMask,
cellsAroundViewport: {first: number, last: number},
lastFocusedItem: ?number,
};

/**
Expand Down Expand Up @@ -737,6 +739,7 @@ class VirtualizedList extends React.PureComponent<Props, State> {
let initialState: State = {
cellsAroundViewport: initialRenderRegion,
renderMask: VirtualizedList._createRenderMask(props, initialRenderRegion),
lastFocusedItem: null,
};

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

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

const renderMask = new CellRenderMask(itemCount);

// Keep the items around the last focused rendered, to allow for keyboard
// navigation
if (lastFocusedItem) {
const first = Math.max(0, lastFocusedItem - 1);
const last = Math.min(itemCount - 1, lastFocusedItem + 1);
renderMask.addCells({first, last});
}

if (itemCount > 0) {
renderMask.addCells(cellsAroundViewport);

Expand Down Expand Up @@ -960,6 +972,7 @@ class VirtualizedList extends React.PureComponent<Props, State> {
return {
cellsAroundViewport: prevState.cellsAroundViewport,
renderMask: VirtualizedList._createRenderMask(newProps, constrainedCells),
lastFocusedItem: prevState.lastFocusedItem,
};
}

Expand Down Expand Up @@ -1004,6 +1017,7 @@ class VirtualizedList extends React.PureComponent<Props, State> {
prevCellKey={prevCellKey}
onUpdateSeparators={this._onUpdateSeparators}
onLayout={e => this._onCellLayout(e, key, ii)}
onFocusCapture={e => this._onCellFocusCapture(ii)}
onUnmount={this._onCellUnmount}
parentProps={this.props}
ref={ref => {
Expand Down Expand Up @@ -1471,6 +1485,15 @@ class VirtualizedList extends React.PureComponent<Props, State> {
this._updateViewableItems(this.props.data);
}

_onCellFocusCapture(itemIndex: number) {
const renderMask = VirtualizedList._createRenderMask(
this.props,
this.state.cellsAroundViewport,
itemIndex,
);
this.setState({...this.state, renderMask, lastFocusedItem: itemIndex});
}

_onCellUnmount = (cellKey: string) => {
const curr = this._frames[cellKey];
if (curr) {
Expand Down Expand Up @@ -1899,6 +1922,7 @@ class VirtualizedList extends React.PureComponent<Props, State> {
const renderMask = VirtualizedList._createRenderMask(
props,
cellsAroundViewport,
state.lastFocusedItem,
);

if (
Expand Down Expand Up @@ -2018,6 +2042,7 @@ type CellRendererProps = {
...
},
prevCellKey: ?string,
onFocusCapture: (event: FocusEvent) => mixed,
...
};

Expand Down Expand Up @@ -2132,6 +2157,7 @@ class CellRenderer extends React.Component<
index,
inversionStyle,
parentProps,
onFocusCapture,
} = this.props;
const {renderItem, getItemLayout, ListItemComponent} = parentProps;
const element = this._renderElement(
Expand Down Expand Up @@ -2161,18 +2187,23 @@ class CellRenderer extends React.Component<
? [styles.row, inversionStyle]
: inversionStyle;
const result = !CellRendererComponent ? (
/* $FlowFixMe[incompatible-type-arg] (>=0.89.0 site=react_native_fb) *
<View
style={cellStyle}
onLayout={onLayout}
onFocusCapture={onFocusCapture}
/* $FlowFixMe[incompatible-type-arg] (>=0.89.0 site=react_native_fb) *
This comment suppresses an error found when Flow v0.89 was deployed. *
To see the error, delete this comment and run Flow. */
<View style={cellStyle} onLayout={onLayout}>
>
{element}
{itemSeparator}
</View>
) : (
<CellRendererComponent
{...this.props}
style={cellStyle}
onLayout={onLayout}>
onLayout={onLayout}
onFocusCapture={onFocusCapture}>
{element}
{itemSeparator}
</CellRendererComponent>
Expand Down
57 changes: 57 additions & 0 deletions Libraries/Lists/__tests__/VirtualizedList-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -1445,6 +1445,63 @@ it('renders windowSize derived region at bottom', () => {
expect(component).toMatchSnapshot();
});

it('keeps last focused item rendered', () => {
const items = generateItems(20);
const ITEM_HEIGHT = 10;

let component;
ReactTestRenderer.act(() => {
component = ReactTestRenderer.create(
<VirtualizedList
initialNumToRender={1}
windowSize={1}
{...baseItemProps(items)}
{...fixedHeightItemLayoutProps(ITEM_HEIGHT)}
/>,
);
});

ReactTestRenderer.act(() => {
simulateLayout(component, {
viewport: {width: 10, height: 50},
content: {width: 10, height: 200},
});

performAllBatches();
});

ReactTestRenderer.act(() => {
const cell3 = component.root.findByProps({value: 3});
cell3.parent.props.onFocusCapture(null);
});

ReactTestRenderer.act(() => {
simulateScroll(component, {x: 0, y: 150});
performAllBatches();
});

// Cells 1-4 should remain rendered after scrolling to the bottom of the list
expect(component).toMatchSnapshot();

ReactTestRenderer.act(() => {
const cell17 = component.root.findByProps({value: 17});
cell17.parent.props.onFocusCapture(null);
});

// Cells 2-4 should no longer be rendered after focus is moved to the end of
// the list
expect(component).toMatchSnapshot();

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

// Cells 16-18 should remain rendered after scrolling back to the top of the
// list
expect(component).toMatchSnapshot();
});

function generateItems(count) {
return Array(count)
.fill()
Expand Down
13 changes: 13 additions & 0 deletions Libraries/Lists/__tests__/__snapshots__/FlatList-test.js.snap
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ exports[`FlatList renders all the bells and whistles 1`] = `
<header />
</View>
<View
onFocusCapture={[Function]}
style={null}
>
<View
Expand All @@ -77,6 +78,7 @@ exports[`FlatList renders all the bells and whistles 1`] = `
<separator />
</View>
<View
onFocusCapture={[Function]}
style={null}
>
<View
Expand All @@ -96,6 +98,7 @@ exports[`FlatList renders all the bells and whistles 1`] = `
<separator />
</View>
<View
onFocusCapture={[Function]}
style={null}
>
<View
Expand Down Expand Up @@ -197,6 +200,7 @@ exports[`FlatList renders simple list (multiple columns) 1`] = `
>
<View>
<View
onFocusCapture={[Function]}
onLayout={[Function]}
style={null}
>
Expand All @@ -216,6 +220,7 @@ exports[`FlatList renders simple list (multiple columns) 1`] = `
</View>
</View>
<View
onFocusCapture={[Function]}
onLayout={[Function]}
style={null}
>
Expand Down Expand Up @@ -268,6 +273,7 @@ exports[`FlatList renders simple list 1`] = `
>
<View>
<View
onFocusCapture={[Function]}
onLayout={[Function]}
style={null}
>
Expand All @@ -276,6 +282,7 @@ exports[`FlatList renders simple list 1`] = `
/>
</View>
<View
onFocusCapture={[Function]}
onLayout={[Function]}
style={null}
>
Expand All @@ -284,6 +291,7 @@ exports[`FlatList renders simple list 1`] = `
/>
</View>
<View
onFocusCapture={[Function]}
onLayout={[Function]}
style={null}
>
Expand Down Expand Up @@ -328,6 +336,7 @@ exports[`FlatList renders simple list using ListItemComponent (multiple columns)
>
<View>
<View
onFocusCapture={[Function]}
onLayout={[Function]}
style={null}
>
Expand All @@ -347,6 +356,7 @@ exports[`FlatList renders simple list using ListItemComponent (multiple columns)
</View>
</View>
<View
onFocusCapture={[Function]}
onLayout={[Function]}
style={null}
>
Expand Down Expand Up @@ -399,6 +409,7 @@ exports[`FlatList renders simple list using ListItemComponent 1`] = `
>
<View>
<View
onFocusCapture={[Function]}
onLayout={[Function]}
style={null}
>
Expand All @@ -407,6 +418,7 @@ exports[`FlatList renders simple list using ListItemComponent 1`] = `
/>
</View>
<View
onFocusCapture={[Function]}
onLayout={[Function]}
style={null}
>
Expand All @@ -415,6 +427,7 @@ exports[`FlatList renders simple list using ListItemComponent 1`] = `
/>
</View>
<View
onFocusCapture={[Function]}
onLayout={[Function]}
style={null}
>
Expand Down
Loading

0 comments on commit 64f3868

Please sign in to comment.