mirror of
https://github.com/facebook/react-native.git
synced 2025-11-01 09:14:26 +00:00
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:
committed by
Facebook GitHub Bot
parent
04e43544b8
commit
479053cb3c
@@ -110,6 +110,10 @@ export class CellRenderMask {
|
||||
);
|
||||
}
|
||||
|
||||
numCells(): number {
|
||||
return this._numCells;
|
||||
}
|
||||
|
||||
equals(other: CellRenderMask): boolean {
|
||||
return (
|
||||
this._numCells === other._numCells &&
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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');
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user