chore(build): rename ionic directory to src and update all references in the build process.

This commit is contained in:
Josh Thomas
2016-05-19 13:20:59 -05:00
parent 8470ae04ac
commit c8f760f080
595 changed files with 73 additions and 87 deletions

View File

@ -0,0 +1,42 @@
import {ViewChild, ElementRef} from '@angular/core';
import {App, Page} from '../../../../../ionic';
@Page({
templateUrl: 'main.html'
})
class E2EPage {
items = [];
@ViewChild('content') content: ElementRef;
constructor() {
for (var i = 0; i < 200; 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: '<ion-nav [root]="root"></ion-nav>'
})
class E2EApp {
root;
constructor() {
this.root = E2EPage;
}
}

View File

@ -0,0 +1,27 @@
<ion-navbar *navbar>
<ion-title>Virtual Scroll</ion-title>
<ion-buttons end>
<button (click)="reload()">
Reload
</button>
</ion-buttons>
</ion-navbar>
<ion-content>
<ion-list [virtualScroll]="items"
[headerFn]="headerFn">
<ion-item-divider *virtualHeader="let header">
Header: {{header}}
</ion-item-divider>
<ion-item *virtualItem="let item">
Item: {{item}}
</ion-item>
</ion-list>
</ion-content>

View File

@ -0,0 +1,54 @@
import {ViewEncapsulation} from '@angular/core';
import {App, Page} from '../../../../../ionic';
@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: '<ion-nav [root]="root"></ion-nav>',
})
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;

View File

@ -0,0 +1,28 @@
<ion-toolbar><ion-title>Virtual Scroll: Cards</ion-title></ion-toolbar>
<ion-content>
<div [virtualScroll]="items" approxItemHeight="320px">
<ion-card *virtualItem="let item">
<div>
<ion-img [src]="item.imgSrc" [height]="item.imgHeight"></ion-img>
</div>
<ion-item>
<ion-avatar item-left>
<ion-img [src]="item.imgSrc"></ion-img>
</ion-avatar>
<h2>{{ item.name }}</h2>
</ion-item>
<ion-card-content>
{{ item.content }}
</ion-card-content>
</ion-card>
</div>
</ion-content>

View File

@ -0,0 +1,101 @@
import {ViewEncapsulation} from '@angular/core';
import {App, Page} from '../../../../../ionic';
@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: '<ion-nav [root]="root"></ion-nav>',
})
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;

View File

@ -0,0 +1,71 @@
<style>
.virtual-header {
width: 100%;
margin-bottom: 5px;
padding: 10px;
background: #eee;
}
.virtual-item {
display: inline-block;
vertical-align: top;
width: 80px;
height: 80px;
margin-left: 5px;
margin-bottom: 5px;
border: 1px solid gray;
}
.virtual-footer {
display: inline-block;
width: 80px;
height: 80px;
border: 1px solid red;
margin-left: 5px;
margin-bottom: 5px;
padding: 10px;
}
.virtual-scroll > :first-child {
border-top: 2px solid blue;
}
.virtual-scroll > :last-child {
background: red;
}
</style>
<ion-navbar *navbar>
<ion-title>Virtual Scroll: Image Gallery</ion-title>
<ion-buttons end>
<button (click)="reload()">
Reload
</button>
</ion-buttons>
</ion-navbar>
<ion-content>
<ion-list [virtualScroll]="items"
[headerFn]="headerFn"
[footerFn]="footerFn"
approxItemWidth="80px"
approxItemHeight="80px"
approxFooterWidth="80px"
approxFooterHeight="80px"
approxHeaderWidth="100%"
approxHeaderHeight="36px">
<div *virtualHeader="let header" class="virtual-header">
Header: {{header.date}}
</div>
<div *virtualItem="let item" class="virtual-item">
<ion-img [src]="item.imgSrc"></ion-img>
<!--{{ item.index }}-->
</div>
<div *virtualFooter="let footer" class="virtual-footer">
footer
</div>
</ion-list>
</ion-content>

View File

@ -0,0 +1,41 @@
import {App, Page} from '../../../../../ionic';
@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: '<ion-nav [root]="root"></ion-nav>',
})
class E2EApp {
root;
constructor() {
this.root = E2EPage;
}
}

View File

@ -0,0 +1,17 @@
<ion-toolbar><ion-title>Virtual Scroll: Variable Sizes</ion-title></ion-toolbar>
<ion-content>
<ion-list [virtualScroll]="items" [headerFn]="headerFn">
<ion-item-divider *virtualHeader="let header">
Header: {{ header }}
</ion-item-divider>
<ion-item *virtualItem="let item">
<span [style.fontSize]="item.fontSize">{{ item.item }}</span>
</ion-item>
</ion-list>
</ion-content>

View File

@ -0,0 +1,569 @@
import {VirtualScroll} from '../virtual-scroll';
import {VirtualCell, VirtualData, VirtualNode} from '../virtual-util';
import {processRecords, populateNodeData, initReadNodes, getVirtualHeight, adjustRendered} from '../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 {
context: {},
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;

View File

@ -0,0 +1,28 @@
import {Directive, TemplateRef, ViewContainerRef} from '@angular/core';
/**
* @private
*/
@Directive({selector: '[virtualHeader]'})
export class VirtualHeader {
constructor(public templateRef: TemplateRef<Object>) {}
}
/**
* @private
*/
@Directive({selector: '[virtualFooter]'})
export class VirtualFooter {
constructor(public templateRef: TemplateRef<Object>) {}
}
/**
* @private
*/
@Directive({selector: '[virtualItem]'})
export class VirtualItem {
constructor(public templateRef: TemplateRef<Object>, public viewContainer: ViewContainerRef) {}
}

View File

@ -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;
}

View File

@ -0,0 +1,644 @@
import {Directive, ContentChild, ContentChildren, QueryList, IterableDiffers, IterableDiffer, TrackByFn, Input, Optional, Renderer, ElementRef, ChangeDetectorRef, NgZone, TemplateRef, ViewContainerRef, DoCheck, AfterContentInit, OnDestroy} from '@angular/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, nativeRaf, cancelRaf, pointerCoord, nativeTimeout, clearNativeTimeout} from '../../util/dom';
import {Img} from '../img/img';
/**
* @name VirtualScroll
* @description
* Virtual Scroll displays a virtual, "infinite" list. An array of records
* is passed to the virtual scroll containing the data to create templates
* for. The template created for each record, referred to as a cell, can
* consist of items, headers, and footers.
*
* For performance reasons, not every record in the list is rendered at once;
* instead a small subset of records (enough to fill the viewport) are rendered
* and reused as the user scrolls.
*
* ### The Basics
*
* The array of records should be passed to the `virtualScroll` property.
* The data given to the `virtualScroll` property must be an array. An item
* template with the `*virtualItem` property is required in the `virtualScroll`.
* The `virtualScroll` and `*virtualItem` properties can be added to any element.
*
* ```html
* <ion-list [virtualScroll]="items">
*
* <ion-item *virtualItem="#item">
* {{ item }}
* </ion-item>
*
* </ion-list>
* ```
*
*
* ### Section Headers and Footers
*
* Section headers and footers are optional. They can be dynamically created
* from developer-defined functions. For example, a large list of contacts
* usually has a divider for each letter in the alphabet. Developers provide
* their own custom function to be called on each record. The logic in the
* custom function should determine whether to create the section template
* and what data to provide to the template. The custom function should
* return `null` if a template shouldn't be created.
*
* ```html
* <ion-list [virtualScroll]="items" [headerFn]="myHeaderFn">
*
* <ion-item-divider *virtualHeader="#header">
* Header: {{ header }}
* </ion-item-divider>
*
* <ion-item *virtualItem="#item">
* Item: {{ item }}
* </ion-item>
*
* </ion-list>
* ```
*
* Below is an example of a custom function called on every record. It
* gets passed the individual record, the record's index number,
* and the entire array of records. In this example, after every 20
* records a header will be inserted. So between the 19th and 20th records,
* between the 39th and 40th, and so on, a `<ion-item-divider>` 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, it's extremely important to know for virtual
* scroll to calculate a good height.
*
*
* ### Images Within Virtual Scroll
*
* Ionic provides `<ion-img>` to manage HTTP requests and image rendering.
* Additionally, it includes a customizable placeholder element which shows
* before the image has finished loading. While scrolling through items
* quickly, `<ion-img>` knows not to make any image 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.
*
* We recommend using our `<ion-img>` element over the native `<img>` element
* because when an `<img>` element is added to the DOM, it immediately
* makes a HTTP request for the image file. HTTP requests, image
* decoding, and image rendering can cause issues while scrolling. For virtual
* scrolling, the natural effects of the `<img>` are not desirable features.
*
* ```html
* <ion-list [virtualScroll]="items">
*
* <ion-item *virtualItem="#item">
* <ion-avatar item-left>
* <ion-img [src]="item.avatarUrl"></ion-img>
* </ion-avatar>
* {{ item.firstName }} {{ item.lastName }}
* </ion-item>
*
* </ion-list>
* ```
*
*
* ### Performance Tips
*
* - Use `<ion-img>` rather than `<img>` 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 the cell 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 perform 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<Img>;
/**
* @input {array} The data that builds the templates within the virtual scroll.
* This is 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
* multiplier 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 will 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._init = true;
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++;
nativeRaf(function() {
readDimensions(done);
});
}
}
}
// ******** DOM READ ****************
readDimensions(function() {
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
nativeRaf(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['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;

View File

@ -0,0 +1,677 @@
import {Directive, Input, ViewContainerRef, TemplateRef, EmbeddedViewRef, } from '@angular/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<Object>, hdrTmp: TemplateRef<Object>, ftrTmp: TemplateRef<Object>,
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: <EmbeddedViewRef<VirtualContext>>viewContainer.createEmbeddedView(
cell.tmpl === TEMPLATE_HEADER ? hdrTmp :
cell.tmpl === TEMPLATE_FOOTER ? ftrTmp :
itmTmp,
new VirtualContext(null, null, null),
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.context.$implicit = cell.data || records[cell.record];
availableNode.view.context.index = cellIndex;
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<Object>) {
if (templateRef) {
let node: VirtualNode = {
tmpl: templateType,
view: <EmbeddedViewRef<VirtualContext>>viewContainer.createEmbeddedView(templateRef),
isLastRecord: true,
hidden: true,
};
node.view.context.$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<VirtualContext>;
isLastRecord?: boolean;
hidden?: boolean;
hasChanges?: boolean;
lastTransform?: string;
}
export class VirtualContext {
constructor(public $implicit: any, public index: number, public count: number) {}
get first(): boolean { return this.index === 0; }
get last(): boolean { return this.index === this.count - 1; }
get even(): boolean { return this.index % 2 === 0; }
get odd(): boolean { return !this.even; }
}
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;