Skip to content

Commit

Permalink
Add "CellRenderMask" Region Tracking Structure (#31420)
Browse files Browse the repository at this point in the history
Summary:
A CellRenderMask helps track regions of cells/spacers to render. It's API allows adding ranges of cells, where its otput be an ordered list of contiguous spacer/non-spacer ranges.

The implementation keeps this region list internally, splitting or merging regions when cells are added. This output will be used by the render function of a refactored VirtualizedList.

## Changelog

<!-- Help reviewers and the release process by writing your own changelog entry. For an example, see:
https://github.com/facebook/react-native/wiki/Changelog
-->

[Internal] [Added] - Add "CellRenderMask" Region Tracking Structure

Pull Request resolved: #31420

Test Plan: Validated via UTs.

Reviewed By: lunaleaps

Differential Revision: D28293161

Pulled By: rozele

fbshipit-source-id: a5e4e2144a90387aabe0974650429018440abf67
  • Loading branch information
NickGerleman authored and facebook-github-bot committed Jun 24, 2021
1 parent d472efb commit 5cb2deb
Show file tree
Hide file tree
Showing 2 changed files with 324 additions and 0 deletions.
145 changes: 145 additions & 0 deletions Libraries/Lists/CellRenderMask.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow strict
* @format
*/

import invariant from 'invariant';

export type CellRegion = {
first: number,
last: number,
isSpacer: boolean,
};

export class CellRenderMask {
_numCells: number;
_regions: Array<CellRegion>;

constructor(numCells: number) {
invariant(
numCells >= 0,
'CellRenderMask must contain a non-negative number os cells',
);

this._numCells = numCells;

if (numCells === 0) {
this._regions = [];
} else {
this._regions = [
{
first: 0,
last: numCells - 1,
isSpacer: true,
},
];
}
}

enumerateRegions(): $ReadOnlyArray<CellRegion> {
return this._regions;
}

addCells(cells: {first: number, last: number}) {
invariant(
cells.first >= 0 &&
cells.first < this._numCells &&
cells.last >= 0 &&
cells.last < this._numCells &&
cells.last >= cells.first,
'CellRenderMask.addCells called with invalid cell range',
);

const [firstIntersect, firstIntersectIdx] = this._findRegion(cells.first);
const [lastIntersect, lastIntersectIdx] = this._findRegion(cells.last);

// Fast-path if the cells to add are already all present in the mask. We
// will otherwise need to do some mutation.
if (firstIntersectIdx === lastIntersectIdx && !firstIntersect.isSpacer) {
return;
}

// We need to replace the existing covered regions with 1-3 new regions
// depending whether we need to split spacers out of overlapping regions.
const newLeadRegion: Array<CellRegion> = [];
const newTailRegion: Array<CellRegion> = [];
const newMainRegion: CellRegion = {
...cells,
isSpacer: false,
};

if (firstIntersect.first < newMainRegion.first) {
if (firstIntersect.isSpacer) {
newLeadRegion.push({
first: firstIntersect.first,
last: newMainRegion.first - 1,
isSpacer: true,
});
} else {
newMainRegion.first = firstIntersect.first;
}
}

if (lastIntersect.last > newMainRegion.last) {
if (lastIntersect.isSpacer) {
newTailRegion.push({
first: newMainRegion.last + 1,
last: lastIntersect.last,
isSpacer: true,
});
} else {
newMainRegion.last = lastIntersect.last;
}
}

const replacementRegions: Array<CellRegion> = [
...newLeadRegion,
newMainRegion,
...newTailRegion,
];
const numRegionsToDelete = lastIntersectIdx - firstIntersectIdx + 1;
this._regions.splice(
firstIntersectIdx,
numRegionsToDelete,
...replacementRegions,
);
}

equals(other: CellRenderMask): boolean {
return (
this._numCells === other._numCells &&
this._regions.length === other._regions.length &&
this._regions.every(
(region, i) =>
region.first === other._regions[i].first &&
region.last === other._regions[i].last &&
region.isSpacer === other._regions[i].isSpacer,
)
);
}

_findRegion(cellIdx: number): [CellRegion, number] {
let firstIdx = 0;
let lastIdx = this._regions.length - 1;

while (firstIdx <= lastIdx) {
const middleIdx = Math.floor((firstIdx + lastIdx) / 2);
const middleRegion = this._regions[middleIdx];

if (cellIdx >= middleRegion.first && cellIdx <= middleRegion.last) {
return [middleRegion, middleIdx];
} else if (cellIdx < middleRegion.first) {
lastIdx = middleIdx - 1;
} else if (cellIdx > middleRegion.last) {
firstIdx = middleIdx + 1;
}
}

invariant(false, `A region was not found containing cellIdx ${cellIdx}`);
}
}
179 changes: 179 additions & 0 deletions Libraries/Lists/__tests__/CellRenderMask-test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow strict
* @format
*/

import {CellRenderMask} from '../CellRenderMask';

describe('CellRenderMask', () => {
it('throws when constructed with invalid size', () => {
expect(() => new CellRenderMask(-1)).toThrow();
});

it('allows creation of empty mask', () => {
const renderMask = new CellRenderMask(0);
expect(renderMask.enumerateRegions()).toEqual([]);
});

it('allows creation of single-cell mask', () => {
const renderMask = new CellRenderMask(1);
expect(renderMask.enumerateRegions()).toEqual([
{first: 0, last: 0, isSpacer: true},
]);
});

it('throws when adding invalid cell ranges', () => {
const renderMask = new CellRenderMask(5);

expect(() => renderMask.addCells({first: -1, last: 0})).toThrow();
expect(() => renderMask.addCells({first: 1, last: 0})).toThrow();
expect(() => renderMask.addCells({first: 0, last: 5})).toThrow();
expect(() => renderMask.addCells({first: 6, last: 7})).toThrow();
});

it('allows adding single cell at beginning', () => {
const renderMask = new CellRenderMask(5);
renderMask.addCells({first: 0, last: 0});

expect(renderMask.enumerateRegions()).toEqual([
{first: 0, last: 0, isSpacer: false},
{first: 1, last: 4, isSpacer: true},
]);
});

it('allows adding single cell at end', () => {
const renderMask = new CellRenderMask(5);
renderMask.addCells({first: 4, last: 4});

expect(renderMask.enumerateRegions()).toEqual([
{first: 0, last: 3, isSpacer: true},
{first: 4, last: 4, isSpacer: false},
]);
});

it('allows adding single cell in middle', () => {
const renderMask = new CellRenderMask(5);
renderMask.addCells({first: 2, last: 2});

expect(renderMask.enumerateRegions()).toEqual([
{first: 0, last: 1, isSpacer: true},
{first: 2, last: 2, isSpacer: false},
{first: 3, last: 4, isSpacer: true},
]);
});

it('allows marking entire cell range', () => {
const renderMask = new CellRenderMask(5);
renderMask.addCells({first: 0, last: 4});

expect(renderMask.enumerateRegions()).toEqual([
{first: 0, last: 4, isSpacer: false},
]);
});

it('correctly replaces fragmented cell ranges', () => {
const renderMask = new CellRenderMask(10);

renderMask.addCells({first: 3, last: 3});
renderMask.addCells({first: 5, last: 7});

expect(renderMask.enumerateRegions()).toEqual([
{first: 0, last: 2, isSpacer: true},
{first: 3, last: 3, isSpacer: false},
{first: 4, last: 4, isSpacer: true},
{first: 5, last: 7, isSpacer: false},
{first: 8, last: 9, isSpacer: true},
]);

renderMask.addCells({first: 3, last: 7});

expect(renderMask.enumerateRegions()).toEqual([
{first: 0, last: 2, isSpacer: true},
{first: 3, last: 7, isSpacer: false},
{first: 8, last: 9, isSpacer: true},
]);
});

it('left-expands region', () => {
const renderMask = new CellRenderMask(5);

renderMask.addCells({first: 3, last: 3});

expect(renderMask.enumerateRegions()).toEqual([
{first: 0, last: 2, isSpacer: true},
{first: 3, last: 3, isSpacer: false},
{first: 4, last: 4, isSpacer: true},
]);

renderMask.addCells({first: 2, last: 3});

expect(renderMask.enumerateRegions()).toEqual([
{first: 0, last: 1, isSpacer: true},
{first: 2, last: 3, isSpacer: false},
{first: 4, last: 4, isSpacer: true},
]);
});

it('right-expands region', () => {
const renderMask = new CellRenderMask(5);

renderMask.addCells({first: 3, last: 3});

expect(renderMask.enumerateRegions()).toEqual([
{first: 0, last: 2, isSpacer: true},
{first: 3, last: 3, isSpacer: false},
{first: 4, last: 4, isSpacer: true},
]);

renderMask.addCells({first: 3, last: 4});

expect(renderMask.enumerateRegions()).toEqual([
{first: 0, last: 2, isSpacer: true},
{first: 3, last: 4, isSpacer: false},
]);
});

it('left+right expands region', () => {
const renderMask = new CellRenderMask(5);

renderMask.addCells({first: 3, last: 3});

expect(renderMask.enumerateRegions()).toEqual([
{first: 0, last: 2, isSpacer: true},
{first: 3, last: 3, isSpacer: false},
{first: 4, last: 4, isSpacer: true},
]);

renderMask.addCells({first: 2, last: 4});

expect(renderMask.enumerateRegions()).toEqual([
{first: 0, last: 1, isSpacer: true},
{first: 2, last: 4, isSpacer: false},
]);
});

it('does nothing when adding existing cells', () => {
const renderMask = new CellRenderMask(5);

renderMask.addCells({first: 2, last: 3});

expect(renderMask.enumerateRegions()).toEqual([
{first: 0, last: 1, isSpacer: true},
{first: 2, last: 3, isSpacer: false},
{first: 4, last: 4, isSpacer: true},
]);

renderMask.addCells({first: 3, last: 3});

expect(renderMask.enumerateRegions()).toEqual([
{first: 0, last: 1, isSpacer: true},
{first: 2, last: 3, isSpacer: false},
{first: 4, last: 4, isSpacer: true},
]);
});
});

0 comments on commit 5cb2deb

Please sign in to comment.