From 00ac4904acdf4a9846942c3d8d0a06e3cd993917 Mon Sep 17 00:00:00 2001 From: Joao Moreno Date: Sun, 28 Oct 2018 06:39:18 -0700 Subject: [PATCH] wip: list with dynamic height elements --- src/vs/base/browser/ui/list/list.ts | 1 + src/vs/base/browser/ui/list/listView.ts | 171 +++++++++++++++----- src/vs/base/browser/ui/tree/abstractTree.ts | 4 + test/tree/public/index.html | 9 +- test/tree/server.js | 2 +- 5 files changed, 145 insertions(+), 42 deletions(-) diff --git a/src/vs/base/browser/ui/list/list.ts b/src/vs/base/browser/ui/list/list.ts index b9815b6ef0b..a551bd62a7a 100644 --- a/src/vs/base/browser/ui/list/list.ts +++ b/src/vs/base/browser/ui/list/list.ts @@ -8,6 +8,7 @@ import { GestureEvent } from 'vs/base/browser/touch'; export interface IListVirtualDelegate { getHeight(element: T): number; getTemplateId(element: T): string; + hasDynamicHeight?(element: T): boolean; } export interface IListRenderer { diff --git a/src/vs/base/browser/ui/list/listView.ts b/src/vs/base/browser/ui/list/listView.ts index 61b6d478b26..b6142e2699f 100644 --- a/src/vs/base/browser/ui/list/listView.ts +++ b/src/vs/base/browser/ui/list/listView.ts @@ -33,19 +33,20 @@ function canUseTranslate3d(): boolean { return true; } - interface IItem { - id: string; - element: T; + readonly id: string; + readonly element: T; + readonly templateId: string; size: number; - templateId: string; + hasDynamicHeight: boolean; + dynamicSizeSnapshotId: number; row: IRow; } export interface IListViewOptions { - useShadows?: boolean; - verticalScrollMode?: ScrollbarVisibility; - setRowLineHeight?: boolean; + readonly useShadows?: boolean; + readonly verticalScrollMode?: ScrollbarVisibility; + readonly setRowLineHeight?: boolean; } const DefaultOptions: IListViewOptions = { @@ -56,6 +57,8 @@ const DefaultOptions: IListViewOptions = { export class ListView implements ISpliceable, IDisposable { + readonly domNode: HTMLElement; + private items: IItem[]; private itemId: number; private rangeMap: RangeMap; @@ -63,7 +66,7 @@ export class ListView implements ISpliceable, IDisposable { private renderers = new Map>(); private lastRenderTop: number; private lastRenderHeight: number; - private _domNode: HTMLElement; + private dynamicSizeSnapshotId = 0; private gesture: Gesture; private rowsContainer: HTMLElement; private scrollableElement: ScrollableElement; @@ -95,8 +98,8 @@ export class ListView implements ISpliceable, IDisposable { this.lastRenderTop = 0; this.lastRenderHeight = 0; - this._domNode = document.createElement('div'); - this._domNode.className = 'monaco-list'; + this.domNode = document.createElement('div'); + this.domNode.className = 'monaco-list'; this.rowsContainer = document.createElement('div'); this.rowsContainer.className = 'monaco-list-rows'; @@ -109,8 +112,8 @@ export class ListView implements ISpliceable, IDisposable { useShadows: getOrDefault(options, o => o.useShadows, DefaultOptions.useShadows) }); - this._domNode.appendChild(this.scrollableElement.getDomNode()); - container.appendChild(this._domNode); + this.domNode.appendChild(this.scrollableElement.getDomNode()); + container.appendChild(this.domNode); this.disposables = [this.rangeMap, this.gesture, this.scrollableElement, this.cache]; @@ -130,10 +133,6 @@ export class ListView implements ISpliceable, IDisposable { this.layout(); } - get domNode(): HTMLElement { - return this._domNode; - } - splice(start: number, deleteCount: number, elements: T[] = []): T[] { if (this.splicing) { throw new Error('Can\'t run recursive splices.'); @@ -164,8 +163,10 @@ export class ListView implements ISpliceable, IDisposable { const inserted = elements.map>(element => ({ id: String(this.itemId++), element, - size: this.virtualDelegate.getHeight(element), templateId: this.virtualDelegate.getTemplateId(element), + size: this.virtualDelegate.getHeight(element), + hasDynamicHeight: !!this.virtualDelegate.hasDynamicHeight && this.virtualDelegate.hasDynamicHeight(element), + dynamicSizeSnapshotId: this.dynamicSizeSnapshotId - 1, row: null })); @@ -183,10 +184,8 @@ export class ListView implements ISpliceable, IDisposable { const removeRanges = Range.relativeComplement(renderedRestRange, renderRange); - for (let r = 0; r < removeRanges.length; r++) { - const removeRange = removeRanges[r]; - - for (let i = removeRange.start; i < removeRange.end; i++) { + for (const range of removeRanges) { + for (let i = range.start; i < range.end; i++) { this.removeItemFromDOM(i); } } @@ -196,14 +195,20 @@ export class ListView implements ISpliceable, IDisposable { const insertRanges = [elementsRange, ...unrenderedRestRanges].map(r => Range.intersect(renderRange, r)); const beforeElement = this.getNextToLastElement(insertRanges); - for (let r = 0; r < insertRanges.length; r++) { - const insertRange = insertRanges[r]; - - for (let i = insertRange.start; i < insertRange.end; i++) { + for (const range of insertRanges) { + for (let i = range.start; i < range.end; i++) { this.insertItemInDOM(i, beforeElement); } } + this.updateScrollHeight(); + + this.rerender(this.scrollTop, this.renderHeight); + + return deleted.map(i => i.element); + } + + private updateScrollHeight(): void { this.scrollHeight = this.getContentHeight(); this.rowsContainer.style.height = `${this.scrollHeight}px`; @@ -215,8 +220,6 @@ export class ListView implements ISpliceable, IDisposable { this.didRequestScrollableElementUpdate = true; } - - return deleted.map(i => i.element); } get length(): number { @@ -255,7 +258,7 @@ export class ListView implements ISpliceable, IDisposable { layout(height?: number): void { this.scrollableElement.setScrollDimensions({ - height: height || DOM.getContentHeight(this._domNode) + height: height || DOM.getContentHeight(this.domNode) }); } @@ -295,13 +298,15 @@ export class ListView implements ISpliceable, IDisposable { // DOM operations - private insertItemInDOM(index: number, beforeElement: HTMLElement | null): void { + private insertItemInDOM(index: number, beforeElement: HTMLElement | null, dynamicHeightProbing = false): void { const item = this.items[index]; if (!item.row) { item.row = this.cache.alloc(item.templateId); } + // item.row.domNode.style.visibility = dynamicHeightProbing ? 'hidden' : ''; + if (!item.row.domNode.parentElement) { if (beforeElement) { this.rowsContainer.insertBefore(item.row.domNode, beforeElement); @@ -310,14 +315,18 @@ export class ListView implements ISpliceable, IDisposable { } } - item.row.domNode.style.height = `${item.size}px`; + if (dynamicHeightProbing) { + item.row.domNode.style.height = ''; + } else { + item.row.domNode.style.height = `${item.size}px`; - if (this.setRowLineHeight) { - item.row.domNode.style.lineHeight = `${item.size}px`; + if (this.setRowLineHeight) { + item.row.domNode.style.lineHeight = `${item.size}px`; + } + + this.updateItemInDOM(item, index); } - this.updateItemInDOM(item, index); - const renderer = this.renderers.get(item.templateId); renderer.renderElement(item.element, index, item.row.templateData); } @@ -401,6 +410,7 @@ export class ListView implements ISpliceable, IDisposable { private onScroll(e: ScrollEvent): void { try { this.render(e.scrollTop, e.height); + this.rerender(e.scrollTop, e.height); } catch (err) { console.log('Got bad scroll event:', e); throw err; @@ -420,7 +430,7 @@ export class ListView implements ISpliceable, IDisposable { } private setupDragAndDropScrollInterval(): void { - var viewTop = DOM.getTopLeftOffset(this._domNode).top; + const viewTop = DOM.getTopLeftOffset(this.domNode).top; if (!this.dragAndDropScrollInterval) { this.dragAndDropScrollInterval = window.setInterval(() => { @@ -494,6 +504,92 @@ export class ListView implements ISpliceable, IDisposable { }; } + /** + * Given a stable rendered state, checks every rendered element whether it needs + * to be probed for dynamic height. Adjusts scroll height and top if necessary. + */ + private rerender(renderTop: number, renderHeight: number): void { + const previousRenderRange = this.getRenderRange(renderTop, renderHeight); + let secondElementIndex: number | undefined; + let secondElementTopDelta: number | undefined; + + if (previousRenderRange.end - previousRenderRange.start > 1) { + secondElementIndex = previousRenderRange.start + 1; + secondElementTopDelta = this.elementTop(secondElementIndex) - renderTop; + } + + let heightDiff = 0; + + while (true) { + const renderRange = this.getRenderRange(renderTop, renderHeight); + + let didChange = false; + + for (let i = renderRange.start; i < renderRange.end; i++) { + const diff = this.probeDynamicHeight(i); + heightDiff += diff; + didChange = didChange || diff !== 0; + } + + if (!didChange) { + if (heightDiff !== 0) { + this.updateScrollHeight(); + } + + const unrenderRanges = Range.relativeComplement(previousRenderRange, renderRange); + + for (const range of unrenderRanges) { + for (let i = range.start; i < range.end; i++) { + if (this.items[i].row) { + this.removeItemFromDOM(i); + } + } + } + + for (let i = renderRange.start; i < renderRange.end; i++) { + if (this.items[i].row) { + this.updateItemInDOM(this.items[i], i); + } + } + + if (typeof secondElementIndex === 'number') { + this.scrollTop = this.elementTop(secondElementIndex) - secondElementTopDelta; + } + + return; + } + } + + } + + private probeDynamicHeight(index: number): number { + const item = this.items[index]; + + if (!item.hasDynamicHeight || item.dynamicSizeSnapshotId === this.dynamicSizeSnapshotId) { + return 0; + } + + const size = item.size; + const rendered = item.row; + + if (rendered) { + item.row.domNode.style.height = ''; + } else { + this.insertItemInDOM(index, null, true); + } + + item.size = item.row.domNode.offsetHeight; + item.dynamicSizeSnapshotId = this.dynamicSizeSnapshotId; + + if (!rendered) { + this.removeItemFromDOM(index); + } + + this.rangeMap.splice(index, 1, item); + + return item.size - size; + } + private getNextToLastElement(ranges: IRange[]): HTMLElement | null { const lastRange = ranges[ranges.length - 1]; @@ -529,9 +625,8 @@ export class ListView implements ISpliceable, IDisposable { this.items = null; } - if (this._domNode && this._domNode.parentElement) { - this._domNode.parentNode.removeChild(this._domNode); - this._domNode = null; + if (this.domNode && this.domNode.parentElement) { + this.domNode.parentNode.removeChild(this.domNode); } this.disposables = dispose(this.disposables); diff --git a/src/vs/base/browser/ui/tree/abstractTree.ts b/src/vs/base/browser/ui/tree/abstractTree.ts index 807cead7989..d667e406e5b 100644 --- a/src/vs/base/browser/ui/tree/abstractTree.ts +++ b/src/vs/base/browser/ui/tree/abstractTree.ts @@ -68,6 +68,10 @@ export class ComposedTreeDelegate implements IListV getTemplateId(element: N): string { return this.delegate.getTemplateId(element.element); } + + hasDynamicHeight(element: N): boolean { + return !!this.delegate.hasDynamicHeight && this.delegate.hasDynamicHeight(element.element); + } } interface ITreeListTemplateData { diff --git a/test/tree/public/index.html b/test/tree/public/index.html index 5113851803f..c9d5bdf373b 100644 --- a/test/tree/public/index.html +++ b/test/tree/public/index.html @@ -45,13 +45,16 @@ function createIndexTree() { const delegate = { getHeight() { return 22; }, - getTemplateId() { return 'template'; } + getTemplateId() { return 'template'; }, + hasDynamicHeight() { return true; } }; const renderer = { templateId: 'template', renderTemplate(container) { return container; }, - renderElement(element, index, container) { container.textContent = element.element; }, + renderElement(element, index, container) { + container.innerHTML = `${element.element}
${element.element}
${element.element}`; + }, disposeElement() { }, disposeTemplate() { } }; @@ -79,7 +82,7 @@ } }; - const tree = new IndexTree(container, delegate, [renderer], { filter: treeFilter }); + const tree = new IndexTree(container, delegate, [renderer], { filter: treeFilter, setRowLineHeight: false }); return { tree, treeFilter }; } diff --git a/test/tree/server.js b/test/tree/server.js index 7e9084bf18a..5431bc1c1cb 100644 --- a/test/tree/server.js +++ b/test/tree/server.js @@ -17,7 +17,7 @@ async function getTree(fsPath, level) { const element = path.basename(fsPath); const stat = await fs.stat(fsPath); - if (!stat.isDirectory() || element === '.git' || element === '.build' || level >= 5) { + if (!stat.isDirectory() || element === '.git' || element === '.build' || level >= 2) { return { element }; }