From b70d13dd6f354dfb809fbe94e6d15a9009a83d5b Mon Sep 17 00:00:00 2001 From: Adam Bradley Date: Mon, 5 Dec 2016 16:58:48 -0600 Subject: [PATCH] perf(virtual-scroll): improve virtual-scroll performance --- src/components/content/content.ts | 4 - src/components/img/test/basic/app-module.ts | 35 ++ .../virtual-scroll/test/list/app-module.ts | 83 +++- .../virtual-scroll/test/list/main.html | 15 +- .../test/virtual-scroll.spec.ts | 2 +- src/components/virtual-scroll/virtual-item.ts | 7 +- .../virtual-scroll/virtual-scroll.scss | 6 +- .../virtual-scroll/virtual-scroll.ts | 417 ++++++++++-------- src/components/virtual-scroll/virtual-util.ts | 210 +++++---- 9 files changed, 470 insertions(+), 309 deletions(-) create mode 100644 src/components/img/test/basic/app-module.ts diff --git a/src/components/content/content.ts b/src/components/content/content.ts index 2c7dd243adf..53e12a4f8c6 100644 --- a/src/components/content/content.ts +++ b/src/components/content/content.ts @@ -500,10 +500,6 @@ export class Content extends Ion implements AfterViewInit, OnDestroy { removeArrayItem(this._imgs, img); } - /** - * @private - */ - /** * @private * DOM WRITE diff --git a/src/components/img/test/basic/app-module.ts b/src/components/img/test/basic/app-module.ts new file mode 100644 index 00000000000..559813021d6 --- /dev/null +++ b/src/components/img/test/basic/app-module.ts @@ -0,0 +1,35 @@ +import { Component, NgModule } from '@angular/core'; +import { IonicApp, IonicModule } from '../../../..'; + + +@Component({ + templateUrl: 'main.html' +}) +export class E2EPage { + +} + + +@Component({ + template: '' +}) +export class E2EApp { + root = E2EPage; +} + + +@NgModule({ + declarations: [ + E2EApp, + E2EPage + ], + imports: [ + IonicModule.forRoot(E2EApp) + ], + bootstrap: [IonicApp], + entryComponents: [ + E2EApp, + E2EPage + ] +}) +export class AppModule {} diff --git a/src/components/virtual-scroll/test/list/app-module.ts b/src/components/virtual-scroll/test/list/app-module.ts index 20669f4df81..5c65c2580bd 100644 --- a/src/components/virtual-scroll/test/list/app-module.ts +++ b/src/components/virtual-scroll/test/list/app-module.ts @@ -6,28 +6,47 @@ import { IonicApp, IonicModule } from '../../../..'; templateUrl: 'main.html' }) export class E2EPage { - items: Array<{title: string; id: number}>; + items: Array<{id: number, url: string, gif: string}> = []; + imgDomain = 'http://localhost:8900'; + responseDelay = 1500; + itemCount = 15; + showGifs = false; constructor() { - this.fillList(); + // take a look at the gulp task: test.imageserver + var xhr = new XMLHttpRequest(); + xhr.open('GET', `${this.imgDomain}/reset`, true); + xhr.onreadystatechange = () => { + if (xhr.readyState === 4) { + this.fillList(); + } + }; + xhr.send(); } fillList() { - this.items = []; - for (let i = 0; i < 500; i++) { + this.items.length = 0; + let gifIndex = Math.ceil(Math.random() * gifs.length) - 1; + + for (let i = 0; i < this.itemCount; i++) { this.items.push({ - title: 'Item ' + i, - id: i + id: i, + url: `${this.imgDomain}/?d=${this.responseDelay}&id=${i}`, + gif: gifs[gifIndex] }); + gifIndex++; + if (gifIndex >= gifs.length) { + gifIndex = 0; + } } } emptyList() { - this.items = []; + this.items.length = 0; } - itemTapped(ev: any, item: {title: string, date: string}) { - console.log(`itemTapped: ${item.title}`); + toggleGifs() { + this.showGifs = !this.showGifs; } reload() { @@ -36,6 +55,52 @@ export class E2EPage { } +const gifs = [ + 'https://media.giphy.com/media/cFdHXXm5GhJsc/giphy.gif', + 'https://media.giphy.com/media/5JjLO6t0lNvLq/giphy.gif', + 'https://media.giphy.com/media/ZmdIZ8K4fKEEM/giphy.gif', + 'https://media.giphy.com/media/lKXEBR8m1jWso/giphy.gif', + 'https://media.giphy.com/media/PjplWH49v1FS0/giphy.gif', + 'https://media.giphy.com/media/SyVyFtBTTVb5m/giphy.gif', + 'https://media.giphy.com/media/LWqQ5glpSMjny/giphy.gif', + 'https://media.giphy.com/media/l396Dat26yQOdfWgw/giphy.gif', + 'https://media.giphy.com/media/zetsDd1oSNd96/giphy.gif', + 'https://media.giphy.com/media/F6PFPjc3K0CPe/giphy.gif', + 'https://media.giphy.com/media/L0GJP0ZxdnVbW/giphy.gif', + 'https://media.giphy.com/media/26ufbLWPFHkhwXcpW/giphy.gif', + 'https://media.giphy.com/media/r3jTnU6iEwpbO/giphy.gif', + 'https://media.giphy.com/media/6Xbr4pVmJW4wM/giphy.gif', + 'https://media.giphy.com/media/FPmzkXGFVhp2U/giphy.gif', + 'https://media.giphy.com/media/p3yU7Rno2PvvW/giphy.gif', + 'https://media.giphy.com/media/vbBmb51klyyB2/giphy.gif', + 'https://media.giphy.com/media/ZAfpXz6fGrlYY/giphy.gif', + 'https://media.giphy.com/media/3oGRFvVyUdGBZeQiAw/giphy.gif', + 'https://media.giphy.com/media/NJbeypFZCHj2g/giphy.gif', + 'https://media.giphy.com/media/WpNO2ZXjhJ85y/giphy.gif', + 'https://media.giphy.com/media/xaw15bdmMEkgg/giphy.gif', + 'https://media.giphy.com/media/tLwQSHQo6hjTa/giphy.gif', + 'https://media.giphy.com/media/3dcoLqDDjd9pC/giphy.gif', + 'https://media.giphy.com/media/QFfs8ubyDkluo/giphy.gif', + 'https://media.giphy.com/media/10hYVVSPrSpZS0/giphy.gif', + 'https://media.giphy.com/media/EYJz9cfMa7WAU/giphy.gif', + 'https://media.giphy.com/media/Q21vzIHyTtmaQ/giphy.gif', + 'https://media.giphy.com/media/pzmUOeqhzJTck/giphy.gif', + 'https://media.giphy.com/media/G6kt1Gb4Luxy0/giphy.gif', + 'https://media.giphy.com/media/13wjHxAz6B6E9i/giphy.gif', + 'https://media.giphy.com/media/ANbbM3IzH9Tna/giphy.gif', + 'https://media.giphy.com/media/EQ5I7NF4BDYA/giphy.gif', + 'https://media.giphy.com/media/L7gHewOS8GOWY/giphy.gif', + 'https://media.giphy.com/media/nO16UrmQh7khW/giphy.gif', + 'https://media.giphy.com/media/eGuk6gQM3Q29W/giphy.gif', + 'https://media.giphy.com/media/8dpPMMlxmDEJO/giphy.gif', + 'https://media.giphy.com/media/5ox090BjCB8ME/giphy.gif', + 'https://media.giphy.com/media/Hzm8c1eMSq3CM/giphy.gif', + 'https://media.giphy.com/media/2APlzZshLu3LO/giphy.gif', + 'https://media.giphy.com/media/dgygjvNe7jckw/giphy.gif', + 'https://media.giphy.com/media/5g0mypSSPupO0/giphy.gif', + 'https://media.giphy.com/media/10JmxORlA6dEFW/giphy.gif', +]; + @Component({ template: '' diff --git a/src/components/virtual-scroll/test/list/main.html b/src/components/virtual-scroll/test/list/main.html index 51c7402402c..50b52ec6b1d 100644 --- a/src/components/virtual-scroll/test/list/main.html +++ b/src/components/virtual-scroll/test/list/main.html @@ -15,16 +15,25 @@
+ +
+ +
+ gulp test.imageserve
- + - + -

{{item.title}}

+

{{item.id}}, top: {{itemBounds.top}}, bottom: {{itemBounds.bottom}}, height: {{itemBounds.height}}

+ +
diff --git a/src/components/virtual-scroll/test/virtual-scroll.spec.ts b/src/components/virtual-scroll/test/virtual-scroll.spec.ts index 3ef0c6e0f4d..197402615f1 100644 --- a/src/components/virtual-scroll/test/virtual-scroll.spec.ts +++ b/src/components/virtual-scroll/test/virtual-scroll.spec.ts @@ -289,7 +289,7 @@ describe('VirtualScroll', () => { cells, records, nodes, viewContainer, itmTmp, hdrTmp, ftrTmp, true); - expect(nodes.length).toBe(6); + expect(nodes.length).toBe(3); expect(nodes[0].cell).toBe(2); expect(nodes[1].cell).toBe(3); diff --git a/src/components/virtual-scroll/virtual-item.ts b/src/components/virtual-scroll/virtual-item.ts index 488b40a96cd..dc3fdd9a82f 100644 --- a/src/components/virtual-scroll/virtual-item.ts +++ b/src/components/virtual-scroll/virtual-item.ts @@ -1,4 +1,5 @@ import { Directive, TemplateRef, ViewContainerRef } from '@angular/core'; +import { VirtualContext } from './virtual-util'; /** @@ -6,7 +7,7 @@ import { Directive, TemplateRef, ViewContainerRef } from '@angular/core'; */ @Directive({selector: '[virtualHeader]'}) export class VirtualHeader { - constructor(public templateRef: TemplateRef) {} + constructor(public templateRef: TemplateRef) {} } @@ -15,7 +16,7 @@ export class VirtualHeader { */ @Directive({selector: '[virtualFooter]'}) export class VirtualFooter { - constructor(public templateRef: TemplateRef) {} + constructor(public templateRef: TemplateRef) {} } @@ -24,5 +25,5 @@ export class VirtualFooter { */ @Directive({selector: '[virtualItem]'}) export class VirtualItem { - constructor(public templateRef: TemplateRef, public viewContainer: ViewContainerRef) {} + constructor(public templateRef: TemplateRef, public viewContainer: ViewContainerRef) {} } diff --git a/src/components/virtual-scroll/virtual-scroll.scss b/src/components/virtual-scroll/virtual-scroll.scss index 1fe49f53e45..25d0fc0e012 100644 --- a/src/components/virtual-scroll/virtual-scroll.scss +++ b/src/components/virtual-scroll/virtual-scroll.scss @@ -2,6 +2,10 @@ // Virtual Scroll // -------------------------------------------------- +.virtual-loading { + opacity: 0; +} + .virtual-scroll { position: relative; @@ -20,6 +24,6 @@ contain: content; } -.virtual-scroll .virtual-hidden { +.virtual-scroll .virtual-last { display: none; } diff --git a/src/components/virtual-scroll/virtual-scroll.ts b/src/components/virtual-scroll/virtual-scroll.ts index ed25fd0abc1..3803e68de9c 100644 --- a/src/components/virtual-scroll/virtual-scroll.ts +++ b/src/components/virtual-scroll/virtual-scroll.ts @@ -1,10 +1,9 @@ -import { AfterContentInit, ChangeDetectorRef, ContentChild, ContentChildren, Directive, DoCheck, ElementRef, Input, IterableDiffers, IterableDiffer, NgZone, OnDestroy, Optional, QueryList, Renderer, TrackByFn } from '@angular/core'; +import { AfterContentInit, ChangeDetectorRef, ContentChild, Directive, DoCheck, ElementRef, Input, IterableDiffers, IterableDiffer, NgZone, OnDestroy, Optional, Renderer, TrackByFn } from '@angular/core'; -import { adjustRendered, calcDimensions, estimateHeight, initReadNodes, processRecords, populateNodeData, updateDimensions, writeToNodes } from './virtual-util'; -import { clearNativeTimeout, nativeRaf, nativeTimeout } from '../../util/dom'; +import { adjustRendered, calcDimensions, estimateHeight, initReadNodes, processRecords, populateNodeData, updateDimensions, updateNodeContext, writeToNodes } from './virtual-util'; import { Config } from '../../config/config'; -import { Content } from '../content/content'; -import { Img } from '../img/img'; +import { Content, ScrollEvent } from '../content/content'; +import { DomController } from '../../util/dom-controller'; import { isBlank, isFunction, isPresent } from '../../util/util'; import { Platform } from '../../platform/platform'; import { ViewController } from '../../navigation/view-controller'; @@ -186,10 +185,9 @@ import { VirtualFooter, VirtualHeader, VirtualItem } from './virtual-item'; export class VirtualScroll implements DoCheck, AfterContentInit, OnDestroy { _trackBy: TrackByFn; _differ: IterableDiffer; - _unreg: Function; + _scrollSub: any; + _scrollEndSub: any; _init: boolean; - _rafId: number; - _tmId: number; _hdrFn: Function; _ftrFn: Function; _records: any[] = []; @@ -200,13 +198,12 @@ export class VirtualScroll implements DoCheck, AfterContentInit, OnDestroy { _data: VirtualData = { scrollTop: 0, }; - _eventAssist: boolean; - _queue: number = null; + _queue: ScrollQueue = null; @ContentChild(VirtualItem) _itmTmp: VirtualItem; @ContentChild(VirtualHeader) _hdrTmp: VirtualHeader; @ContentChild(VirtualFooter) _ftrTmp: VirtualFooter; - @ContentChildren(Img) _imgs: QueryList; + /** * @input {array} The data that builds the templates within the virtual scroll. @@ -338,6 +335,8 @@ export class VirtualScroll implements DoCheck, AfterContentInit, OnDestroy { this._trackBy = val; } + private _hasUpdate = false; + constructor( private _iterableDiffers: IterableDiffers, private _elementRef: ElementRef, @@ -347,16 +346,85 @@ export class VirtualScroll implements DoCheck, AfterContentInit, OnDestroy { private _content: Content, private _platform: Platform, @Optional() private _ctrl: ViewController, - config: Config) { - this._eventAssist = config.getBoolean('virtualScrollEventAssist'); + config: Config, + private _dom: DomController) { + + // hide the virtual scroll element with opacity so we don't + // see jank as it loads up, but we're still able to read + // dimensions because it's still rendered and only opacity hidden + this._renderer.setElementClass(_elementRef.nativeElement, 'virtual-loading', true); + + // wait for the content to be rendered and has readable dimensions + _content.readReady.subscribe(() => { + this.readUpdate(true, true); + + if (!this._scrollSub) { + // listen for scroll events + this.addScrollListener(config.getBoolean('virtualScrollEventAssist')); + } + }); + + // wait for the content to be writable + _content.writeReady.subscribe(() => { + this.writeUpdate(); + }); + } + + readUpdate(checkDataChanges: boolean, dimensionsUpdated: boolean) { + if (!this._records) return; + + if (checkDataChanges && !dimensionsUpdated) { + if (isPresent(this._differ) && !isPresent(this._differ.diff(this._records))) { + // no changes + return; + } + } + + console.debug(`virtual-scroll, readUpdate, checkDataChanges: ${checkDataChanges}, dimensionsUpdated: ${dimensionsUpdated}`); + + this._hasUpdate = true; + + // reset everything + this._cells.length = 0; + this._nodes.length = 0; + this._itmTmp.viewContainer.clear(); + + // ******** DOM READ **************** + calcDimensions(this._data, this._elementRef.nativeElement, + this.approxItemWidth, this.approxItemHeight, + this.approxHeaderWidth, this.approxHeaderHeight, + this.approxFooterWidth, this.approxFooterHeight, + this.bufferRatio); + + } + + writeUpdate() { + if (!this._hasUpdate) { + return; } + console.debug(`virtual-scroll, writeUpdate`); + + processRecords(this._data.renderHeight, + this._records, + this._cells, + this._hdrFn, + this._ftrFn, + this._data); + + // ******** DOM WRITE **************** + this.renderVirtual(); + + this._hasUpdate = false; + } + /** * @private */ ngDoCheck() { if (this._init) { - this.update(true); + this.readUpdate(true, false); + this.writeUpdate(); } } @@ -377,84 +445,13 @@ export class VirtualScroll implements DoCheck, AfterContentInit, OnDestroy { console.warn('Virtual Scroll: Please provide an "approxItemHeight" input to ensure proper virtual scroll rendering'); } - this.update(true); - - this._platform.onResize(() => { - console.debug('VirtualScroll, onResize'); - this.update(false); - }); - - } - } - - /** - * @private - * DOM READ THEN DOM WRITE - */ - update(checkChanges: boolean) { - const self = this; - - if (!self._records) return; - - if (checkChanges) { - if (isPresent(self._differ)) { - let changes = self._differ.diff(self._records); - if (!isPresent(changes)) return; - } - } - - console.debug('VirtualScroll, update, records:', self._records.length); + // this.update(true); - // reset everything - self._cells.length = 0; - self._nodes.length = 0; - self._itmTmp.viewContainer.clear(); - self._elementRef.nativeElement.parentElement.scrollTop = 0; - - let attempts = 0; - function readDimensions(done: Function/* cuz promises add unnecessary overhead here */) { - if (self._data.valid) { - // good to go, we already have good dimension data - done(); - - } else { - // ******** DOM READ **************** - calcDimensions(self._data, self._elementRef.nativeElement.parentElement, - self.approxItemWidth, self.approxItemHeight, - self.approxHeaderWidth, self.approxHeaderHeight, - self.approxFooterWidth, self.approxFooterHeight, - self.bufferRatio); - - if (self._data.valid) { - // sweet, we got some good dimension data! - done(); - - } else if (attempts < 30) { - // oh no! the DOM doesn't have good data yet! - // let's try again in XXms, and give up eventually if we never get data - attempts++; - nativeRaf(function() { - readDimensions(done); - }); - } - } + // this._platform.onResize(() => { + // console.debug('VirtualScroll, onResize'); + // this.update(false); + // }); } - - // ******** DOM READ **************** - readDimensions(function() { - processRecords(self._data.renderHeight, - self._records, - self._cells, - self._hdrFn, - self._ftrFn, - self._data); - - // ******** DOM WRITE **************** - self.renderVirtual(); - - // list for scroll events - self.addScrollListener(); - }); } /** @@ -462,13 +459,18 @@ export class VirtualScroll implements DoCheck, AfterContentInit, OnDestroy { * DOM WRITE */ renderVirtual() { + const nodes = this._nodes; + const cells = this._cells; + const data = this._data; + const records = this._records; + // initialize nodes with the correct cell data - this._data.topCell = 0; - this._data.bottomCell = (this._cells.length - 1); + data.topCell = 0; + data.bottomCell = (cells.length - 1); - populateNodeData(0, this._data.bottomCell, - this._data.viewWidth, true, - this._cells, this._records, this._nodes, + populateNodeData(0, data.bottomCell, + data.viewWidth, true, + cells, records, nodes, this._itmTmp.viewContainer, this._itmTmp.templateRef, this._hdrTmp && this._hdrTmp.templateRef, @@ -477,56 +479,93 @@ export class VirtualScroll implements DoCheck, AfterContentInit, OnDestroy { // ******** DOM WRITE **************** this._cd.detectChanges(); + + // at this point, this fn was called from within another + // requestAnimationFrame, so the next dom reads/writes within the next frame // wait a frame before trying to read and calculate the dimensions - nativeRaf(this.postRenderVirtual.bind(this)); - } + this._dom.read(() => { + // ******** DOM READ **************** + initReadNodes(nodes, cells, data); + }); - /** - * @private - * DOM READ THEN DOM WRITE - */ - postRenderVirtual() { - // ******** DOM READ THEN DOM WRITE **************** - initReadNodes(this._nodes, this._cells, this._data); + this._dom.write(() => { + const ele = this._elementRef.nativeElement; + const recordsLength = records.length; + const renderer = this._renderer; + // update the bound context for each node + updateNodeContext(nodes, cells, data); - // ******** DOM READS ABOVE / DOM WRITES BELOW **************** + // ******** DOM WRITE **************** + this._cd.detectChanges(); + // add an element at the end so :last-child css doesn't get messed up + // ******** DOM WRITE **************** + const lastEle: HTMLElement = renderer.createElement(ele, 'div'); + lastEle.className = 'virtual-last'; - // ******** DOM WRITE **************** - this._renderer.setElementClass(this._elementRef.nativeElement, 'virtual-scroll', true); + // ******** DOM WRITE **************** + renderer.setElementClass(ele, 'virtual-scroll', true); - // ******** DOM WRITE **************** - writeToNodes(this._nodes, this._cells, this._records.length); + // ******** DOM WRITE **************** + renderer.setElementClass(ele, 'virtual-loading', false); + + // ******** DOM WRITE **************** + writeToNodes(nodes, cells, recordsLength); + + // ******** DOM WRITE **************** + this.setVirtualHeight( + estimateHeight(recordsLength, cells[cells.length - 1], this._vHeight, 0.25) + ); + + this._content.imgsRefresh(); + }); - // ******** DOM WRITE **************** - this.setVirtualHeight( - estimateHeight(this._records.length, this._cells[this._cells.length - 1], this._vHeight, 0.25) - ); } /** * @private */ - scrollUpdate() { - clearNativeTimeout(this._tmId); - this._tmId = nativeTimeout(this.onScrollEnd.bind(this), SCROLL_END_TIMEOUT_MS); + scrollUpdate(ev: ScrollEvent) { + // there is a queue system so that we can + // spread out the work over multiple frames + const data = this._data; + const cells = this._cells; + const nodes = this._nodes; - let data = this._data; + // set the scroll top from the scroll event + data.scrollTop = ev.scrollTop; - if (this._queue === QUEUE_CHANGE_DETECTION) { - // ******** DOM WRITE **************** - this._cd.detectChanges(); + if (this._queue === ScrollQueue.RequiresDomWrite) { - // ******** DOM WRITE **************** - writeToNodes(this._nodes, this._cells, this._records.length); + this._dom.write(() => { + // ******** DOM WRITE **************** + writeToNodes(nodes, cells, this._records.length); - // ******** DOM WRITE **************** - this.setVirtualHeight( - estimateHeight(this._records.length, this._cells[this._cells.length - 1], this._vHeight, 0.25) - ); + // ******** DOM WRITE **************** + this.setVirtualHeight( + estimateHeight(this._records.length, cells[cells.length - 1], this._vHeight, 0.25) + ); - this._queue = null; + // we're done here, good work + this._queue = ScrollQueue.NoChanges; + }); + + } else if (this._queue === ScrollQueue.RequiresChangeDetection) { + + this._dom.write(() => { + // we've got work painting do, let's throw it in the + // domWrite callback so everyone plays nice + // ******** DOM WRITE **************** + for (var i = 0; i < nodes.length; i++) { + if (nodes[i].hasChanges) { + (nodes[i].view).detectChanges(); + } + } + + // on the next frame we need write to the dom nodes manually + this._queue = ScrollQueue.RequiresDomWrite; + }); } else { @@ -538,36 +577,31 @@ export class VirtualScroll implements DoCheck, AfterContentInit, OnDestroy { if (data.scrollDiff > 0) { // load data we may not have processed yet - let stopAtHeight = (data.scrollTop + data.renderHeight); + var stopAtHeight = (data.scrollTop + data.renderHeight); - processRecords(stopAtHeight, this._records, this._cells, + processRecords(stopAtHeight, this._records, cells, this._hdrFn, this._ftrFn, data); } // ******** DOM READ **************** - updateDimensions(this._nodes, this._cells, data, false); - - adjustRendered(this._cells, data); + updateDimensions(nodes, cells, data, false); - let madeChanges = populateNodeData(data.topCell, data.bottomCell, - data.viewWidth, data.scrollDiff > 0, - this._cells, this._records, this._nodes, - this._itmTmp.viewContainer, - this._itmTmp.templateRef, - this._hdrTmp && this._hdrTmp.templateRef, - this._ftrTmp && this._ftrTmp.templateRef, false); + adjustRendered(cells, data); - if (madeChanges) { - // do not update images while scrolling - this._imgs.forEach(img => { - img.enable(false); - }); + var hasChanges = populateNodeData(data.topCell, data.bottomCell, + data.viewWidth, data.scrollDiff > 0, + cells, this._records, nodes, + this._itmTmp.viewContainer, + this._itmTmp.templateRef, + this._hdrTmp && this._hdrTmp.templateRef, + this._ftrTmp && this._ftrTmp.templateRef, false); + if (hasChanges) { // queue making updates in the next frame - this._queue = QUEUE_CHANGE_DETECTION; + this._queue = ScrollQueue.RequiresChangeDetection; - } else { - this._queue = null; + // update the bound context for each node + updateNodeContext(nodes, cells, data); } } @@ -578,24 +612,35 @@ export class VirtualScroll implements DoCheck, AfterContentInit, OnDestroy { * @private * DOM WRITE */ - onScrollEnd() { - // scrolling is done, allow images to be updated now - this._imgs.forEach(img => { - img.enable(true); - }); + scrollEnd(ev: ScrollEvent) { + const nodes = this._nodes; + const cells = this._cells; + const data = this._data; // ******** DOM READ **************** - updateDimensions(this._nodes, this._cells, this._data, false); + updateDimensions(nodes, cells, data, false); - adjustRendered(this._cells, this._data); + adjustRendered(cells, data); - // ******** DOM WRITE **************** - this._cd.detectChanges(); + // ******** DOM READS ABOVE / DOM WRITES BELOW **************** - // ******** DOM WRITE **************** - this.setVirtualHeight( - estimateHeight(this._records.length, this._cells[this._cells.length - 1], this._vHeight, 0.05) - ); + this._dom.write(() => { + // update the bound context for each node + updateNodeContext(nodes, cells, data); + + // ******** DOM WRITE **************** + this._cd.detectChanges(); + + // ******** DOM WRITE **************** + writeToNodes(nodes, cells, this._records.length); + + // ******** DOM WRITE **************** + this.setVirtualHeight( + estimateHeight(this._records.length, cells[cells.length - 1], this._vHeight, 0.05) + ); + + this._queue = ScrollQueue.NoChanges; + }); } /** @@ -616,34 +661,22 @@ export class VirtualScroll implements DoCheck, AfterContentInit, OnDestroy { * @private * NO DOM */ - addScrollListener() { - let self = this; - - if (!self._unreg) { - self._zone.runOutsideAngular(() => { - - function onScroll() { - // ******** DOM READ **************** - self._data.scrollTop = self._content.getScrollTop(); - - // ******** DOM READ THEN DOM WRITE **************** - self.scrollUpdate(); - } - - if (self._eventAssist) { - // use JS scrolling for iOS UIWebView - // goal is to completely remove this when iOS - // fully supports scroll events - // listen to JS scroll events - self._unreg = self._content.jsScroll(onScroll); + addScrollListener(eventAssist: boolean) { + if (eventAssist) { + // use JS scrolling for iOS UIWebView + // goal is to completely remove this when iOS + // fully supports scroll events + // listen to JS scroll events + this._content.enableJsScroll(); + } - } else { - // listen to native scroll events - self._unreg = self._content.addScrollListener(onScroll); - } + this._scrollSub = this._content.ionScroll.subscribe((ev: ScrollEvent) => { + this.scrollUpdate(ev); + }); - }); - } + this._scrollEndSub = this._content.ionScrollEnd.subscribe((ev: ScrollEvent) => { + this.scrollEnd(ev); + }); } /** @@ -651,12 +684,16 @@ export class VirtualScroll implements DoCheck, AfterContentInit, OnDestroy { * NO DOM */ ngOnDestroy() { - this._unreg && this._unreg(); - this._unreg = null; + this._scrollSub && this._scrollSub.unsubscribe(); + this._scrollEndSub && this._scrollEndSub.unsubscribe(); } } -const SCROLL_END_TIMEOUT_MS = 140; const SCROLL_DIFFERENCE_MINIMUM = 20; -const QUEUE_CHANGE_DETECTION = 0; + +export const enum ScrollQueue { + NoChanges, + RequiresChangeDetection, + RequiresDomWrite +} diff --git a/src/components/virtual-scroll/virtual-util.ts b/src/components/virtual-scroll/virtual-util.ts index a4cea507b05..04d08a01904 100644 --- a/src/components/virtual-scroll/virtual-util.ts +++ b/src/components/virtual-scroll/virtual-util.ts @@ -129,24 +129,22 @@ function addCell(previousCell: VirtualCell, recordIndex: number, tmpl: number, t */ export function populateNodeData(startCellIndex: number, endCellIndex: number, viewportWidth: number, scrollingDown: boolean, cells: VirtualCell[], records: any[], nodes: VirtualNode[], viewContainer: ViewContainerRef, - itmTmp: TemplateRef, hdrTmp: TemplateRef, ftrTmp: TemplateRef, + itmTmp: TemplateRef, hdrTmp: TemplateRef, ftrTmp: TemplateRef, initialLoad: boolean): boolean { - - if (!records.length) { + const recordsLength = records.length; + if (!recordsLength) { nodes.length = 0; - // made changes return true; } - let madeChanges = false; + let hasChanges = false; let node: VirtualNode; let availableNode: VirtualNode; let cell: VirtualCell; let isAlreadyRendered: boolean; - let lastRecordIndex = (records.length - 1); let viewInsertIndex: number = null; let totalNodes = nodes.length; - let templateRef: TemplateRef; + let templateRef: TemplateRef; startCellIndex = Math.max(startCellIndex, 0); endCellIndex = Math.min(endCellIndex, cells.length - 1); @@ -165,16 +163,6 @@ export function populateNodeData(startCellIndex: number, endCellIndex: number, v // first node can only be used by the first cell (css :first-child reasons) // this node is never available to be reused continue; - - } else if (node.isLastRecord) { - // very last record, but could be a header/item/footer - if (cell.record === lastRecordIndex) { - availableNode = nodes[i]; - availableNode.hidden = false; - break; - } - // this node is for the last record, but not actually the last - continue; } if (node.cell === cellIndex) { @@ -215,7 +203,7 @@ export function populateNodeData(startCellIndex: number, endCellIndex: number, v viewInsertIndex = -1; for (var j = totalNodes - 1; j >= 0; j--) { node = nodes[j]; - if (node && !node.isLastRecord) { + if (node) { viewInsertIndex = viewContainer.indexOf(node.view); break; } @@ -231,7 +219,7 @@ export function populateNodeData(startCellIndex: number, endCellIndex: number, v availableNode = { tmpl: cell.tmpl, - view: >viewContainer.createEmbeddedView( + view: viewContainer.createEmbeddedView( templateRef, new VirtualContext(null, null, null), viewInsertIndex @@ -247,63 +235,31 @@ export function populateNodeData(startCellIndex: number, endCellIndex: number, v // apply the cell's data to this node availableNode.view.context.$implicit = cell.data || records[cell.record]; availableNode.view.context.index = cellIndex; + availableNode.view.context.count = recordsLength; availableNode.hasChanges = true; availableNode.lastTransform = null; - madeChanges = true; - } - - if (initialLoad) { - // add nodes that go at the very end, and only represent the last record - let lastNodeTempData: any = (records[lastRecordIndex] || {}); - addLastNodes(nodes, viewContainer, TEMPLATE_HEADER, hdrTmp, lastNodeTempData); - addLastNodes(nodes, viewContainer, TEMPLATE_ITEM, itmTmp, lastNodeTempData); - addLastNodes(nodes, viewContainer, TEMPLATE_FOOTER, ftrTmp, lastNodeTempData); + hasChanges = true; } - return madeChanges; -} - - -function addLastNodes(nodes: VirtualNode[], viewContainer: ViewContainerRef, - templateType: number, templateRef: TemplateRef, temporaryData: any) { - if (templateRef) { - let node: VirtualNode = { - tmpl: templateType, - view: >viewContainer.createEmbeddedView(templateRef), - isLastRecord: true, - hidden: true, - }; - node.view.context.$implicit = temporaryData; - nodes.push(node); - } + return hasChanges; } /** - * DOM READ THEN DOM WRITE + * DOM READ */ export function initReadNodes(nodes: VirtualNode[], cells: VirtualCell[], data: VirtualData) { if (nodes.length && cells.length) { // first node // ******** DOM READ **************** - let firstEle = getElement(nodes[0]); - cells[0].top = firstEle.clientTop; - cells[0].left = firstEle.clientLeft; - cells[0].row = 0; + var ele = getElement(nodes[0]); + var firstCell = cells[0]; + firstCell.top = ele.clientTop; + firstCell.left = ele.clientLeft; + firstCell.row = 0; // ******** DOM READ **************** updateDimensions(nodes, cells, data, true); - - - // ******** DOM READS ABOVE / DOM WRITES BELOW **************** - - - for (var i = 0; i < nodes.length; i++) { - if (nodes[i].hidden) { - // ******** DOM WRITE **************** - getElement(nodes[i]).classList.add('virtual-hidden'); - } - } } } @@ -313,17 +269,17 @@ export function initReadNodes(nodes: VirtualNode[], cells: VirtualCell[], data: */ export function updateDimensions(nodes: VirtualNode[], cells: VirtualCell[], data: VirtualData, initialUpdate: boolean) { let node: VirtualNode; - let element: HTMLElement; - let totalCells = cells.length; + let element: VirtualHtmlElement; let cell: VirtualCell; let previousCell: VirtualCell; + const totalCells = cells.length; for (var i = 0; i < nodes.length; i++) { node = nodes[i]; cell = cells[node.cell]; // read element dimensions if they haven't been checked enough times - if (cell && cell.reads < REQUIRED_DOM_READS && !node.hidden) { + if (cell && cell.reads < REQUIRED_DOM_READS) { element = getElement(node); // ******** DOM READ **************** @@ -353,10 +309,11 @@ export function updateDimensions(nodes: VirtualNode[], cells: VirtualCell[], dat cell.reads++; } + } // figure out which cells are currently viewable within the viewport - let viewableBottom = (data.scrollTop + data.viewHeight); + const viewableBottom = (data.scrollTop + data.viewHeight); data.topViewCell = totalCells; data.bottomViewCell = 0; @@ -386,15 +343,42 @@ export function updateDimensions(nodes: VirtualNode[], cells: VirtualCell[], dat data.bottomViewCell = i; } } + +} + + +export function updateNodeContext(nodes: VirtualNode[], cells: VirtualCell[], data: VirtualData) { + // ensure each node has the correct bounds in its context + let node: VirtualNode; + let cell: VirtualCell; + let bounds: VirtualBounds; + + for (var i = 0, ilen = nodes.length; i < ilen; i++) { + node = nodes[i]; + cell = cells[node.cell]; + + if (node && cell) { + bounds = node.view.context.bounds; + + bounds.top = cell.top + data.viewTop; + bounds.bottom = bounds.top + cell.height; + + bounds.left = cell.left + data.viewLeft; + bounds.right = bounds.left + cell.width; + + bounds.width = cell.width; + bounds.height = cell.height; + } + } } /** * DOM READ */ -function readElements(cell: VirtualCell, element: HTMLElement) { +function readElements(cell: VirtualCell, element: VirtualHtmlElement) { // ******** DOM READ **************** - let styles = window.getComputedStyle(element); + const styles = window.getComputedStyle(element); // ******** DOM READ **************** cell.left = (element.offsetLeft - parseFloat(styles.marginLeft)); @@ -412,43 +396,34 @@ function readElements(cell: VirtualCell, element: HTMLElement) { */ export function writeToNodes(nodes: VirtualNode[], cells: VirtualCell[], totalRecords: number) { let node: VirtualNode; - let element: HTMLElement; + let element: VirtualHtmlElement; let cell: VirtualCell; - let totalCells = Math.max(totalRecords, cells.length).toString(); let transform: string; + const totalCells = Math.max(totalRecords, cells.length); for (var i = 0, ilen = nodes.length; i < ilen; i++) { node = nodes[i]; + cell = cells[node.cell]; - if (!node.hidden) { - cell = cells[node.cell]; - - transform = `translate3d(${cell.left}px,${cell.top}px,0px)`; - - if (node.lastTransform !== transform) { - element = getElement(node); + transform = `translate3d(${cell.left}px,${cell.top}px,0px)`; - if (element) { - // ******** DOM WRITE **************** - (element.style)[CSS.transform] = node.lastTransform = transform; + if (node.lastTransform !== transform) { + element = getElement(node); - // ******** DOM WRITE **************** - element.classList.add('virtual-position'); + if (element) { + // ******** DOM WRITE **************** + element.style[CSS.transform] = node.lastTransform = transform; - if (node.isLastRecord) { - // its the last record, now with data and safe to show - // ******** DOM WRITE **************** - element.classList.remove('virtual-hidden'); - } + // ******** DOM WRITE **************** + element.classList.add('virtual-position'); - // https://www.w3.org/TR/wai-aria/states_and_properties#aria-posinset - // ******** DOM WRITE **************** - element.setAttribute('aria-posinset', (node.cell + 1).toString()); + // https://www.w3.org/TR/wai-aria/states_and_properties#aria-posinset + // ******** DOM WRITE **************** + element.setAttribute('aria-posinset', node.cell + 1); - // https://www.w3.org/TR/wai-aria/states_and_properties#aria-setsize - // ******** DOM WRITE **************** - element.setAttribute('aria-setsize', totalCells); - } + // https://www.w3.org/TR/wai-aria/states_and_properties#aria-setsize + // ******** DOM WRITE **************** + element.setAttribute('aria-setsize', totalCells); } } } @@ -555,19 +530,30 @@ export function estimateHeight(totalRecords: number, lastCell: VirtualCell, exis * DOM READ */ export function calcDimensions(data: VirtualData, - viewportElement: HTMLElement, + virtualScrollElement: HTMLElement, approxItemWidth: string, approxItemHeight: string, appoxHeaderWidth: string, approxHeaderHeight: string, approxFooterWidth: string, approxFooterHeight: string, bufferRatio: number) { - // get the parent container's viewport height + // get the parent container's viewport bounds + const viewportElement = virtualScrollElement.parentElement; + // ******** DOM READ **************** data.viewWidth = viewportElement.offsetWidth; // ******** DOM READ **************** data.viewHeight = viewportElement.offsetHeight; + + // get the virtual scroll element's offset data + // ******** DOM READ **************** + data.viewTop = virtualScrollElement.offsetTop; + + // ******** DOM READ **************** + data.viewLeft = virtualScrollElement.offsetLeft; + + // the height we'd like to render, which is larger than viewable data.renderHeight = (data.viewHeight * bufferRatio); @@ -614,8 +600,8 @@ function calcHeight(viewportHeight: number, approxHeight: string): number { /** * NO DOM */ -function getElement(node: VirtualNode): HTMLElement { - let rootNodes = node.view.rootNodes; +function getElement(node: VirtualNode): VirtualHtmlElement { + const rootNodes = node.view.rootNodes; for (var i = 0; i < rootNodes.length; i++) { if (rootNodes[i].nodeType === 1) { return rootNodes[i]; @@ -625,6 +611,23 @@ function getElement(node: VirtualNode): HTMLElement { } +export interface VirtualHtmlElement { + clientTop: number; + clientLeft: number; + offsetTop: number; + offsetLeft: number; + offsetWidth: number; + offsetHeight: number; + style: any; + classList: { + add: {(name: string)}; + remove: {(name: string)}; + }; + setAttribute: {(name: string, value: any)}; + parentElement: VirtualHtmlElement; +} + + // could be either record data or divider data export interface VirtualCell { record?: number; @@ -644,13 +647,13 @@ export interface VirtualNode { cell?: number; tmpl: number; view: EmbeddedViewRef; - isLastRecord?: boolean; - hidden?: boolean; hasChanges?: boolean; lastTransform?: string; } export class VirtualContext { + bounds: VirtualBounds = {}; + constructor(public $implicit: any, public index: number, public count: number) {} get first(): boolean { return this.index === 0; } @@ -660,12 +663,23 @@ export class VirtualContext { get even(): boolean { return this.index % 2 === 0; } get odd(): boolean { return !this.even; } + } +export interface VirtualBounds { + top?: number; + bottom?: number; + left?: number; + right?: number; + width?: number; + height?: number; +} export interface VirtualData { scrollTop?: number; scrollDiff?: number; + viewTop?: number; + viewLeft?: number; viewWidth?: number; viewHeight?: number; renderHeight?: number;