mirror of
https://github.com/ionic-team/ionic-framework.git
synced 2025-08-22 05:21:52 +08:00
chore(build): rename ionic directory to src and update all references in the build process.
This commit is contained in:
42
src/components/virtual-scroll/test/basic/index.ts
Normal file
42
src/components/virtual-scroll/test/basic/index.ts
Normal 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;
|
||||
}
|
||||
}
|
27
src/components/virtual-scroll/test/basic/main.html
Normal file
27
src/components/virtual-scroll/test/basic/main.html
Normal 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>
|
54
src/components/virtual-scroll/test/cards/index.ts
Normal file
54
src/components/virtual-scroll/test/cards/index.ts
Normal 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;
|
28
src/components/virtual-scroll/test/cards/main.html
Normal file
28
src/components/virtual-scroll/test/cards/main.html
Normal 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>
|
101
src/components/virtual-scroll/test/image-gallery/index.ts
Normal file
101
src/components/virtual-scroll/test/image-gallery/index.ts
Normal 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;
|
71
src/components/virtual-scroll/test/image-gallery/main.html
Normal file
71
src/components/virtual-scroll/test/image-gallery/main.html
Normal 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>
|
||||
|
41
src/components/virtual-scroll/test/variable-size/index.ts
Normal file
41
src/components/virtual-scroll/test/variable-size/index.ts
Normal 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;
|
||||
}
|
||||
}
|
17
src/components/virtual-scroll/test/variable-size/main.html
Normal file
17
src/components/virtual-scroll/test/variable-size/main.html
Normal 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>
|
569
src/components/virtual-scroll/test/virtual-scroll.spec.ts
Normal file
569
src/components/virtual-scroll/test/virtual-scroll.spec.ts
Normal 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;
|
28
src/components/virtual-scroll/virtual-item.ts
Normal file
28
src/components/virtual-scroll/virtual-item.ts
Normal 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) {}
|
||||
}
|
22
src/components/virtual-scroll/virtual-scroll.scss
Normal file
22
src/components/virtual-scroll/virtual-scroll.scss
Normal 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;
|
||||
}
|
644
src/components/virtual-scroll/virtual-scroll.ts
Normal file
644
src/components/virtual-scroll/virtual-scroll.ts
Normal 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;
|
677
src/components/virtual-scroll/virtual-util.ts
Normal file
677
src/components/virtual-scroll/virtual-util.ts
Normal 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;
|
Reference in New Issue
Block a user