Skip to content

Commit e6808d1

Browse files
authored
Incremental layout for TableView and ListView (#3795)
1 parent 3e5f99b commit e6808d1

File tree

6 files changed

+227
-64
lines changed

6 files changed

+227
-64
lines changed

packages/@react-spectrum/table/test/TableSizing.test.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -225,7 +225,8 @@ describe('TableViewSizing', function () {
225225
it('should support variable row heights with overflowMode="wrap"', function () {
226226
let scrollHeight = jest.spyOn(window.HTMLElement.prototype, 'scrollHeight', 'get')
227227
.mockImplementation(function () {
228-
return this.textContent === 'Foo 1' ? 64 : 48;
228+
let row = this.closest('[role=row]');
229+
return row && row.textContent.includes('Foo 1') ? 64 : 48;
229230
});
230231

231232
let tree = renderTable({overflowMode: 'wrap'});

packages/@react-stately/layout/src/ListLayout.ts

Lines changed: 96 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,8 @@ export interface LayoutNode {
3434
node?: Node<unknown>,
3535
layoutInfo: LayoutInfo,
3636
header?: LayoutInfo,
37-
children?: LayoutNode[]
37+
children?: LayoutNode[],
38+
validRect: Rect
3839
}
3940

4041
const DEFAULT_HEIGHT = 48;
@@ -70,6 +71,8 @@ export class ListLayout<T> extends Layout<Node<T>> implements KeyboardDelegate,
7071
protected invalidateEverything: boolean;
7172
protected loaderHeight: number;
7273
protected placeholderHeight: number;
74+
protected lastValidRect: Rect;
75+
protected validRect: Rect;
7376

7477
/**
7578
* Creates a new ListLayout with options. See the list of properties below for a description
@@ -92,13 +95,37 @@ export class ListLayout<T> extends Layout<Node<T>> implements KeyboardDelegate,
9295
this.lastWidth = 0;
9396
this.lastCollection = null;
9497
this.allowDisabledKeyFocus = options.allowDisabledKeyFocus;
98+
this.lastValidRect = new Rect();
99+
this.validRect = new Rect();
100+
this.contentSize = new Size();
95101
}
96102

97103
getLayoutInfo(key: Key) {
98-
return this.layoutInfos.get(key);
104+
let res = this.layoutInfos.get(key);
105+
106+
// If the layout info wasn't found, it might be outside the bounds of the area that we've
107+
// computed layout for so far. This can happen when accessing a random key, e.g pressing Home/End.
108+
// Compute the full layout and try again.
109+
if (!res && this.validRect.area < this.contentSize.area && this.lastCollection) {
110+
this.lastValidRect = this.validRect;
111+
this.validRect = new Rect(0, 0, Infinity, Infinity);
112+
this.rootNodes = this.buildCollection();
113+
this.validRect = new Rect(0, 0, this.contentSize.width, this.contentSize.height);
114+
res = this.layoutInfos.get(key);
115+
}
116+
117+
return res;
99118
}
100119

101120
getVisibleLayoutInfos(rect: Rect) {
121+
// If layout hasn't yet been done for the requested rect, union the
122+
// new rect with the existing valid rect, and recompute.
123+
if (!this.validRect.containsRect(rect) && this.lastCollection) {
124+
this.lastValidRect = this.validRect;
125+
this.validRect = this.validRect.union(rect);
126+
this.rootNodes = this.buildCollection();
127+
}
128+
102129
let res: LayoutInfo[] = [];
103130

104131
let addNodes = (nodes: LayoutNode[]) => {
@@ -124,16 +151,27 @@ export class ListLayout<T> extends Layout<Node<T>> implements KeyboardDelegate,
124151
return node.layoutInfo.rect.intersects(rect) || node.layoutInfo.isSticky || this.virtualizer.isPersistedKey(node.layoutInfo.key);
125152
}
126153

127-
validate(invalidationContext: InvalidationContext<Node<T>, unknown>) {
154+
protected shouldInvalidateEverything(invalidationContext: InvalidationContext<Node<T>, unknown>) {
128155
// Invalidate cache if the size of the collection changed.
129156
// In this case, we need to recalculate the entire layout.
130-
this.invalidateEverything = invalidationContext.sizeChanged;
157+
return invalidationContext.sizeChanged;
158+
}
131159

160+
validate(invalidationContext: InvalidationContext<Node<T>, unknown>) {
132161
this.collection = this.virtualizer.collection;
162+
163+
// Reset valid rect if we will have to invalidate everything.
164+
// Otherwise we can reuse cached layout infos outside the current visible rect.
165+
this.invalidateEverything = this.shouldInvalidateEverything(invalidationContext);
166+
if (this.invalidateEverything) {
167+
this.lastValidRect = this.validRect;
168+
this.validRect = this.virtualizer.getVisibleRect();
169+
}
170+
133171
this.rootNodes = this.buildCollection();
134172

135173
// Remove deleted layout nodes
136-
if (this.lastCollection) {
174+
if (this.lastCollection && this.collection !== this.lastCollection) {
137175
for (let key of this.lastCollection.getKeys()) {
138176
if (!this.collection.getItem(key)) {
139177
let layoutNode = this.layoutNodes.get(key);
@@ -148,15 +186,31 @@ export class ListLayout<T> extends Layout<Node<T>> implements KeyboardDelegate,
148186

149187
this.lastWidth = this.virtualizer.visibleRect.width;
150188
this.lastCollection = this.collection;
189+
this.invalidateEverything = false;
151190
}
152191

153192
buildCollection(): LayoutNode[] {
154193
let y = this.padding;
194+
let skipped = 0;
155195
let nodes = [];
156196
for (let node of this.collection) {
197+
let rowHeight = (this.rowHeight ?? this.estimatedRowHeight);
198+
199+
// Skip rows before the valid rectangle unless they are already cached.
200+
if (node.type === 'item' && y + rowHeight < this.validRect.y && !this.isValid(node, y)) {
201+
y += rowHeight;
202+
skipped++;
203+
continue;
204+
}
205+
157206
let layoutNode = this.buildChild(node, 0, y);
158207
y = layoutNode.layoutInfo.rect.maxY;
159208
nodes.push(layoutNode);
209+
210+
if (node.type === 'item' && y > this.validRect.maxY) {
211+
y += (this.collection.size - (nodes.length + skipped)) * rowHeight;
212+
break;
213+
}
160214
}
161215

162216
if (this.isLoading) {
@@ -181,10 +235,21 @@ export class ListLayout<T> extends Layout<Node<T>> implements KeyboardDelegate,
181235
return nodes;
182236
}
183237

184-
buildChild(node: Node<T>, x: number, y: number): LayoutNode {
238+
isValid(node: Node<T>, y: number) {
185239
let cached = this.layoutNodes.get(node.key);
186-
if (!this.invalidateEverything && cached && cached.node === node && y === (cached.header || cached.layoutInfo).rect.y) {
187-
return cached;
240+
return (
241+
!this.invalidateEverything &&
242+
cached &&
243+
cached.node === node &&
244+
y === (cached.header || cached.layoutInfo).rect.y &&
245+
cached.layoutInfo.rect.intersects(this.lastValidRect) &&
246+
cached.validRect.containsRect(cached.layoutInfo.rect.intersection(this.validRect))
247+
);
248+
}
249+
250+
buildChild(node: Node<T>, x: number, y: number): LayoutNode {
251+
if (this.isValid(node, y)) {
252+
return this.layoutNodes.get(node.key);
188253
}
189254

190255
let layoutNode = this.buildNode(node, x, y);
@@ -245,19 +310,36 @@ export class ListLayout<T> extends Layout<Node<T>> implements KeyboardDelegate,
245310
let layoutInfo = new LayoutInfo(node.type, node.key, rect);
246311

247312
let startY = y;
313+
let skipped = 0;
248314
let children = [];
249315
for (let child of node.childNodes) {
316+
let rowHeight = (this.rowHeight ?? this.estimatedRowHeight);
317+
318+
// Skip rows before the valid rectangle unless they are already cached.
319+
if (y + rowHeight < this.validRect.y && !this.isValid(node, y)) {
320+
y += rowHeight;
321+
skipped++;
322+
continue;
323+
}
324+
250325
let layoutNode = this.buildChild(child, x, y);
251326
y = layoutNode.layoutInfo.rect.maxY;
252327
children.push(layoutNode);
328+
329+
if (y > this.validRect.maxY) {
330+
// Estimate the remaining height for rows that we don't need to layout right now.
331+
y += ([...node.childNodes].length - (children.length + skipped)) * rowHeight;
332+
break;
333+
}
253334
}
254335

255336
rect.height = y - startY;
256337

257338
return {
258339
header,
259340
layoutInfo,
260-
children
341+
children,
342+
validRect: layoutInfo.rect.intersection(this.validRect)
261343
};
262344
}
263345

@@ -273,10 +355,8 @@ export class ListLayout<T> extends Layout<Node<T>> implements KeyboardDelegate,
273355
// or the content of the item changed.
274356
let previousLayoutNode = this.layoutNodes.get(node.key);
275357
if (previousLayoutNode) {
276-
let curNode = this.collection.getItem(node.key);
277-
let lastNode = this.lastCollection ? this.lastCollection.getItem(node.key) : null;
278358
rectHeight = previousLayoutNode.layoutInfo.rect.height;
279-
isEstimated = width !== this.lastWidth || curNode !== lastNode || previousLayoutNode.layoutInfo.estimatedSize;
359+
isEstimated = width !== this.lastWidth || node !== previousLayoutNode.node || previousLayoutNode.layoutInfo.estimatedSize;
280360
} else {
281361
rectHeight = this.estimatedRowHeight;
282362
isEstimated = true;
@@ -297,7 +377,8 @@ export class ListLayout<T> extends Layout<Node<T>> implements KeyboardDelegate,
297377
layoutInfo.allowOverflow = true;
298378
layoutInfo.estimatedSize = isEstimated;
299379
return {
300-
layoutInfo
380+
layoutInfo,
381+
validRect: layoutInfo.rect
301382
};
302383
}
303384

@@ -333,8 +414,8 @@ export class ListLayout<T> extends Layout<Node<T>> implements KeyboardDelegate,
333414
updateLayoutNode(key: Key, oldLayoutInfo: LayoutInfo, newLayoutInfo: LayoutInfo) {
334415
let n = this.layoutNodes.get(key);
335416
if (n) {
336-
// Invalidate by clearing node.
337-
n.node = null;
417+
// Invalidate by reseting validRect.
418+
n.validRect = new Rect();
338419

339420
// Replace layout info in LayoutNode
340421
if (n.header === oldLayoutInfo) {

0 commit comments

Comments
 (0)