feat(virtual-scroll): items update fast path

This commit is contained in:
Manu Mtz.-Almeida
2018-02-09 21:53:30 +01:00
parent 77e16bbc3f
commit 3462ed8985
5 changed files with 111 additions and 24 deletions

View File

@ -381,6 +381,12 @@ which is an expensive operation and should be avoided if possible.
## Methods ## Methods
#### markDirty()
#### markDirtyTail()
#### positionForItem() #### positionForItem()

View File

@ -33,17 +33,20 @@
</ion-app> </ion-app>
<script> <script>
function addItems() {
const virtual = document.getElementById('virtual'); const virtual = document.getElementById('virtual');
virtual.addItems() const items = Array.from({length: 100}, (x, i) => i);
function addItems() {
const append = Array.from({length: 10}, (x, i) => "append" + i);
items.push(...append);
virtual.markDirtyTail(append.length)
} }
const virtual = document.getElementById('virtual');
virtual.itemHeight = () => 45; virtual.itemHeight = () => 45;
virtual.headerFn = (item, index) => { virtual.headerFn = (item, index) => {
if (index % 20 === 0) { // if (index % 20 === 0) {
return 'Header ' + index; // return 'Header ' + index;
} // }
return null; return null;
} }
@ -75,7 +78,7 @@
if (cell.type === 0) return renderItem(el, cell.value); if (cell.type === 0) return renderItem(el, cell.value);
return renderHeader(el, cell.value); return renderHeader(el, cell.value);
}; };
virtual.items = Array.from({length: 1000}, (x, i) => i); virtual.items = items;
</script> </script>
</body> </body>
</html> </html>

View File

@ -60,7 +60,7 @@ describe('getRange', () => {
expect(bounds).toEqual({ expect(bounds).toEqual({
offset: 0, offset: 0,
length: 4, length: 5,
}); });
}); });
@ -148,7 +148,7 @@ describe('resizeBuffer', () => {
describe('calcCells', () => { describe('calcCells', () => {
it('should calculate cells without headers and itemHeight', () => { it('should calculate cells without headers and itemHeight', () => {
const items = ['0', 2, 'hola', {data: 'hello'}]; const items = ['0', 2, 'hola', {data: 'hello'}];
const cells = calcCells(items, null, null, null, 10, 20, 30); const cells = calcCells(items, null, null, null, 10, 20, 30, 0, 0, items.length);
expect(cells).toEqual([ expect(cells).toEqual([
{ {
type: CellType.Item, type: CellType.Item,
@ -197,7 +197,7 @@ describe('calcCells', () => {
called++; called++;
return index * 20 + 20; return index * 20 + 20;
}; };
const cells = calcCells(items, itemHeight, null, null, 10, 20, 30); const cells = calcCells(items, itemHeight, null, null, 10, 20, 30, 0, 0, items.length);
expect(called).toEqual(3); expect(called).toEqual(3);
expect(cells).toEqual([ expect(cells).toEqual([
@ -253,7 +253,7 @@ describe('calcCells', () => {
called++; called++;
return index * 20 + 20; return index * 20 + 20;
}; };
const cells = calcCells(items, itemHeight, headerFn, footerFn, 10, 20, 30); const cells = calcCells(items, itemHeight, headerFn, footerFn, 10, 20, 30, 0, 0, items.length);
expect(cells).toHaveLength(5); expect(cells).toHaveLength(5);
expect(called).toEqual(3); expect(called).toEqual(3);
expect(headerCalled).toEqual(3); expect(headerCalled).toEqual(3);
@ -317,7 +317,7 @@ describe('calcHeightIndex', () => {
const footerFn: HeaderFn = (_, index) => { const footerFn: HeaderFn = (_, index) => {
return (index === 2) ? 'my footer' : null; return (index === 2) ? 'my footer' : null;
}; };
const cells = calcCells(items, null, headerFn, footerFn, 10, 20, 50); const cells = calcCells(items, null, headerFn, footerFn, 10, 20, 50, 0, 0, items.length);
const buf = resizeBuffer(null, cells.length); const buf = resizeBuffer(null, cells.length);
const totalHeight = calcHeightIndex(buf, cells, 0); const totalHeight = calcHeightIndex(buf, cells, 0);
expect(buf.length).toEqual(7); expect(buf.length).toEqual(7);
@ -506,7 +506,7 @@ function mockVirtualScroll(
headerFn: HeaderFn = null, headerFn: HeaderFn = null,
footerFn: HeaderFn = null footerFn: HeaderFn = null
) { ) {
const cells = calcCells(items, itemHeight, headerFn, footerFn, 10, 10, 30); const cells = calcCells(items, itemHeight, headerFn, footerFn, 10, 10, 30, 0, 0, items.length);
const heightIndex = resizeBuffer(null, cells.length); const heightIndex = resizeBuffer(null, cells.length);
calcHeightIndex(heightIndex, cells, 0); calcHeightIndex(heightIndex, cells, 0);
return { items, heightIndex, cells }; return { items, heightIndex, cells };

View File

@ -148,7 +148,7 @@ export function doRender(
node.visible = visible; node.visible = visible;
} }
// dynamic height inference // dynamic height
if (cell.reads > 0) { if (cell.reads > 0) {
updateCellHeight(cell, child); updateCellHeight(cell, child);
cell.reads--; cell.reads--;
@ -182,7 +182,8 @@ export function getRange(heightIndex: Uint32Array, viewport: Viewport, buffer: n
break; break;
} }
} }
const end = Math.min(i + buffer, heightIndex.length - 1);
const end = Math.min(i + buffer, heightIndex.length);
const length = end - offset; const length = end - offset;
return { offset, length }; return { offset, length };
} }
@ -197,6 +198,23 @@ export function getShouldUpdate(dirtyIndex: number, currentRange: Range, range:
} }
export function findCellIndex(cells: Cell[], index: number): number {
if (index === 0) {
return 0;
}
return cells.findIndex(c => c.index === index);
}
export function inplaceUpdate(dst: Cell[], src: Cell[], offset: number) {
if (offset === 0 && src.length >= dst.length) {
return src;
}
for (let i = 0; i < src.length; i++) {
dst[i + offset] = src[i];
}
return dst;
}
export function calcCells( export function calcCells(
items: any[], items: any[],
@ -206,12 +224,15 @@ export function calcCells(
approxHeaderHeight: number, approxHeaderHeight: number,
approxFooterHeight: number, approxFooterHeight: number,
approxItemHeight: number approxItemHeight: number,
j: number,
offset: number,
len: number
): Cell[] { ): Cell[] {
const cells = []; const cells = [];
let j = 0; const end = len + offset;
for (let i = offset; i < end; i++) {
for (let i = 0; i < items.length; i++) {
const item = items[i]; const item = items[i];
if (headerFn) { if (headerFn) {
const value = headerFn(item, i, items); const value = headerFn(item, i, items);

View File

@ -2,7 +2,7 @@ import { Component, Element, EventListenerEnable, Listen, Method, Prop, Watch }
import { DomController } from '../../index'; import { DomController } from '../../index';
import { Cell, DomRenderFn, HeaderFn, ItemHeightFn, ItemRenderFn, NodeHeightFn, Range, import { Cell, DomRenderFn, HeaderFn, ItemHeightFn, ItemRenderFn, NodeHeightFn, Range,
Viewport, VirtualNode, calcCells, calcHeightIndex, doRender, getRange, Viewport, VirtualNode, calcCells, calcHeightIndex, doRender, getRange,
getShouldUpdate, getViewport, positionForIndex, resizeBuffer, updateVDom } from './virtual-scroll-utils'; getShouldUpdate, getViewport, positionForIndex, resizeBuffer, updateVDom, findCellIndex, inplaceUpdate } from './virtual-scroll-utils';
@Component({ @Component({
@ -24,6 +24,7 @@ export class VirtualScroll {
private indexDirty = 0; private indexDirty = 0;
private totalHeight = 0; private totalHeight = 0;
private heightChanged = false; private heightChanged = false;
private lastItemLen = 0;
@Element() el: HTMLElement; @Element() el: HTMLElement;
@ -144,6 +145,52 @@ export class VirtualScroll {
return positionForIndex(index, this.cells, this.heightIndex); return positionForIndex(index, this.cells, this.heightIndex);
} }
@Method()
markDirty(offset: number, len = -1) {
// TODO: kind of hacky how we do in-place updated of the cells
// array. this part needs a complete refactor
if (!this.items) {
return;
}
if (len === -1) {
len = this.items.length - offset;
}
const max = this.lastItemLen;
let j = 0;
if (offset > 0 && offset < max) {
j = findCellIndex(this.cells, offset);
} else if (offset === 0) {
j = 0;
} else if (offset === max) {
j = this.cells.length;
} else {
console.warn('bad values for markDirty');
return;
}
const cells = calcCells(
this.items,
this.itemHeight,
this.headerFn,
this.footerFn,
this.approxHeaderHeight,
this.approxFooterHeight,
this.approxItemHeight,
j, offset, len
);
console.debug('[virtual] cells recalculated', cells.length);
this.cells = inplaceUpdate(this.cells, cells, offset);
this.lastItemLen = this.items.length;
this.indexDirty = Math.max(offset - 1, 0);
this.scheduleUpdate();
}
@Method()
markDirtyTail() {
const offset = this.lastItemLen;
this.markDirty(offset, this.items.length - offset);
}
private updateVirtualScroll() { private updateVirtualScroll() {
// do nothing if there is a scheduled update // do nothing if there is a scheduled update
if (!this.isEnabled || !this.scrollEl) { if (!this.isEnabled || !this.scrollEl) {
@ -229,12 +276,16 @@ export class VirtualScroll {
} }
cell.visible = true; cell.visible = true;
if (cell.height !== height) { if (cell.height !== height) {
console.debug(`[${cell.reads}] cell size ${cell.height} -> ${height}`); console.debug(`[virtual] cell height changed ${cell.height}px -> ${height}px`);
cell.height = height; cell.height = height;
this.indexDirty = Math.min(this.indexDirty, index);
this.scheduleUpdate();
}
}
private scheduleUpdate() {
clearTimeout(this.timerUpdate); clearTimeout(this.timerUpdate);
this.timerUpdate = setTimeout(() => this.updateVirtualScroll(), 100); this.timerUpdate = setTimeout(() => this.updateVirtualScroll(), 100);
this.indexDirty = Math.min(this.indexDirty, index);
}
} }
private updateState() { private updateState() {
@ -252,10 +303,12 @@ export class VirtualScroll {
} }
} }
private calcCells() { private calcCells() {
if (!this.items) { if (!this.items) {
return; return;
} }
this.lastItemLen = this.items.length;
this.cells = calcCells( this.cells = calcCells(
this.items, this.items,
this.itemHeight, this.itemHeight,
@ -263,8 +316,10 @@ export class VirtualScroll {
this.footerFn, this.footerFn,
this.approxHeaderHeight, this.approxHeaderHeight,
this.approxFooterHeight, this.approxFooterHeight,
this.approxItemHeight this.approxItemHeight,
0, 0, this.lastItemLen
); );
console.debug('[virtual] cells recalculated', this.cells.length);
this.indexDirty = 0; this.indexDirty = 0;
} }
@ -279,9 +334,11 @@ export class VirtualScroll {
this.heightIndex = resizeBuffer(this.heightIndex, this.cells.length); this.heightIndex = resizeBuffer(this.heightIndex, this.cells.length);
const totalHeight = calcHeightIndex(this.heightIndex, this.cells, index); const totalHeight = calcHeightIndex(this.heightIndex, this.cells, index);
if (totalHeight !== this.totalHeight) { if (totalHeight !== this.totalHeight) {
console.debug(`[virtual] total height changed: ${this.totalHeight}px -> ${totalHeight}px`);
this.totalHeight = totalHeight; this.totalHeight = totalHeight;
this.heightChanged = true; this.heightChanged = true;
} }
console.debug('[virtual] height index recalculated', this.heightIndex.length - index);
this.indexDirty = Infinity; this.indexDirty = Infinity;
} }