-
Notifications
You must be signed in to change notification settings - Fork 24.3k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add "CellRenderMask" Region Tracking Structure (#31420)
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
1 parent
d472efb
commit 5cb2deb
Showing
2 changed files
with
324 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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}`); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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}, | ||
]); | ||
}); | ||
}); |