From 7679ac08f568870a4f4c56c1bfdabbd68408329f Mon Sep 17 00:00:00 2001 From: Adam Bradley Date: Tue, 5 Apr 2016 14:49:58 -0500 Subject: [PATCH] feat(virtualScroll): init virtual scroll Closes #5418 --- ionic/components.core.scss | 4 +- ionic/components.ts | 1 + .../virtual-scroll/test/basic/index.ts | 43 ++ .../virtual-scroll/test/basic/main.html | 27 + .../virtual-scroll/test/cards/index.ts | 54 ++ .../virtual-scroll/test/cards/main.html | 28 + .../test/image-gallery/index.ts | 101 +++ .../test/image-gallery/main.html | 71 ++ .../test/variable-size/index.ts | 41 ++ .../test/variable-size/main.html | 17 + .../test/virtual-scroll.spec.ts | 569 +++++++++++++++ .../components/virtual-scroll/virtual-item.ts | 28 + .../virtual-scroll/virtual-scroll.scss | 22 + .../virtual-scroll/virtual-scroll.ts | 652 +++++++++++++++++ .../components/virtual-scroll/virtual-util.ts | 666 ++++++++++++++++++ ionic/config/directives.ts | 10 + ionic/platform/registry.ts | 5 + 17 files changed, 2338 insertions(+), 1 deletion(-) create mode 100644 ionic/components/virtual-scroll/test/basic/index.ts create mode 100644 ionic/components/virtual-scroll/test/basic/main.html create mode 100644 ionic/components/virtual-scroll/test/cards/index.ts create mode 100644 ionic/components/virtual-scroll/test/cards/main.html create mode 100644 ionic/components/virtual-scroll/test/image-gallery/index.ts create mode 100644 ionic/components/virtual-scroll/test/image-gallery/main.html create mode 100644 ionic/components/virtual-scroll/test/variable-size/index.ts create mode 100644 ionic/components/virtual-scroll/test/variable-size/main.html create mode 100644 ionic/components/virtual-scroll/test/virtual-scroll.spec.ts create mode 100644 ionic/components/virtual-scroll/virtual-item.ts create mode 100644 ionic/components/virtual-scroll/virtual-scroll.scss create mode 100644 ionic/components/virtual-scroll/virtual-scroll.ts create mode 100644 ionic/components/virtual-scroll/virtual-util.ts diff --git a/ionic/components.core.scss b/ionic/components.core.scss index 1d97ed8540..84e1a766f5 100644 --- a/ionic/components.core.scss +++ b/ionic/components.core.scss @@ -16,6 +16,7 @@ @import "components/grid/grid", "components/icon/icon", + "components/img/img", "components/infinite-scroll/infinite-scroll", "components/loading/loading", "components/menu/menu", @@ -23,7 +24,8 @@ "components/refresher/refresher", "components/scroll/scroll", "components/slides/slides", - "components/spinner/spinner"; + "components/spinner/spinner", + "components/virtual-scroll/virtual-scroll"; // Ionicons (to be replaced with SVGs) diff --git a/ionic/components.ts b/ionic/components.ts index 2a3d11b835..02968e5ae9 100644 --- a/ionic/components.ts +++ b/ionic/components.ts @@ -45,3 +45,4 @@ export * from './components/tabs/tab' export * from './components/tap-click/tap-click' export * from './components/toggle/toggle' export * from './components/toolbar/toolbar' +export * from './components/virtual-scroll/virtual-scroll' diff --git a/ionic/components/virtual-scroll/test/basic/index.ts b/ionic/components/virtual-scroll/test/basic/index.ts new file mode 100644 index 0000000000..0156b7aefd --- /dev/null +++ b/ionic/components/virtual-scroll/test/basic/index.ts @@ -0,0 +1,43 @@ +import {ViewChild, ElementRef} from 'angular2/core'; +import {App, Page} from 'ionic-angular'; + + +@Page({ + templateUrl: 'main.html' +}) +class E2EPage { + items = []; + + @ViewChild('content') content: ElementRef; + + constructor() { + for (var i = 0; i < 14; i++) { + this.items.push(i); + } + + } + + headerFn(record: any, index: number, records: any[]) { + if (index % 4 === 0) { + return index + ' is divisible by 4'; + } + + return null; + } + + reload() { + window.location.reload(true); + } + +} + + +@App({ + template: '' +}) +class E2EApp { + root; + constructor() { + this.root = E2EPage; + } +} diff --git a/ionic/components/virtual-scroll/test/basic/main.html b/ionic/components/virtual-scroll/test/basic/main.html new file mode 100644 index 0000000000..d6568a7e40 --- /dev/null +++ b/ionic/components/virtual-scroll/test/basic/main.html @@ -0,0 +1,27 @@ + + Virtual Scroll + + + + + + + + + + + + Header: {{header}} + + + + Item: {{item}} + + + + + + diff --git a/ionic/components/virtual-scroll/test/cards/index.ts b/ionic/components/virtual-scroll/test/cards/index.ts new file mode 100644 index 0000000000..720a75499c --- /dev/null +++ b/ionic/components/virtual-scroll/test/cards/index.ts @@ -0,0 +1,54 @@ +import {ViewEncapsulation} from 'angular2/core'; +import {App, Page} from 'ionic-angular'; + + +@Page({ + templateUrl: 'main.html', + encapsulation: ViewEncapsulation.None +}) +class E2EPage { + items = []; + + constructor() { + for (var i = 0; i < 500; i++) { + this.items.push({ + imgSrc: `../../img/img/${images[rotateImg]}.jpg?${Math.random()}`, + imgHeight: Math.floor((Math.random() * 50) + 150), + name: i + ' - ' + images[rotateImg], + content: lorem.substring(0, (Math.random() * (lorem.length - 100)) + 100) + }); + + rotateImg++; + if (rotateImg === images.length) rotateImg = 0; + } + } + +} + + +@App({ + template: '', +}) +class E2EApp { + root; + constructor() { + this.root = E2EPage; + } +} + +const lorem = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.'; + +const images = [ + 'bandit', + 'batmobile', + 'blues-brothers', + 'bueller', + 'delorean', + 'eleanor', + 'general-lee', + 'ghostbusters', + 'knight-rider', + 'mirth-mobile', +]; + +let rotateImg = 0; diff --git a/ionic/components/virtual-scroll/test/cards/main.html b/ionic/components/virtual-scroll/test/cards/main.html new file mode 100644 index 0000000000..f96295aa93 --- /dev/null +++ b/ionic/components/virtual-scroll/test/cards/main.html @@ -0,0 +1,28 @@ +Virtual Scroll: Cards + + + +
+ + + +
+ +
+ + + + + +

{{ item.name }}

+
+ + + {{ item.content }} + + +
+ +
+ +
diff --git a/ionic/components/virtual-scroll/test/image-gallery/index.ts b/ionic/components/virtual-scroll/test/image-gallery/index.ts new file mode 100644 index 0000000000..2e36b6f60b --- /dev/null +++ b/ionic/components/virtual-scroll/test/image-gallery/index.ts @@ -0,0 +1,101 @@ +import {ViewEncapsulation} from 'angular2/core'; +import {App, Page} from 'ionic-angular'; + + +@Page({ + templateUrl: 'main.html', + encapsulation: ViewEncapsulation.None +}) +class E2EPage { + lastMonth: number; + items = []; + + constructor() { + var utcSeconds = 787523438; // Dec 15, 1994 + var d = new Date(0); + d.setUTCSeconds(utcSeconds); + + for (var i = 0; i < 1000; i++) { + this.items.push({ + index: i, + date: d, + imgSrc: `../../img/img/${images[rotateImg]}.jpg?${Math.random()}`, + }); + + rotateImg++; + if (rotateImg === images.length) rotateImg = 0; + + if (i < 100) { + utcSeconds += 237600; // 2.75 days + } else { + utcSeconds += (Math.random() * 237600) + 86400; + } + + d = new Date(0); + d.setUTCSeconds(utcSeconds); + } + } + + headerFn(record: any, recordIndex: number, records: any[]) { + if (this.lastMonth !== record.date.getMonth()) { + this.lastMonth = record.date.getMonth(); + + return { + date: monthNames[this.lastMonth] + ' ' + record.date.getFullYear() + } + } + + return null; + } + + footerFn(record: any, recordIndex: number, records: any[]) { + + if (recordIndex === records.length - 1) { + return true; + + } else { + if (records[recordIndex + 1].date.getMonth() !== this.lastMonth) { + return true; + } + } + + return null; + } + + ngDoCheck() { + console.log('DoCheck') + } + + reload() { + window.location.reload(true); + } + +} + + +@App({ + template: '', +}) +class E2EApp { + root; + constructor() { + this.root = E2EPage; + } +} + +var monthNames = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December']; + +const images = [ + 'bandit', + 'batmobile', + 'blues-brothers', + 'bueller', + 'delorean', + 'eleanor', + 'general-lee', + 'ghostbusters', + 'knight-rider', + 'mirth-mobile', +]; + +let rotateImg = 0; diff --git a/ionic/components/virtual-scroll/test/image-gallery/main.html b/ionic/components/virtual-scroll/test/image-gallery/main.html new file mode 100644 index 0000000000..358ddad017 --- /dev/null +++ b/ionic/components/virtual-scroll/test/image-gallery/main.html @@ -0,0 +1,71 @@ + + + + Virtual Scroll: Image Gallery + + + + + + + + + +
+ Header: {{header.date}} +
+ +
+ + +
+ + + +
+ +
+ diff --git a/ionic/components/virtual-scroll/test/variable-size/index.ts b/ionic/components/virtual-scroll/test/variable-size/index.ts new file mode 100644 index 0000000000..44745d5b3a --- /dev/null +++ b/ionic/components/virtual-scroll/test/variable-size/index.ts @@ -0,0 +1,41 @@ +import {App, Page} from 'ionic-angular'; + + +@Page({ + templateUrl: 'main.html' +}) +class E2EPage { + items = []; + + constructor() { + + for (var i = 0; i < 5000; i++) { + + this.items.push({ + isHeader: ((i % 10) === 0), + fontSize: Math.floor((Math.random() * 32) + 16) + 'px', + item: i + }); + + } + } + + headerFn(record, recordIndex) { + if (recordIndex > 0 && recordIndex % 100 === 0) { + return recordIndex; + } + return null; + } + +} + + +@App({ + template: '', +}) +class E2EApp { + root; + constructor() { + this.root = E2EPage; + } +} diff --git a/ionic/components/virtual-scroll/test/variable-size/main.html b/ionic/components/virtual-scroll/test/variable-size/main.html new file mode 100644 index 0000000000..2431c0e8a9 --- /dev/null +++ b/ionic/components/virtual-scroll/test/variable-size/main.html @@ -0,0 +1,17 @@ +Virtual Scroll: Variable Sizes + + + + + + + Header: {{ header }} + + + + {{ item.item }} + + + + + diff --git a/ionic/components/virtual-scroll/test/virtual-scroll.spec.ts b/ionic/components/virtual-scroll/test/virtual-scroll.spec.ts new file mode 100644 index 0000000000..3c2cc9e718 --- /dev/null +++ b/ionic/components/virtual-scroll/test/virtual-scroll.spec.ts @@ -0,0 +1,569 @@ +import {VirtualScroll} from '../../../../ionic/components/virtual-scroll/virtual-scroll'; +import {VirtualCell, VirtualData, VirtualNode} from '../../../../ionic/components/virtual-scroll/virtual-util'; +import {processRecords, populateNodeData, initReadNodes, getVirtualHeight, adjustRendered} from '../../../../ionic/components/virtual-scroll/virtual-util'; + + +export function run() { + +describe('VirtualScroll', () => { + + beforeEach(() => { + records = [0,1,2,3,4,5,6,7,8,9]; + cells = []; + nodes = []; + headerFn = null; + footerFn = null; + var viewportWidth = 300; + data = { + viewWidth: viewportWidth, + viewHeight: 600, + itmWidth: viewportWidth, + itmHeight: HEIGHT_ITEM, + hdrWidth: viewportWidth, + hdrHeight: HEIGHT_HEADER, + ftrWidth: viewportWidth, + ftrHeight: HEIGHT_FOOTER + }; + window.getComputedStyle = function(element) { + var styles: any = { + marginTop: '0px', + marginRight: '0px', + marginBottom: '0px', + marginLeft: '0px' + } + return styles; + }; + }); + + describe('processRecords', () => { + + it('should load data for 100% width items', () => { + records = [0,1,2,3,4] + let stopAtHeight = 200; + + processRecords(stopAtHeight, records, cells, + headerFn, footerFn, data); + + expect(cells.length).toBe(4); + + expect(cells[0].record).toBe(0); + expect(cells[0].row).toBe(0); + expect(cells[0].top).toBe(HEIGHT_ITEM * 0); + expect(cells[0].height).toBe(HEIGHT_ITEM); + expect(cells[0].data).toBeUndefined(); + expect(cells[0].tmpl).toBe(TEMPLATE_ITEM); + + expect(cells[1].row).toBe(1); + expect(cells[1].top).toBe(HEIGHT_ITEM * 1); + expect(cells[1].height).toBe(HEIGHT_ITEM); + + expect(cells[2].row).toBe(2); + expect(cells[2].top).toBe(HEIGHT_ITEM * 2); + expect(cells[2].height).toBe(HEIGHT_ITEM); + + expect(cells[3].row).toBe(3); + expect(cells[3].top).toBe(HEIGHT_ITEM * 3); + expect(cells[3].height).toBe(HEIGHT_ITEM); + }); + + it('should load data for 30% width items', () => { + records = [0,1,2,3,4]; + let stopAtHeight = 1000; + data.viewWidth = 300; + data.itmWidth = 90; // 30%, 3 per row + data.hdrWidth = data.viewWidth; // 100%, 1 per row + data.ftrWidth = data.viewWidth; // 100%, 1 per row + + headerFn = function(record) { + return (record === 0) ? 'Header' : null; + }; + + footerFn = function(record) { + return (record === 4) ? 'Footer' : null; + }; + + processRecords(stopAtHeight, records, cells, + headerFn, footerFn, data); + + expect(cells.length).toBe(7); + + expect(cells[0].row).toBe(0); + expect(cells[0].width).toBe(data.viewWidth); + expect(cells[0].height).toBe(HEIGHT_HEADER); + expect(cells[0].top).toBe(0); + expect(cells[0].left).toBe(0); + expect(cells[0].tmpl).toBe(TEMPLATE_HEADER); + expect(cells[0].data).toBe('Header'); + expect(cells[0].record).toBe(0); + + expect(cells[1].row).toBe(1); + expect(cells[1].width).toBe(data.itmWidth); + expect(cells[1].height).toBe(HEIGHT_ITEM); + expect(cells[1].top).toBe(HEIGHT_HEADER); + expect(cells[1].left).toBe(data.itmWidth * 0); + expect(cells[1].tmpl).toBe(TEMPLATE_ITEM); + expect(cells[1].data).toBeUndefined(); + expect(cells[1].record).toBe(0); + + expect(cells[2].row).toBe(1); + expect(cells[2].width).toBe(data.itmWidth); + expect(cells[2].height).toBe(HEIGHT_ITEM); + expect(cells[2].top).toBe(HEIGHT_HEADER); + expect(cells[2].left).toBe(data.itmWidth * 1); + expect(cells[2].tmpl).toBe(TEMPLATE_ITEM); + expect(cells[2].data).toBeUndefined(); + expect(cells[2].record).toBe(1); + + expect(cells[3].row).toBe(1); + expect(cells[3].width).toBe(data.itmWidth); + expect(cells[3].height).toBe(HEIGHT_ITEM); + expect(cells[3].top).toBe(HEIGHT_HEADER); + expect(cells[3].left).toBe(data.itmWidth * 2); + expect(cells[3].tmpl).toBe(TEMPLATE_ITEM); + expect(cells[3].data).toBeUndefined(); + expect(cells[3].record).toBe(2); + + expect(cells[4].row).toBe(2); + expect(cells[4].width).toBe(data.itmWidth); + expect(cells[4].height).toBe(HEIGHT_ITEM); + expect(cells[4].top).toBe(HEIGHT_HEADER + HEIGHT_ITEM); + expect(cells[4].left).toBe(data.itmWidth * 0); + expect(cells[4].tmpl).toBe(TEMPLATE_ITEM); + expect(cells[4].data).toBeUndefined(); + expect(cells[4].record).toBe(3); + + expect(cells[5].row).toBe(2); + expect(cells[5].width).toBe(data.itmWidth); + expect(cells[5].height).toBe(HEIGHT_ITEM); + expect(cells[5].top).toBe(HEIGHT_HEADER + HEIGHT_ITEM); + expect(cells[5].left).toBe(data.itmWidth * 1); + expect(cells[5].tmpl).toBe(TEMPLATE_ITEM); + expect(cells[5].data).toBeUndefined(); + expect(cells[5].record).toBe(4); + + expect(cells[6].row).toBe(3); + expect(cells[6].width).toBe(data.ftrWidth); + expect(cells[6].height).toBe(HEIGHT_FOOTER); + expect(cells[6].top).toBe(HEIGHT_HEADER + HEIGHT_ITEM + HEIGHT_ITEM); + expect(cells[6].left).toBe(0); + expect(cells[6].tmpl).toBe(TEMPLATE_FOOTER); + expect(cells[6].data).toBe('Footer'); + expect(cells[6].record).toBe(4); + }); + + it('should process more data', () => { + records = [0,1,2,3,4,5,6,7,8,9]; + let stopAtHeight = 100; + data.viewWidth = 200; + data.itmWidth = 90; // 2 per row + data.hdrWidth = data.viewWidth; // 100%, 1 per row + + headerFn = function(record) { + return (record === 0) ? 'Header' : null; + }; + + processRecords(stopAtHeight, records, cells, + headerFn, footerFn, data); + + expect(cells.length).toBe(5); + + expect(cells[0].row).toBe(0); + expect(cells[0].top).toBe(0); + expect(cells[0].left).toBe(0); + expect(cells[0].tmpl).toBe(TEMPLATE_HEADER); + expect(cells[0].record).toBe(0); + + expect(cells[1].row).toBe(1); + expect(cells[1].top).toBe(HEIGHT_HEADER); + expect(cells[1].left).toBe(data.itmWidth * 0); + expect(cells[1].tmpl).toBe(TEMPLATE_ITEM); + expect(cells[1].record).toBe(0); + + stopAtHeight = 150; + processRecords(stopAtHeight, records, cells, + headerFn, footerFn, data); + + expect(cells[2].row).toBe(1); + expect(cells[2].top).toBe(HEIGHT_HEADER); + expect(cells[2].left).toBe(data.itmWidth * 1); + expect(cells[2].tmpl).toBe(TEMPLATE_ITEM); + expect(cells[2].record).toBe(1); + + expect(cells.length).toBe(9); + + expect(cells[3].row).toBe(2); + expect(cells[3].top).toBe(HEIGHT_HEADER + HEIGHT_ITEM); + expect(cells[3].left).toBe(data.itmWidth * 0); + expect(cells[3].tmpl).toBe(TEMPLATE_ITEM); + expect(cells[3].record).toBe(2); + + stopAtHeight = 20000; + processRecords(stopAtHeight, records, cells, + headerFn, footerFn, data); + + expect(cells.length).toBe(11); + + expect(cells[5].row).toBe(3); + expect(cells[5].top).toBe(HEIGHT_HEADER + HEIGHT_ITEM + HEIGHT_ITEM); + expect(cells[5].left).toBe(data.itmWidth * 0); + expect(cells[5].tmpl).toBe(TEMPLATE_ITEM); + expect(cells[5].record).toBe(4); + + }); + + }); + + describe('populateNodeData', () => { + + it('should skip already rendered, and create nodes', () => { + cells = [ + {row: 0, tmpl: TEMPLATE_ITEM}, + {row: 1, tmpl: TEMPLATE_ITEM}, + {row: 2, tmpl: TEMPLATE_HEADER}, + {row: 3, tmpl: TEMPLATE_ITEM}, + {row: 4, tmpl: TEMPLATE_FOOTER}, + {row: 5, tmpl: TEMPLATE_ITEM}, + {row: 6, tmpl: TEMPLATE_ITEM, reads: 0} + ]; + + nodes = [ + {cell: 0, tmpl: TEMPLATE_ITEM, view: getView()}, + {cell: 1, tmpl: TEMPLATE_ITEM, view: getView()}, + {cell: 2, tmpl: TEMPLATE_HEADER, view: getView()}, + {cell: 3, tmpl: TEMPLATE_ITEM, view: getView()}, + ]; + + let startCellIndex = 2; + let endCellIndex = 5; + + populateNodeData(startCellIndex, endCellIndex, data.viewWidth, true, + cells, records, nodes, viewContainer, + itmTmp, hdrTmp, ftrTmp, false); + + expect(nodes.length).toBe(5); + + // first stays unchanged + expect(nodes[0].cell).toBe(0); + + expect(nodes[1].cell).toBe(5); + expect(nodes[2].cell).toBe(2); + expect(nodes[3].cell).toBe(3); + expect(nodes[4].cell).toBe(4); + }); + + it('should create nodes', () => { + cells = [ + {row: 0, tmpl: TEMPLATE_ITEM}, + {row: 1, tmpl: TEMPLATE_ITEM}, + {row: 2, tmpl: TEMPLATE_HEADER}, + {row: 3, tmpl: TEMPLATE_ITEM}, + {row: 4, tmpl: TEMPLATE_FOOTER}, + {row: 5, tmpl: TEMPLATE_ITEM}, + {row: 6, tmpl: TEMPLATE_ITEM} + ]; + + let startCellIndex = 2; + let endCellIndex = 4; + + populateNodeData(startCellIndex, endCellIndex, data.viewWidth, true, + cells, records, nodes, viewContainer, + itmTmp, hdrTmp, ftrTmp, true); + + expect(nodes.length).toBe(3); + + expect(nodes[0].cell).toBe(2); + expect(nodes[1].cell).toBe(3); + expect(nodes[2].cell).toBe(4); + + expect(nodes[0].tmpl).toBe(TEMPLATE_HEADER); + expect(nodes[1].tmpl).toBe(TEMPLATE_ITEM); + expect(nodes[2].tmpl).toBe(TEMPLATE_FOOTER); + }); + + }); + + describe('initReadNodes', () => { + + it('should get all the row heights w/ 30% width rows', () => { + let firstTop = 3; + nodes = [ + {cell: 0, tmpl: TEMPLATE_HEADER, view: getView(data.viewWidth, HEIGHT_HEADER, firstTop, 0)}, + {cell: 1, tmpl: TEMPLATE_ITEM, view: getView(90, HEIGHT_ITEM, HEIGHT_HEADER + firstTop, 0)}, + {cell: 2, tmpl: TEMPLATE_ITEM, view: getView(90, HEIGHT_ITEM, HEIGHT_HEADER + firstTop, 90)}, + {cell: 3, tmpl: TEMPLATE_ITEM, view: getView(90, HEIGHT_ITEM, HEIGHT_HEADER + firstTop, 180)}, + {cell: 4, tmpl: TEMPLATE_ITEM, view: getView(90, HEIGHT_ITEM, HEIGHT_HEADER + HEIGHT_ITEM + firstTop, 0)}, + {cell: 5, tmpl: TEMPLATE_FOOTER, view: getView(data.viewWidth, HEIGHT_FOOTER, HEIGHT_HEADER + HEIGHT_ITEM + HEIGHT_ITEM + firstTop, 0)}, + ]; + + cells = [ + {row: 0, tmpl: TEMPLATE_HEADER, reads: 0}, + {row: 0, tmpl: TEMPLATE_ITEM, reads: 0}, + {row: 0, tmpl: TEMPLATE_ITEM, reads: 0}, + {row: 0, tmpl: TEMPLATE_ITEM, reads: 0}, + {row: 0, tmpl: TEMPLATE_ITEM, reads: 0}, + {row: 0, tmpl: TEMPLATE_FOOTER, reads: 0}, + ]; + + initReadNodes(nodes, cells, data); + + expect(cells[0].top).toBe(firstTop); + expect(cells[0].left).toBe(0); + expect(cells[0].width).toBe(data.viewWidth); + expect(cells[0].height).toBe(HEIGHT_HEADER); + + expect(cells[1].top).toBe(firstTop + HEIGHT_HEADER); + expect(cells[1].left).toBe(0); + expect(cells[1].width).toBe(90); + expect(cells[1].height).toBe(HEIGHT_ITEM); + + expect(cells[2].top).toBe(firstTop + HEIGHT_HEADER); + expect(cells[2].left).toBe(data.itmWidth); + expect(cells[2].width).toBe(90); + expect(cells[2].height).toBe(HEIGHT_ITEM); + + expect(cells[3].top).toBe(firstTop + HEIGHT_HEADER); + expect(cells[3].left).toBe(data.itmWidth * 2); + expect(cells[3].width).toBe(90); + expect(cells[3].height).toBe(HEIGHT_ITEM); + + expect(cells[4].top).toBe(firstTop + HEIGHT_HEADER + HEIGHT_ITEM); + expect(cells[4].left).toBe(0); + expect(cells[4].width).toBe(90); + expect(cells[4].height).toBe(HEIGHT_ITEM); + + expect(cells[5].top).toBe(firstTop + HEIGHT_HEADER + HEIGHT_ITEM + HEIGHT_ITEM); + expect(cells[5].left).toBe(0); + expect(cells[5].width).toBe(data.viewWidth); + expect(cells[5].height).toBe(HEIGHT_FOOTER); + }); + + it('should get all the row heights w/ all 100% width rows', () => { + nodes = [ + {cell: 0, tmpl: TEMPLATE_HEADER, view: getView(data.viewWidth, HEIGHT_HEADER, 0, 0)}, + {cell: 1, tmpl: TEMPLATE_ITEM, view: getView(data.viewWidth, HEIGHT_ITEM, HEIGHT_HEADER, 0)}, + {cell: 2, tmpl: TEMPLATE_ITEM, view: getView(data.viewWidth, HEIGHT_ITEM, HEIGHT_HEADER + HEIGHT_ITEM, 0)}, + {cell: 3, tmpl: TEMPLATE_ITEM, view: getView(data.viewWidth, HEIGHT_ITEM, HEIGHT_HEADER + HEIGHT_ITEM + HEIGHT_ITEM, 0)}, + {cell: 4, tmpl: TEMPLATE_FOOTER, view: getView(data.viewWidth, HEIGHT_FOOTER, HEIGHT_HEADER + HEIGHT_ITEM + HEIGHT_ITEM + HEIGHT_ITEM, 0)}, + ]; + + cells = [ + {row: 0, tmpl: TEMPLATE_HEADER, reads: 0}, + {row: 1, tmpl: TEMPLATE_ITEM, reads: 0}, + {row: 2, tmpl: TEMPLATE_ITEM, reads: 0}, + {row: 3, tmpl: TEMPLATE_ITEM, reads: 0}, + {row: 4, tmpl: TEMPLATE_FOOTER, reads: 0}, + ]; + + initReadNodes(nodes, cells, data); + + expect(cells[0].top).toBe(0); + expect(cells[0].height).toBe(HEIGHT_HEADER); + expect(cells[0].reads).toBe(1); + + expect(cells[1].top).toBe(HEIGHT_HEADER); + expect(cells[1].height).toBe(HEIGHT_ITEM); + + expect(cells[2].top).toBe(HEIGHT_HEADER + HEIGHT_ITEM); + expect(cells[2].height).toBe(HEIGHT_ITEM); + + expect(cells[3].top).toBe(HEIGHT_HEADER + HEIGHT_ITEM + HEIGHT_ITEM); + expect(cells[3].height).toBe(HEIGHT_ITEM); + + expect(cells[4].top).toBe(HEIGHT_HEADER + HEIGHT_ITEM + HEIGHT_ITEM + HEIGHT_ITEM); + expect(cells[4].height).toBe(HEIGHT_FOOTER); + }); + + }); + + describe('adjustRendered', () => { + + it('should adjust when all the way to the top, scrolling down', () => { + for (var i = 0; i < 100; i++) { + cells.push({top: i, height: 1, row: i}); + } + data.scrollDiff = 1; + data.viewHeight = 20; + data.itmHeight = 1; + data.renderHeight = 40; + + data.topViewCell = 0; + data.bottomViewCell = 19; + + adjustRendered(cells, data); + + expect(data.topCell).toBe(0); + expect(data.bottomCell).toBe(38); + }); + + it('should adjust when in the middle, scrolling down', () => { + for (var i = 0; i < 100; i++) { + cells.push({top: i, height: 1, row: i}); + } + data.scrollDiff = 1; + data.viewHeight = 20; + data.itmHeight = 1; + data.renderHeight = 40; + + data.topViewCell = 30; + data.bottomViewCell = 49; + + adjustRendered(cells, data); + + expect(data.topCell).toBe(27); + expect(data.bottomCell).toBe(65); + }); + + it('should adjust when all the way to the bottom, scrolling down', () => { + for (var i = 0; i < 100; i++) { + cells.push({top: i, height: 1, row: i}); + } + data.scrollDiff = 1; + data.viewHeight = 20; + data.itmHeight = 1; + data.renderHeight = 40; + + data.topViewCell = 80; + data.bottomViewCell = 99; + + adjustRendered(cells, data); + + expect(data.topCell).toBe(77); + expect(data.bottomCell).toBe(99); + }); + + it('should adjust when all the way to the bottom, scrolling up', () => { + for (var i = 0; i < 100; i++) { + cells.push({top: i, height: 1, row: i}); + } + data.scrollDiff = -1; + data.viewHeight = 20; + data.itmHeight = 1; + data.renderHeight = 40; + + data.topViewCell = 80; + data.bottomViewCell = 99; + + adjustRendered(cells, data); + + expect(data.topCell).toBe(61); + expect(data.bottomCell).toBe(99); + }); + + }); + + describe('getVirtualHeight', () => { + + it('should return known height from last cell/record', () => { + let totalRecords = 1000; + let lastCell: VirtualCell = { + record: 999, + tmpl: TEMPLATE_ITEM, + row: 800, + top: 900, + height: 45 + }; + + let virtualHeight = getVirtualHeight(totalRecords, lastCell); + + expect(virtualHeight).toBe(945); + }); + + it('should guess the height from 1 known cell', () => { + let totalRecords = 100; + let lastCell: VirtualCell = { + record: 0, + tmpl: TEMPLATE_ITEM, + row: 0, + top: 0, + height: 50 + }; + + let virtualHeight = getVirtualHeight(totalRecords, lastCell); + + expect(virtualHeight).toBe(5000); + }); + + it('should guess the height from 1/2 known cells', () => { + let totalRecords = 100; + let lastCell: VirtualCell = { + record: 49, + tmpl: TEMPLATE_ITEM, + row: 0, + top: 2450, + height: 50 + }; + + let virtualHeight = getVirtualHeight(totalRecords, lastCell); + + expect(virtualHeight).toBe(5000); + }); + + it('should guess the height from 99/100 known cells', () => { + let totalRecords = 100; + let lastCell: VirtualCell = { + record: 98, + tmpl: TEMPLATE_ITEM, + row: 0, + top: 4900, + height: 50 + }; + + let virtualHeight = getVirtualHeight(totalRecords, lastCell); + + expect(virtualHeight).toBe(5000); + }); + + }); + + let records: any[]; + let cells: VirtualCell[]; + let nodes: VirtualNode[]; + let headerFn: Function; + let footerFn: Function; + let data: VirtualData; + let itmTmp = null; + let hdrTmp = null; + let ftrTmp = null; + let viewContainer: any = { + createEmbeddedView: function() { + return getView(); + }, + indexOf: function() { + return 0; + } + }; + + function getView(width?:number, height?: number, top?: number, left?: number): any { + return { + setLocal: function(){}, + rootNodes: [{ + nodeType: 1, + offsetWidth: width, + offsetHeight: height, + offsetTop: top, + offsetLeft: left, + style: { + top: '', + left: '' + }, + classList: { + add: function(){}, + remove: function(){} + }, + setAttribute: function(){}, + innerHTML: '', + }] + } + } + +}); + +} + +const TEMPLATE_ITEM = 0; +const TEMPLATE_HEADER = 1; +const TEMPLATE_FOOTER = 2; + +const HEIGHT_HEADER = 50; +const HEIGHT_ITEM = 45; +const HEIGHT_FOOTER = 32; diff --git a/ionic/components/virtual-scroll/virtual-item.ts b/ionic/components/virtual-scroll/virtual-item.ts new file mode 100644 index 0000000000..4e40e5bc41 --- /dev/null +++ b/ionic/components/virtual-scroll/virtual-item.ts @@ -0,0 +1,28 @@ +import {Directive, TemplateRef, ViewContainerRef} from 'angular2/core'; + + +/** + * @private + */ +@Directive({selector: '[virtualHeader]'}) +export class VirtualHeader { + constructor(public templateRef: TemplateRef) {} +} + + +/** + * @private + */ +@Directive({selector: '[virtualFooter]'}) +export class VirtualFooter { + constructor(public templateRef: TemplateRef) {} +} + + +/** + * @private + */ +@Directive({selector: '[virtualItem]'}) +export class VirtualItem { + constructor(public templateRef: TemplateRef, public viewContainer: ViewContainerRef) {} +} diff --git a/ionic/components/virtual-scroll/virtual-scroll.scss b/ionic/components/virtual-scroll/virtual-scroll.scss new file mode 100644 index 0000000000..9832cfb745 --- /dev/null +++ b/ionic/components/virtual-scroll/virtual-scroll.scss @@ -0,0 +1,22 @@ + +// Virtual Scroll +// -------------------------------------------------- + + +.virtual-scroll { + position: relative; +} + +.virtual-scroll .virtual-position, +.virtual-scroll .virtual-position.item { + position: absolute; + + top: 0; + left: 0; + + transition-duration: 0ms; +} + +.virtual-scroll .virtual-hidden { + display: none; +} diff --git a/ionic/components/virtual-scroll/virtual-scroll.ts b/ionic/components/virtual-scroll/virtual-scroll.ts new file mode 100644 index 0000000000..adeae79ae8 --- /dev/null +++ b/ionic/components/virtual-scroll/virtual-scroll.ts @@ -0,0 +1,652 @@ +import {Directive, ContentChild, ContentChildren, QueryList, IterableDiffers, IterableDiffer, TrackByFn, Input, Optional, Renderer, ElementRef, ChangeDetectorRef, NgZone, TemplateRef, ViewContainerRef, DoCheck, AfterContentInit, OnDestroy} from 'angular2/core'; + +import {Config} from '../../config/config'; +import {Content} from '../content/content'; +import {Platform} from '../../platform/platform'; +import {ViewController} from '../nav/view-controller'; +import {VirtualItem, VirtualHeader, VirtualFooter} from './virtual-item'; +import {VirtualCell, VirtualNode, VirtualData} from './virtual-util'; +import {processRecords, populateNodeData, initReadNodes, writeToNodes, updateDimensions, adjustRendered, calcDimensions, estimateHeight} from './virtual-util'; +import {isBlank, isPresent, isFunction} from '../../util/util'; +import {rafFrames, raf, cancelRaf, pointerCoord, nativeTimeout, clearNativeTimeout} from '../../util/dom'; +import {Img} from '../img/img'; + + +/** + * @name VirtualScroll + * @description + * Virtual scroll allows an app to render large lists of items more + * performantly than `ngFor`. The difference is that virtual scroll + * only renders a small amount of elements within the DOM, relative to + * the actual number of items within the dataset. + * + * Basically, instead of rendering potentionally thousands of elements + * within the DOM, it'll only render the few that are currently viewable + * (and a few extra for good measure). Not only does it render item data, + * but it can also dynamically insert section headers and footers based + * off of user-provided functions. + * + * + * ### The Basics + * + * The data given to the `virtualScroll` property must be an array. Note + * that the `virtualScroll` property can be added to any element, not + * just `ion-list`. Next, within the virtual scroll directive you must + * provide an item template, using the `*virtualItem` attribute. + * + * ```html + * + * + * + * {{ item }} + * + * + * + * ``` + * + * + * ### Section Headers and Footers + * + * Section headers and footers, and the data used within their given + * templates, can be dynamically created using custom user-defined functions. + * For example, a large list of contacts usually has dividers between each + * letter in the alphabet. App's can provide their own custom function + * which is called on each record within the dataset. The logic within + * the custom functions can decide if a section template should be used, + * and what data to provide to the template. The custom function must + * return `null` if a template shouldn't be created. + * + * ```html + * + * + * + * Header: {{ header }} + * + * + * + * Item: {{ item }} + * + * + * + * ``` + * + * Below is the user-defined function called on every record. Its + * arguments are passed the individual record, the record's index number, + * and the entire record dataset (think `Array.forEach`). In this example, + * after every 20 items a header will be inserted. So between the 19th + * and 20th records, between the 39th and 40th, and so on, a + * `` will be created and the template's data will come + * from the function's returned data. + * + * ```ts + * myHeaderFn(record, recordIndex, records) { + * if (recordIndex % 20 === 0) { + * return 'Header ' + recordIndex; + * } + * return null; + * } + * ``` + * + * + * ### Approximate Widths and Heights + * + * The approximate width and height of each template is used to help + * determine how many cells should be created, and to help calculate + * the height of the scrollable area. Note that the actual rendered size + * of each cell comes from the app's CSS, whereas this approximation + * is only used to help calculate initial dimensions. + * + * It's also important to know that Ionic's default item sizes have + * slightly different heights between platforms, which is perfectly fine. + * An exact pixel-perfect size is not necessary, but a good estimation + * is important. Basically if each item is roughly 500px tall, rather than + * the default of 40px tall, that's extremely important to know for virtual + * scroll to calculate a good height. + * + * + * ### Images Within Virtual Scroll + * + * With images, the moment the `` tag hits the DOM, it immediately + * makes a HTTP request for the image file in the `src` attribute. HTTP + * requests, along with image decoding and image rendering, are great + * sources of scroll jank. For virtual scrolling and these poor performance + * implications, the natural effect of the `` are not a desirable + * features. A user's device shouldn't be firing up hundreds of + * HTTP requests, image decoding and rendering, when they're mostly unnecessary + * as the user scrolls pass many of them. + * + * Ionic provides `` so it can better manage HTTP requests and rendering. + * Additionally, it includes a customizable placeholder element which shows + * before the image has finished loading. While scrolling through items + * quickly, `` knows not to make any images requests, and only loads + * the images that are viewable after scrolling. It's also important for app + * developers to ensure image sizes are locked in, and after images have fully + * loaded they do not change size and affect any other element sizes. + * + * ```html + * + * + * + * + * + * + * {{ item.firstName }} {{ item.lastName }} + * + * + * + * ``` + * + * + * ### Performance Tips + * + * - Use `` rather than `` so images are lazy loaded + * while scrolling. + * - Image sizes should be locked in, meaning the size of any element + * should not change after the image has loaded. + * - Provide an approximate width and height so the virtual scroll can + * best calculate its height. + * - Changing the dataset requires the entire virtual scroll to be + * reset, which is an expensive operation and should be avoided + * if possible. + * - Do not performan any DOM manipulation within section header and + * footer functions. These functions are called for every record in the + * dataset, so please make sure they're performant. + * + */ +@Directive({ + selector: '[virtualScroll]' +}) +export class VirtualScroll implements DoCheck, AfterContentInit, OnDestroy { + private _trackBy: TrackByFn; + private _differ: IterableDiffer; + private _unreg: Function; + private _init: boolean; + private _rafId: number; + private _tmId: number; + private _hdrFn: Function; + private _ftrFn: Function; + private _records: any[] = []; + private _cells: VirtualCell[] = []; + private _nodes: VirtualNode[] = []; + private _vHeight: number = 0; + private _lastCheck: number = 0; + private _data: VirtualData = { + scrollTop: 0, + }; + private _eventAssist: boolean; + private _queue: number = null; + + @ContentChild(VirtualItem) private _itmTmp: VirtualItem; + @ContentChild(VirtualHeader) private _hdrTmp: VirtualHeader; + @ContentChild(VirtualFooter) private _ftrTmp: VirtualFooter; + @ContentChildren(Img) private _imgs: QueryList; + + /** + * @input {array} The data that builds the items within the virtual scroll. + * This as the same data that you'd pass to `ngFor`. It's important to note + * that when this data has changed, then the entire virtual scroll is reset, + * which is an expensive operation and should be avoided if possible. + */ + @Input() + set virtualScroll(val: any) { + this._records = val; + if (isBlank(this._differ) && isPresent(val)) { + this._differ = this._iterableDiffers.find(val).create(this._cd, this._trackBy); + } + } + + /** + * @input {number} The buffer ratio is used to decide how many cells + * should get created when initially rendered. The number is a + * multipler against the viewable area's height. For example, if it + * takes `20` cells to fill up the height of the viewable area, then + * with a buffer ratio of `2` it'll create `40` cells that are + * available for reuse while scrolling. For better performance, it's + * better to have more cells than what are required to fill the + * viewable area. Default is `2`. + */ + @Input() bufferRatio: number = 2; + + /** + * @input {string} The approximate width of each item template's cell. + * This dimension is used to help determine how many cells should + * be created when initialized, and to help calculate the height of + * the scrollable area. This value can use either `px` or `%` units. + * Note that the actual rendered size of each cell comes from the + * app's CSS, whereas this approximation is used to help calculate + * initial dimensions. Default is `100%`. + */ + @Input() approxItemWidth: string = '100%'; + + /** + * @input {string} The approximate height of each item template's cell. + * This dimension is used to help determine how many cells should + * be created when initialized, and to help calculate the height of + * the scrollable area. This height value can only use `px` units. + * Note that the actual rendered size of each cell comes from the + * app's CSS, whereas this approximation is used to help calculate + * initial dimensions. Default is `40px`. + */ + @Input() approxItemHeight: string = '40px'; + + /** + * @input {string} The approximate width of each header template's cell. + * This dimension is used to help determine how many cells should + * be created when initialized, and to help calculate the height of + * the scrollable area. This value can use either `px` or `%` units. + * Note that the actual rendered size of each cell comes from the + * app's CSS, whereas this approximation is used to help calculate + * initial dimensions. Default is `100%`. + */ + @Input() approxHeaderWidth: string = '100%'; + + /** + * @input {string} The approximate height of each header template's cell. + * This dimension is used to help determine how many cells should + * be created when initialized, and to help calculate the height of + * the scrollable area. This height value can only use `px` units. + * Note that the actual rendered size of each cell comes from the + * app's CSS, whereas this approximation is used to help calculate + * initial dimensions. Default is `40px`. + */ + @Input() approxHeaderHeight: string = '40px'; + + /** + * @input {string} The approximate width of each footer template's cell. + * This dimension is used to help determine how many cells should + * be created when initialized, and to help calculate the height of + * the scrollable area. This value can use either `px` or `%` units. + * Note that the actual rendered size of each cell comes from the + * app's CSS, whereas this approximation is used to help calculate + * initial dimensions. Default is `100%`. + */ + @Input() approxFooterWidth: string = '100%'; + + /** + * @input {string} The approximate height of each footer template's cell. + * This dimension is used to help determine how many cells should + * be created when initialized, and to help calculate the height of + * the scrollable area. This height value can only use `px` units. + * Note that the actual rendered size of each cell comes from the + * app's CSS, whereas this approximation is used to help calculate + * initial dimensions. Default is `40px`. + */ + @Input() approxFooterHeight: string = '40px'; + + /** + * @input {function} Section headers and the data used within its given + * template can be dynamically created by passing a function to `headerFn`. + * For example, a large list of contacts usually has dividers between each + * letter in the alphabet. App's can provide their own custom `headerFn` + * which is called with each record within the dataset. The logic within + * the header function can decide if the header template should be used, + * and what data to give to the header template. The function must return + * `null` if a header cell shouldn't be created. + */ + @Input() set headerFn(val: Function) { + if (isFunction(val)) { + this._hdrFn = val.bind((this._ctrl && this._ctrl.instance) || this); + } + } + + /** + * @input {function} Section footers and the data used within its given + * template can be dynamically created by passing a function to `footerFn`. + * The logic within the footer function can decide if the footer template + * should be used, and what data to give to the footer template. The function + * must return `null` if a footer cell shouldn't be created. + */ + @Input() set footerFn(val: Function) { + if (isFunction(val)) { + this._ftrFn = val.bind((this._ctrl && this._ctrl.instance) || this); + } + } + + /** + * @input {function} Same as `ngForTrackBy` which can be used on `ngFor`. + */ + @Input() set virtualTrackBy(val: TrackByFn) { + this._trackBy = val; + } + + constructor( + private _iterableDiffers: IterableDiffers, + private _elementRef: ElementRef, + private _renderer: Renderer, + private _zone: NgZone, + private _cd: ChangeDetectorRef, + private _content: Content, + private _platform: Platform, + @Optional() private _ctrl: ViewController, + config: Config) { + this._eventAssist = config.getBoolean('virtualScrollEventAssist'); + } + + /** + * @private + */ + ngDoCheck() { + if (this._init) { + this.update(true); + } + } + + /** + * @private + */ + ngAfterContentInit() { + if (!this._init) { + + if (!this._itmTmp) { + throw 'virtualItem required within virtualScroll'; + } + + this.update(true); + + this._platform.onResize(() => { + console.debug('VirtualScroll, onResize'); + this.update(false); + }); + } + } + + /** + * @private + * DOM READ THEN DOM WRITE + */ + update(checkChanges: boolean) { + var self = this; + + if (!self._records || !self._records.length) 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); + + // 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++; + raf(function() { + readDimensions(done); + }); + } + } + } + + // ******** DOM READ **************** + readDimensions(function() { + // we were able to read good DOM dimension data, let's do this! + self._init = true; + + processRecords(self._data.renderHeight, + self._records, + self._cells, + self._hdrFn, + self._ftrFn, + self._data); + + // ******** DOM WRITE **************** + self.renderVirtual(); + + // ******** DOM WRITE **************** + self._renderer.setElementClass(self._elementRef.nativeElement, 'virtual-scroll', true); + + // list for scroll events + self.addScrollListener(); + }); + } + + /** + * @private + * DOM WRITE + */ + renderVirtual() { + // initialize nodes with the correct cell data + this._data.topCell = 0; + this._data.bottomCell = (this._cells.length - 1); + + populateNodeData(0, this._data.bottomCell, + this._data.viewWidth, true, + this._cells, this._records, this._nodes, + this._itmTmp.viewContainer, + this._itmTmp.templateRef, + this._hdrTmp && this._hdrTmp.templateRef, + this._ftrTmp && this._ftrTmp.templateRef, true); + + // ******** DOM WRITE **************** + this._cd.detectChanges(); + + // wait a frame before trying to read and calculate the dimensions + raf(this.postRenderVirtual.bind(this)); + } + + /** + * @private + * DOM READ THEN DOM WRITE + */ + postRenderVirtual() { + // ******** DOM READ **************** + calcDimensions(this._data, this._elementRef.nativeElement.parentElement, + this.approxItemWidth, this.approxItemHeight, + this.approxHeaderWidth, this.approxHeaderHeight, + this.approxFooterWidth, this.approxFooterHeight, + this.bufferRatio); + + // ******** DOM READ THEN DOM WRITE **************** + initReadNodes(this._nodes, this._cells, this._data); + + + // ******** DOM READS ABOVE / DOM WRITES BELOW **************** + + + // ******** DOM WRITE **************** + writeToNodes(this._nodes, this._cells, this._records.length); + + // ******** 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); + + let data = this._data; + + if (this._queue === QUEUE_CHANGE_DETECTION) { + // ******** DOM WRITE **************** + let node: VirtualNode; + for (var i = 0; i < this._nodes.length; i++) { + node = this._nodes[i]; + if (node.hasChanges) { + node.view['changeDetectorRef'].detectChanges(); + node.hasChanges = false; + } + } + + if (this._eventAssist) { + // queue updating node positions in the next frame + this._queue = QUEUE_WRITE_TO_NODES; + + } else { + // update node positions right now + // ******** DOM WRITE **************** + writeToNodes(this._nodes, this._cells, this._records.length); + this._queue = null; + } + + // ******** DOM WRITE **************** + this.setVirtualHeight( + estimateHeight(this._records.length, this._cells[this._cells.length - 1], this._vHeight, 0.25) + ); + + } else if (this._queue === QUEUE_WRITE_TO_NODES) { + // ******** DOM WRITE **************** + writeToNodes(this._nodes, this._cells, this._records.length); + this._queue = null; + + } else { + + data.scrollDiff = (data.scrollTop - this._lastCheck); + + if (Math.abs(data.scrollDiff) > 10) { + // don't bother updating if the scrollTop hasn't changed much + this._lastCheck = data.scrollTop; + + if (data.scrollDiff > 0) { + // load data we may not have processed yet + let stopAtHeight = (data.scrollTop + data.renderHeight); + + processRecords(stopAtHeight, this._records, this._cells, + this._hdrFn, this._ftrFn, data); + } + + // ******** DOM READ **************** + updateDimensions(this._nodes, this._cells, data, false); + + adjustRendered(this._cells, data); + + 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); + + if (madeChanges) { + // do not update images while scrolling + this._imgs.toArray().forEach(img => { + img.enable(false); + }); + + // queue making updates in the next frame + this._queue = QUEUE_CHANGE_DETECTION; + + } else { + this._queue = null; + } + } + + } + } + + /** + * @private + * DOM WRITE + */ + onScrollEnd() { + // scrolling is done, allow images to be updated now + this._imgs.toArray().forEach(img => { + img.enable(true); + }); + + // ******** DOM READ **************** + updateDimensions(this._nodes, this._cells, this._data, false); + + adjustRendered(this._cells, this._data); + + // ******** DOM WRITE **************** + this._cd.detectChanges(); + + // ******** DOM WRITE **************** + this.setVirtualHeight( + estimateHeight(this._records.length, this._cells[this._cells.length - 1], this._vHeight, 0.05) + ); + } + /** + * @private + * DOM WRITE + */ + setVirtualHeight(newVirtualHeight: number) { + if (newVirtualHeight !== this._vHeight) { + // ******** DOM WRITE **************** + this._renderer.setElementStyle(this._elementRef.nativeElement, 'height', newVirtualHeight > 0 ? newVirtualHeight + 'px' : ''); + this._vHeight = newVirtualHeight; + console.debug('VirtualScroll, height', newVirtualHeight); + } + } + + /** + * @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); + + } else { + // listen to native scroll events + self._unreg = self._content.addScrollListener(onScroll); + } + + }); + } + } + + /** + * @private + * NO DOM + */ + ngOnDestroy() { + this._unreg && this._unreg(); + this._unreg = null; + } + +} + +const SCROLL_END_TIMEOUT_MS = 140; + +const QUEUE_CHANGE_DETECTION = 0; +const QUEUE_WRITE_TO_NODES = 1; diff --git a/ionic/components/virtual-scroll/virtual-util.ts b/ionic/components/virtual-scroll/virtual-util.ts new file mode 100644 index 0000000000..fd4cbcf4dc --- /dev/null +++ b/ionic/components/virtual-scroll/virtual-util.ts @@ -0,0 +1,666 @@ +import {Directive, Input, ViewContainerRef, TemplateRef, EmbeddedViewRef} from 'angular2/core'; + +import {CSS} from '../../util/dom'; + + +/** + * NO DOM + */ +export function processRecords(stopAtHeight: number, + records: any[], cells: VirtualCell[], + headerFn: Function, footerFn: Function, + data: VirtualData) { + let record: any; + let startRecordIndex: number; + let previousCell: VirtualCell; + let tmpData: any; + let lastRecordIndex = (records.length - 1); + + if (cells.length) { + // we already have cells + previousCell = cells[ cells.length - 1]; + if (previousCell.top + previousCell.height > stopAtHeight) { + return; + } + startRecordIndex = (previousCell.record + 1); + + } else { + // no cells have been created yet + previousCell = { + row: 0, + width: 0, + height: 0, + top: 0, + left: 0, + tmpl: -1 + }; + startRecordIndex = 0; + } + + let processedTotal = 0; + + for (var recordIndex = startRecordIndex; recordIndex <= lastRecordIndex; recordIndex++) { + record = records[recordIndex]; + + if (headerFn) { + tmpData = headerFn(record, recordIndex, records); + + if (tmpData !== null) { + // add header data + previousCell = addCell(previousCell, recordIndex, TEMPLATE_HEADER, tmpData, + data.hdrWidth, data.hdrHeight, data.viewWidth); + cells.push(previousCell); + } + } + + // add item data + previousCell = addCell(previousCell, recordIndex, TEMPLATE_ITEM, null, + data.itmWidth, data.itmHeight, data.viewWidth); + cells.push(previousCell); + + if (footerFn) { + tmpData = footerFn(record, recordIndex, records); + + if (tmpData !== null) { + // add footer data + previousCell = addCell(previousCell, recordIndex, TEMPLATE_FOOTER, tmpData, + data.ftrWidth, data.ftrHeight, data.viewWidth); + cells.push(previousCell); + } + } + + if (previousCell.record === lastRecordIndex) { + previousCell.isLast = true; + } + + // should always process at least 3 records + processedTotal++; + + if (previousCell.top + previousCell.height + data.itmHeight > stopAtHeight && processedTotal > 3) { + return; + } + + } + +} + + +function addCell(previousCell: VirtualCell, recordIndex: number, tmpl: number, tmplData: any, + cellWidth: number, cellHeight: number, viewportWidth: number) { + let newCell: VirtualCell; + + if (previousCell.left + previousCell.width + cellWidth > viewportWidth) { + // add a new cell in a new row + newCell = { + record: recordIndex, + tmpl: tmpl, + row: (previousCell.row + 1), + width: cellWidth, + height: cellHeight, + top: (previousCell.top + previousCell.height), + left: 0, + reads: 0, + }; + + } else { + // add a new cell in the same row + newCell = { + record: recordIndex, + tmpl: tmpl, + row: previousCell.row, + width: cellWidth, + height: cellHeight, + top: previousCell.top, + left: (previousCell.left + previousCell.width), + reads: 0, + }; + } + + if (tmplData) { + newCell.data = tmplData; + } + + return newCell; +} + + +/** + * NO DOM + */ +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, + initialLoad: boolean): boolean { + let madeChanges = false; + let node: VirtualNode; + let availableNode: VirtualNode; + let cell: VirtualCell; + let previousCell: VirtualCell; + let isAlreadyRendered: boolean; + let lastRecordIndex = (records.length - 1); + let viewInsertIndex: number = null; + let totalNodes = nodes.length; + + startCellIndex = Math.max(startCellIndex, 0); + endCellIndex = Math.min(endCellIndex, cells.length - 1); + + for (var cellIndex = startCellIndex; cellIndex <= endCellIndex; cellIndex++) { + cell = cells[cellIndex]; + availableNode = null; + isAlreadyRendered = false; + + // find the first one that's available + if (!initialLoad) { + for (var i = 0; i < totalNodes; i++) { + node = nodes[i]; + + if (cell.tmpl !== node.tmpl || i === 0 && cellIndex !== 0) { + // the cell must use the correct template + // 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) { + isAlreadyRendered = true; + break; + } + + if (node.cell < startCellIndex || node.cell > endCellIndex) { + + if (!availableNode) { + // havent gotten an available node yet + availableNode = nodes[i]; + + } else if (scrollingDown) { + // scrolling down + if (node.cell < availableNode.cell) { + availableNode = nodes[i]; + } + + } else { + // scrolling up + if (node.cell > availableNode.cell) { + availableNode = nodes[i]; + } + } + } + } + + if (isAlreadyRendered) { + continue; + } + } + + if (!availableNode) { + // did not find an available node to put the cell data into + // insert a new node before the last record nodes + if (viewInsertIndex === null) { + viewInsertIndex = -1; + for (var j = totalNodes - 1; j >= 0; j--) { + node = nodes[j]; + if (node && !node.isLastRecord) { + viewInsertIndex = viewContainer.indexOf(node.view); + break; + } + } + } + + availableNode = { + tmpl: cell.tmpl, + view: viewContainer.createEmbeddedView( + cell.tmpl === TEMPLATE_HEADER ? hdrTmp : + cell.tmpl === TEMPLATE_FOOTER ? ftrTmp : + itmTmp, + viewInsertIndex + ) + }; + + totalNodes = nodes.push(availableNode); + //console.debug(`VirtrualScroll, new node, tmpl ${cell.tmpl}, height ${cell.height}`); + } + + //console.debug(`node was cell ${availableNode.cell} but is now ${cellIndex}, was top: ${cell.top}`); + + // assign who's the new cell index for this node + availableNode.cell = cellIndex; + + // apply the cell's data to this node + availableNode.view.setLocal('\$implicit', cell.data || records[cell.record]); + availableNode.view.setLocal('index', cellIndex); + availableNode.view.setLocal('even', (cellIndex % 2 == 0)); + availableNode.view.setLocal('odd', (cellIndex % 2 == 1)); + availableNode.hasChanges = true; + availableNode.lastTransform = null; + madeChanges = true; + } + + if (initialLoad) { + // add nodes that go at the very end, and only represent the last record + addLastNodes(nodes, viewContainer, TEMPLATE_HEADER, hdrTmp); + addLastNodes(nodes, viewContainer, TEMPLATE_ITEM, itmTmp); + addLastNodes(nodes, viewContainer, TEMPLATE_FOOTER, ftrTmp); + } + + return madeChanges; +} + + +function addLastNodes(nodes: VirtualNode[], viewContainer: ViewContainerRef, + templateType: number, templateRef: TemplateRef) { + if (templateRef) { + let node: VirtualNode = { + tmpl: templateType, + view: viewContainer.createEmbeddedView(templateRef), + isLastRecord: true, + hidden: true, + }; + node.view.setLocal('\$implicit', {}); + nodes.push(node); + } +} + + +/** + * DOM READ THEN DOM WRITE + */ +export function initReadNodes(nodes: VirtualNode[], cells: VirtualCell[], data: VirtualData) { + if (nodes.length && cells.length) { + // first node + // ******** DOM READ **************** + cells[0].top = getElement(nodes[0]).offsetTop; + cells[0].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'); + } + } + } +} + + +/** + * DOM READ + */ +export function updateDimensions(nodes: VirtualNode[], cells: VirtualCell[], data: VirtualData, initialUpdate: boolean) { + let node: VirtualNode; + let element: HTMLElement; + let totalCells = cells.length; + let cell: VirtualCell; + let previousCell: VirtualCell; + + 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) { + element = getElement(node); + + // ******** DOM READ **************** + readElements(cell, element); + + if (initialUpdate) { + // update estimated dimensions with more accurate dimensions + if (cell.tmpl === TEMPLATE_HEADER) { + data.hdrHeight = cell.height; + if (cell.left === 0) { + data.hdrWidth = cell.width; + } + + } else if (cell.tmpl === TEMPLATE_FOOTER) { + data.ftrHeight = cell.height; + if (cell.left === 0) { + data.ftrWidth = cell.width; + } + + } else { + data.itmHeight = cell.height; + if (cell.left === 0) { + data.itmWidth = cell.width; + } + } + } + + cell.reads++; + } + } + + // figure out which cells are currently viewable within the viewport + let viewableBottom = (data.scrollTop + data.viewHeight); + data.topViewCell = totalCells; + data.bottomViewCell = 0; + + // completely realign position to ensure they're all accurately placed + for (var i = 1; i < totalCells; i++) { + cell = cells[i]; + previousCell = cells[i - 1]; + + if (previousCell.left + previousCell.width + cell.width > data.viewWidth) { + // new row + cell.row++; + cell.top = (previousCell.top + previousCell.height); + cell.left = 0; + + } else { + // same row + cell.row = previousCell.row; + cell.top = previousCell.top; + cell.left = (previousCell.left + previousCell.width); + } + + // figure out which cells are viewable within the viewport + if (cell.top + cell.height > data.scrollTop && i < data.topViewCell) { + data.topViewCell = i; + + } else if (cell.top < viewableBottom && i > data.bottomViewCell) { + data.bottomViewCell = i; + } + } +} + + +/** + * DOM READ + */ +function readElements(cell: VirtualCell, element: HTMLElement) { + // ******** DOM READ **************** + let styles = window.getComputedStyle(element); + + // ******** DOM READ **************** + cell.left = (element.offsetLeft - parseFloat(styles.marginLeft)); + + // ******** DOM READ **************** + cell.width = (element.offsetWidth + parseFloat(styles.marginLeft) + parseFloat(styles.marginRight)); + + // ******** DOM READ **************** + cell.height = (element.offsetHeight + parseFloat(styles.marginTop) + parseFloat(styles.marginBottom)); +} + + +/** + * DOM WRITE + */ +export function writeToNodes(nodes: VirtualNode[], cells: VirtualCell[], totalRecords: number) { + let node: VirtualNode; + let element: HTMLElement; + let cell: VirtualCell; + let totalCells = Math.max(totalRecords, cells.length).toString(); + let transform: string; + + for (var i = 0, ilen = nodes.length; i < ilen; i++) { + node = nodes[i]; + + if (node.hidden) { + continue; + } + + cell = cells[node.cell]; + + transform = `translate3d(${cell.left}px,${cell.top}px,0px)`; + + if (node.lastTransform === transform) { + continue; + } + + element = getElement(node); + + if (element) { + // ******** DOM WRITE **************** + element.style[CSS.transform] = node.lastTransform = transform; + + // ******** DOM WRITE **************** + element.classList.add('virtual-position'); + + if (node.isLastRecord) { + // its the last record, now with data and safe to show + // ******** DOM WRITE **************** + element.classList.remove('virtual-hidden'); + } + + // 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-setsize + // ******** DOM WRITE **************** + element.setAttribute('aria-setsize', totalCells); + } + } +} + + +/** + * NO DOM + */ +export function adjustRendered(cells: VirtualCell[], data: VirtualData) { + // figure out which cells should be rendered + let cell: VirtualCell; + let lastRow = -1; + let cellsRenderHeight = 0; + let maxRenderHeight = (data.renderHeight - data.itmHeight); + let totalCells = cells.length; + let viewableRenderedPadding = (data.itmHeight < 90 ? VIEWABLE_RENDERED_PADDING : 0); + + if (data.scrollDiff > 0) { + // scrolling down + data.topCell = Math.max(data.topViewCell - viewableRenderedPadding, 0); + data.bottomCell = Math.min(data.topCell + 2, totalCells - 1); + + for (var i = data.topCell; i < totalCells; i++) { + cell = cells[i]; + if (cell.row !== lastRow) { + cellsRenderHeight += cell.height; + lastRow = cell.row; + } + + if (i > data.bottomCell) { + data.bottomCell = i; + } + + if (cellsRenderHeight >= maxRenderHeight) { + break; + } + } + + } else { + // scroll up + data.bottomCell = Math.min(data.bottomViewCell + viewableRenderedPadding, totalCells - 1); + data.topCell = Math.max(data.bottomCell - 2, 0); + + for (var i = data.bottomCell; i >= 0; i--) { + cell = cells[i]; + if (cell.row !== lastRow) { + cellsRenderHeight += cell.height; + lastRow = cell.row; + } + + if (i < data.topCell) { + data.topCell = i; + } + + if (cellsRenderHeight >= maxRenderHeight) { + break; + } + } + } + + //console.log(`adjustRendered topCell: ${data.topCell}, bottomCell: ${data.bottomCell}, cellsRenderHeight: ${cellsRenderHeight}, data.renderHeight: ${data.renderHeight}`); +} + + +/** + * NO DOM + */ +export function getVirtualHeight(totalRecords: number, lastCell: VirtualCell): number { + if (lastCell.record >= totalRecords - 1) { + return (lastCell.top + lastCell.height); + } + + let unknownRecords = (totalRecords - lastCell.record - 1); + let knownHeight = (lastCell.top + lastCell.height); + + return Math.ceil(knownHeight + ((knownHeight / (totalRecords - unknownRecords)) * unknownRecords)); +} + + +/** + * NO DOM + */ +export function estimateHeight(totalRecords: number, lastCell: VirtualCell, existingHeight: number, difference: number): number { + let newHeight = getVirtualHeight(totalRecords, lastCell); + + let percentToBottom = (lastCell.record / (totalRecords - 1)); + let diff = Math.abs(existingHeight - newHeight); + + if ((diff > (newHeight * difference)) || + (percentToBottom > .995)) { + return newHeight; + } + + return existingHeight; +} + + +/** + * DOM READ + */ +export function calcDimensions(data: VirtualData, + viewportElement: HTMLElement, + approxItemWidth: string, approxItemHeight: string, + appoxHeaderWidth: string, approxHeaderHeight: string, + approxFooterWidth: string, approxFooterHeight: string, + bufferRatio: number) { + + // get the parent container's viewport height + // ******** DOM READ **************** + data.viewWidth = viewportElement.offsetWidth; + + // ******** DOM READ **************** + data.viewHeight = viewportElement.offsetHeight; + + // the height we'd like to render, which is larger than viewable + data.renderHeight = (data.viewHeight * bufferRatio); + + if (data.viewWidth > 0 && data.viewHeight > 0) { + data.itmWidth = calcWidth(data.viewWidth, approxItemWidth); + data.itmHeight = calcHeight(data.viewHeight, approxItemHeight); + data.hdrWidth = calcWidth(data.viewWidth, appoxHeaderWidth); + data.hdrHeight = calcHeight(data.viewHeight, approxHeaderHeight); + data.ftrWidth = calcWidth(data.viewWidth, approxFooterWidth); + data.ftrHeight = calcHeight(data.viewHeight, approxFooterHeight); + + data.valid = true; + } +} + + +/** + * NO DOM + */ +function calcWidth(viewportWidth: number, approxWidth: string): number { + if (approxWidth.indexOf('%') > 0) { + return (viewportWidth * (parseFloat(approxWidth) / 100)); + + } else if (approxWidth.indexOf('px') > 0) { + return parseFloat(approxWidth); + } + + throw 'virtual scroll width can only use "%" or "px" units'; +} + + +/** + * NO DOM + */ +function calcHeight(viewportHeight: number, approxHeight: string): number { + if (approxHeight.indexOf('px') > 0) { + return parseFloat(approxHeight); + } + + throw 'virtual scroll height must use "px" units'; +} + + +/** + * NO DOM + */ +function getElement(node: VirtualNode) { + let rootNodes = node.view.rootNodes; + for (var i = 0; i < rootNodes.length; i++) { + if (rootNodes[i].nodeType === 1) { + return rootNodes[i]; + } + } +} + + +// could be either record data or divider data +export interface VirtualCell { + record?: number; + tmpl?: number; + data?: any; + row?: number; + left?: number; + width?: number; + top?: number; + height?: number; + reads?: number; + isLast?: boolean; +} + +// one of the rendered nodes +export interface VirtualNode { + cell?: number; + tmpl: number; + view: EmbeddedViewRef; + isLastRecord?: boolean; + hidden?: boolean; + hasChanges?: boolean; + lastTransform?: string; +} + + +export interface VirtualData { + scrollTop?: number; + scrollDiff?: number; + viewWidth?: number; + viewHeight?: number; + renderHeight?: number; + topCell?: number; + bottomCell?: number; + topViewCell?: number; + bottomViewCell?: number; + valid?: boolean; + itmWidth?: number; + itmHeight?: number; + hdrWidth?: number; + hdrHeight?: number; + ftrWidth?: number; + ftrHeight?: number; +} + +const TEMPLATE_ITEM = 0; +const TEMPLATE_HEADER = 1; +const TEMPLATE_FOOTER = 2; +const VIEWABLE_RENDERED_PADDING = 3; +const REQUIRED_DOM_READS = 2; diff --git a/ionic/config/directives.ts b/ionic/config/directives.ts index 8fd313a238..1be5c83854 100644 --- a/ionic/config/directives.ts +++ b/ionic/config/directives.ts @@ -9,6 +9,7 @@ import {Badge} from '../components/badge/badge'; import {Button} from '../components/button/button'; import {Blur} from '../components/blur/blur'; import {Content} from '../components/content/content'; +import {Img} from '../components/img/img'; import {Scroll} from '../components/scroll/scroll'; import {InfiniteScroll} from '../components/infinite-scroll/infinite-scroll'; import {InfiniteScrollContent} from '../components/infinite-scroll/infinite-scroll-content'; @@ -20,6 +21,8 @@ import {Tab} from '../components/tabs/tab'; import {List, ListHeader} from '../components/list/list'; import {Item} from '../components/item/item'; import {ItemSliding} from '../components/item/item-sliding'; +import {VirtualScroll} from '../components/virtual-scroll/virtual-scroll'; +import {VirtualItem, VirtualHeader, VirtualFooter} from '../components/virtual-scroll/virtual-item'; import {Toolbar, ToolbarTitle, ToolbarItem} from '../components/toolbar/toolbar'; import {Icon} from '../components/icon/icon'; import {Spinner} from '../components/spinner/spinner'; @@ -70,6 +73,8 @@ import {ShowWhen, HideWhen} from '../components/show-hide-when/show-hide-when'; * - ListHeader * - Item * - ItemSliding + * - VirtualScroll + * - VirtualFor * * **Slides** * - Slides @@ -134,12 +139,17 @@ export const IONIC_DIRECTIVES = [ InfiniteScrollContent, Refresher, RefresherContent, + Img, // Lists List, ListHeader, Item, ItemSliding, + VirtualScroll, + VirtualItem, + VirtualHeader, + VirtualFooter, // Slides Slides, diff --git a/ionic/platform/registry.ts b/ionic/platform/registry.ts index 3e56440e36..3d650c2831 100644 --- a/ionic/platform/registry.ts +++ b/ionic/platform/registry.ts @@ -103,6 +103,11 @@ Platform.register({ swipeBackEnabled: isIOSDevice, swipeBackThreshold: 40, tapPolyfill: isIOSDevice, + virtualScrollEventAssist: function() { + // UIWebView needs help getting scroll events + // WKWebView does not (WKWebView supports indexDB) + return !(window.indexedDB); + } }, isMatch(p: Platform): boolean { return p.isPlatformMatch('ios', ['iphone', 'ipad', 'ipod'], ['windows phone']);