Add VirtualizedList_EXPERIMENTAL (CellRenderMask Usage)

Summary:
# This Change

https://github.com/react-native-community/discussions-and-proposals/pull/335  discussed a set of problems with VirtualizedList and focus. These were seen as severe externally for a11y on desktop. The issues center on users of keyboard and accessibility tools, where users expect to be able to move focus in an uninterrupted loop.

The design and implementation evolved to be a bit more general, and without any API-surface behavior changes. It was implemented and rolled out externally as a series of changes. The remaining changes that were not upstreamed into RN are rolled into https://github.com/facebook/react-native/pull/32646

This diff brings this change into the repo, as a separate copy of VirtualizedList, to measure its impact to guardrail metrics, without yet making it the default implementation. The intention is for this to be temporary, until there is confidence the implementation is correct.

## List Implementation (more on GitHub)

This change makes it possible to synchronously move native focus to arbitrary items in a VirtualizedList. This is implemented by switching component state to a sparse bitset. This was previously implemented and upstreamed as `CellRenderMask`.

A usage of this is added, to keep the last focused item rendered. This allows the focus loop to remain unbroken, when scrolling away, or tab loops which leave/re-enter the list.

VirtualizedList tracks the last focused cell through the capture phase of `onFocus`. It will keep the cell, and a viewport above and below the last focused cell rendered, to allow movement to it without blanking (without using too much memory).

## Experimentation Implementation

A mechanism is added to gate the change via VirtualizedListInjection, mirroring the approach taken for Switch with D27381306 (https://github.com/facebook/react-native/commit/683b825b327e7fa71e87e3d613cb9acd37c24288).

It allows VirtualizedList to delegate to a global override. It has a slight penalty to needing to import both modules, but means code which imports VirtualizedList directly is affected the changes.

Changelog:
[Internal][Added] - Add VirtualizedList_EXPERIMENTAL (CellRenderMask Usage)

Reviewed By: lunaleaps

Differential Revision: D38020408

fbshipit-source-id: ad0aaa6791f3f4455e3068502a2841f3ffb40b41
This commit is contained in:
Nick Gerleman
2022-07-26 20:06:58 -07:00
committed by Facebook GitHub Bot
parent 04e43544b8
commit 479053cb3c
8 changed files with 8008 additions and 5 deletions
+4
View File
@@ -110,6 +110,10 @@ export class CellRenderMask {
);
}
numCells(): number {
return this._numCells;
}
equals(other: CellRenderMask): boolean {
return (
this._numCells === other._numCells &&
-2
View File
@@ -102,7 +102,6 @@ export function computeWindowedRenderLimits(
prev: {
first: number,
last: number,
...
},
getFrameMetricsApprox: (index: number) => {
length: number,
@@ -120,7 +119,6 @@ export function computeWindowedRenderLimits(
): {
first: number,
last: number,
...
} {
const itemCount = getItemCount(data);
if (itemCount === 0) {
+2 -1
View File
@@ -2217,4 +2217,5 @@ const styles = StyleSheet.create({
},
});
module.exports = VirtualizedList;
module.exports = (require('./VirtualizedListInjection').default
.unstable_VirtualizedList ?? VirtualizedList: typeof VirtualizedList);
@@ -0,0 +1,16 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow
* @format
*/
'use strict';
import typeof VirtualizedList from './VirtualizedList';
export default {
unstable_VirtualizedList: (null: ?VirtualizedList),
};
File diff suppressed because it is too large Load Diff
@@ -10,8 +10,18 @@
'use strict';
const React = require('react');
const ReactTestRenderer = require('react-test-renderer');
import React from 'react';
import ReactTestRenderer from 'react-test-renderer';
import VirtualizedListInjection from '../VirtualizedListInjection';
import VirtualizedList_EXPERIMENTAL from '../VirtualizedList_EXPERIMENTAL';
const useExperimentalList =
process.env.USE_EXPERIMENTAL_VIRTUALIZEDLIST === 'true';
if (useExperimentalList) {
VirtualizedListInjection.unstable_VirtualizedList =
VirtualizedList_EXPERIMENTAL;
}
const VirtualizedList = require('../VirtualizedList');
@@ -1498,6 +1508,188 @@ it('calls _onCellLayout properly', () => {
expect(mock).not.toHaveBeenCalledWith(event, 'i3', 2);
});
if (useExperimentalList) {
describe('VirtualizedList (Experimental functionality)', () => {
it('keeps viewport below last focused 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(() => {
component.getInstance()._onCellFocusCapture(3);
});
ReactTestRenderer.act(() => {
simulateScroll(component, {x: 0, y: 150});
performAllBatches();
});
// Cells 1-8 should remain rendered after scrolling to the bottom of the list
expect(component).toMatchSnapshot();
});
it('virtualizes away last focused item if focus changes to a new cell', () => {
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(() => {
component.getInstance()._onCellFocusCapture(3);
});
ReactTestRenderer.act(() => {
simulateScroll(component, {x: 0, y: 150});
performAllBatches();
});
ReactTestRenderer.act(() => {
component.getInstance()._onCellFocusCapture(17);
});
// Cells 1-8 should no longer be rendered after focus is moved to the end of
// the list
expect(component).toMatchSnapshot();
});
it('keeps viewport above last focused 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(() => {
component.getInstance()._onCellFocusCapture(3);
});
ReactTestRenderer.act(() => {
simulateScroll(component, {x: 0, y: 150});
performAllBatches();
});
ReactTestRenderer.act(() => {
component.getInstance()._onCellFocusCapture(17);
});
ReactTestRenderer.act(() => {
simulateScroll(component, {x: 0, y: 0});
performAllBatches();
});
// Cells 12-19 should remain rendered after scrolling to the top of the list
expect(component).toMatchSnapshot();
});
it('virtualizes away last focused index if item removed', () => {
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(() => {
component.getInstance()._onCellFocusCapture(3);
});
ReactTestRenderer.act(() => {
simulateScroll(component, {x: 0, y: 150});
performAllBatches();
});
const itemsWithoutFocused = [...items.slice(0, 3), ...items.slice(4)];
ReactTestRenderer.act(() => {
component.update(
<VirtualizedList
initialNumToRender={1}
windowSize={1}
{...baseItemProps(itemsWithoutFocused)}
{...fixedHeightItemLayoutProps(ITEM_HEIGHT)}
/>,
);
});
// Cells 1-8 should no longer be rendered
expect(component).toMatchSnapshot();
});
});
}
function generateItems(count) {
return Array(count)
.fill()
@@ -0,0 +1,14 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
* @emails oncall+react_native
*/
'use strict';
process.env.USE_EXPERIMENTAL_VIRTUALIZEDLIST = 'true';
require('./VirtualizedList-test');