mirror of
https://github.com/ionic-team/ionic-framework.git
synced 2025-08-20 12:29:55 +08:00
667 lines
18 KiB
TypeScript
667 lines
18 KiB
TypeScript
import {Directive, Input, ViewContainerRef, TemplateRef, EmbeddedViewRef} from 'angular2/core';
|
|
|
|
import {CSS} from '../../util/dom';
|
|
|
|
|
|
/**
|
|
* NO DOM
|
|
*/
|
|
export function processRecords(stopAtHeight: number,
|
|
records: any[], cells: VirtualCell[],
|
|
headerFn: Function, footerFn: Function,
|
|
data: VirtualData) {
|
|
let record: any;
|
|
let startRecordIndex: number;
|
|
let previousCell: VirtualCell;
|
|
let tmpData: any;
|
|
let lastRecordIndex = (records.length - 1);
|
|
|
|
if (cells.length) {
|
|
// we already have cells
|
|
previousCell = cells[ cells.length - 1];
|
|
if (previousCell.top + previousCell.height > stopAtHeight) {
|
|
return;
|
|
}
|
|
startRecordIndex = (previousCell.record + 1);
|
|
|
|
} else {
|
|
// no cells have been created yet
|
|
previousCell = {
|
|
row: 0,
|
|
width: 0,
|
|
height: 0,
|
|
top: 0,
|
|
left: 0,
|
|
tmpl: -1
|
|
};
|
|
startRecordIndex = 0;
|
|
}
|
|
|
|
let processedTotal = 0;
|
|
|
|
for (var recordIndex = startRecordIndex; recordIndex <= lastRecordIndex; recordIndex++) {
|
|
record = records[recordIndex];
|
|
|
|
if (headerFn) {
|
|
tmpData = headerFn(record, recordIndex, records);
|
|
|
|
if (tmpData !== null) {
|
|
// add header data
|
|
previousCell = addCell(previousCell, recordIndex, TEMPLATE_HEADER, tmpData,
|
|
data.hdrWidth, data.hdrHeight, data.viewWidth);
|
|
cells.push(previousCell);
|
|
}
|
|
}
|
|
|
|
// add item data
|
|
previousCell = addCell(previousCell, recordIndex, TEMPLATE_ITEM, null,
|
|
data.itmWidth, data.itmHeight, data.viewWidth);
|
|
cells.push(previousCell);
|
|
|
|
if (footerFn) {
|
|
tmpData = footerFn(record, recordIndex, records);
|
|
|
|
if (tmpData !== null) {
|
|
// add footer data
|
|
previousCell = addCell(previousCell, recordIndex, TEMPLATE_FOOTER, tmpData,
|
|
data.ftrWidth, data.ftrHeight, data.viewWidth);
|
|
cells.push(previousCell);
|
|
}
|
|
}
|
|
|
|
if (previousCell.record === lastRecordIndex) {
|
|
previousCell.isLast = true;
|
|
}
|
|
|
|
// should always process at least 3 records
|
|
processedTotal++;
|
|
|
|
if (previousCell.top + previousCell.height + data.itmHeight > stopAtHeight && processedTotal > 3) {
|
|
return;
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
function addCell(previousCell: VirtualCell, recordIndex: number, tmpl: number, tmplData: any,
|
|
cellWidth: number, cellHeight: number, viewportWidth: number) {
|
|
let newCell: VirtualCell;
|
|
|
|
if (previousCell.left + previousCell.width + cellWidth > viewportWidth) {
|
|
// add a new cell in a new row
|
|
newCell = {
|
|
record: recordIndex,
|
|
tmpl: tmpl,
|
|
row: (previousCell.row + 1),
|
|
width: cellWidth,
|
|
height: cellHeight,
|
|
top: (previousCell.top + previousCell.height),
|
|
left: 0,
|
|
reads: 0,
|
|
};
|
|
|
|
} else {
|
|
// add a new cell in the same row
|
|
newCell = {
|
|
record: recordIndex,
|
|
tmpl: tmpl,
|
|
row: previousCell.row,
|
|
width: cellWidth,
|
|
height: cellHeight,
|
|
top: previousCell.top,
|
|
left: (previousCell.left + previousCell.width),
|
|
reads: 0,
|
|
};
|
|
}
|
|
|
|
if (tmplData) {
|
|
newCell.data = tmplData;
|
|
}
|
|
|
|
return newCell;
|
|
}
|
|
|
|
|
|
/**
|
|
* NO DOM
|
|
*/
|
|
export function populateNodeData(startCellIndex: number, endCellIndex: number, viewportWidth: number, scrollingDown: boolean,
|
|
cells: VirtualCell[], records: any[], nodes: VirtualNode[], viewContainer: ViewContainerRef,
|
|
itmTmp: TemplateRef, hdrTmp: TemplateRef, ftrTmp: TemplateRef,
|
|
initialLoad: boolean): boolean {
|
|
let madeChanges = false;
|
|
let node: VirtualNode;
|
|
let availableNode: VirtualNode;
|
|
let cell: VirtualCell;
|
|
let previousCell: VirtualCell;
|
|
let isAlreadyRendered: boolean;
|
|
let lastRecordIndex = (records.length - 1);
|
|
let viewInsertIndex: number = null;
|
|
let totalNodes = nodes.length;
|
|
|
|
startCellIndex = Math.max(startCellIndex, 0);
|
|
endCellIndex = Math.min(endCellIndex, cells.length - 1);
|
|
|
|
for (var cellIndex = startCellIndex; cellIndex <= endCellIndex; cellIndex++) {
|
|
cell = cells[cellIndex];
|
|
availableNode = null;
|
|
isAlreadyRendered = false;
|
|
|
|
// find the first one that's available
|
|
if (!initialLoad) {
|
|
for (var i = 0; i < totalNodes; i++) {
|
|
node = nodes[i];
|
|
|
|
if (cell.tmpl !== node.tmpl || i === 0 && cellIndex !== 0) {
|
|
// the cell must use the correct template
|
|
// first node can only be used by the first cell (css :first-child reasons)
|
|
// this node is never available to be reused
|
|
continue;
|
|
|
|
} else if (node.isLastRecord) {
|
|
// very last record, but could be a header/item/footer
|
|
if (cell.record === lastRecordIndex) {
|
|
availableNode = nodes[i];
|
|
availableNode.hidden = false;
|
|
break;
|
|
}
|
|
// this node is for the last record, but not actually the last
|
|
continue;
|
|
}
|
|
|
|
if (node.cell === cellIndex) {
|
|
isAlreadyRendered = true;
|
|
break;
|
|
}
|
|
|
|
if (node.cell < startCellIndex || node.cell > endCellIndex) {
|
|
|
|
if (!availableNode) {
|
|
// havent gotten an available node yet
|
|
availableNode = nodes[i];
|
|
|
|
} else if (scrollingDown) {
|
|
// scrolling down
|
|
if (node.cell < availableNode.cell) {
|
|
availableNode = nodes[i];
|
|
}
|
|
|
|
} else {
|
|
// scrolling up
|
|
if (node.cell > availableNode.cell) {
|
|
availableNode = nodes[i];
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (isAlreadyRendered) {
|
|
continue;
|
|
}
|
|
}
|
|
|
|
if (!availableNode) {
|
|
// did not find an available node to put the cell data into
|
|
// insert a new node before the last record nodes
|
|
if (viewInsertIndex === null) {
|
|
viewInsertIndex = -1;
|
|
for (var j = totalNodes - 1; j >= 0; j--) {
|
|
node = nodes[j];
|
|
if (node && !node.isLastRecord) {
|
|
viewInsertIndex = viewContainer.indexOf(node.view);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
availableNode = {
|
|
tmpl: cell.tmpl,
|
|
view: viewContainer.createEmbeddedView(
|
|
cell.tmpl === TEMPLATE_HEADER ? hdrTmp :
|
|
cell.tmpl === TEMPLATE_FOOTER ? ftrTmp :
|
|
itmTmp,
|
|
viewInsertIndex
|
|
)
|
|
};
|
|
|
|
totalNodes = nodes.push(availableNode);
|
|
// console.debug(`VirtrualScroll, new node, tmpl ${cell.tmpl}, height ${cell.height}`);
|
|
}
|
|
|
|
// console.debug(`node was cell ${availableNode.cell} but is now ${cellIndex}, was top: ${cell.top}`);
|
|
|
|
// assign who's the new cell index for this node
|
|
availableNode.cell = cellIndex;
|
|
|
|
// apply the cell's data to this node
|
|
availableNode.view.setLocal('\$implicit', cell.data || records[cell.record]);
|
|
availableNode.view.setLocal('index', cellIndex);
|
|
availableNode.view.setLocal('even', (cellIndex % 2 === 0));
|
|
availableNode.view.setLocal('odd', (cellIndex % 2 === 1));
|
|
availableNode.hasChanges = true;
|
|
availableNode.lastTransform = null;
|
|
madeChanges = true;
|
|
}
|
|
|
|
if (initialLoad) {
|
|
// add nodes that go at the very end, and only represent the last record
|
|
addLastNodes(nodes, viewContainer, TEMPLATE_HEADER, hdrTmp);
|
|
addLastNodes(nodes, viewContainer, TEMPLATE_ITEM, itmTmp);
|
|
addLastNodes(nodes, viewContainer, TEMPLATE_FOOTER, ftrTmp);
|
|
}
|
|
|
|
return madeChanges;
|
|
}
|
|
|
|
|
|
function addLastNodes(nodes: VirtualNode[], viewContainer: ViewContainerRef,
|
|
templateType: number, templateRef: TemplateRef) {
|
|
if (templateRef) {
|
|
let node: VirtualNode = {
|
|
tmpl: templateType,
|
|
view: viewContainer.createEmbeddedView(templateRef),
|
|
isLastRecord: true,
|
|
hidden: true,
|
|
};
|
|
node.view.setLocal('\$implicit', {});
|
|
nodes.push(node);
|
|
}
|
|
}
|
|
|
|
|
|
/**
|
|
* DOM READ THEN DOM WRITE
|
|
*/
|
|
export function initReadNodes(nodes: VirtualNode[], cells: VirtualCell[], data: VirtualData) {
|
|
if (nodes.length && cells.length) {
|
|
// first node
|
|
// ******** DOM READ ****************
|
|
cells[0].top = getElement(nodes[0]).offsetTop;
|
|
cells[0].row = 0;
|
|
|
|
// ******** DOM READ ****************
|
|
updateDimensions(nodes, cells, data, true);
|
|
|
|
|
|
// ******** DOM READS ABOVE / DOM WRITES BELOW ****************
|
|
|
|
|
|
for (var i = 0; i < nodes.length; i++) {
|
|
if (nodes[i].hidden) {
|
|
// ******** DOM WRITE ****************
|
|
getElement(nodes[i]).classList.add('virtual-hidden');
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
/**
|
|
* DOM READ
|
|
*/
|
|
export function updateDimensions(nodes: VirtualNode[], cells: VirtualCell[], data: VirtualData, initialUpdate: boolean) {
|
|
let node: VirtualNode;
|
|
let element: HTMLElement;
|
|
let totalCells = cells.length;
|
|
let cell: VirtualCell;
|
|
let previousCell: VirtualCell;
|
|
|
|
for (var i = 0; i < nodes.length; i++) {
|
|
node = nodes[i];
|
|
cell = cells[node.cell];
|
|
|
|
// read element dimensions if they haven't been checked enough times
|
|
if (cell && cell.reads < REQUIRED_DOM_READS && !node.hidden) {
|
|
element = getElement(node);
|
|
|
|
// ******** DOM READ ****************
|
|
readElements(cell, element);
|
|
|
|
if (initialUpdate) {
|
|
// update estimated dimensions with more accurate dimensions
|
|
if (cell.tmpl === TEMPLATE_HEADER) {
|
|
data.hdrHeight = cell.height;
|
|
if (cell.left === 0) {
|
|
data.hdrWidth = cell.width;
|
|
}
|
|
|
|
} else if (cell.tmpl === TEMPLATE_FOOTER) {
|
|
data.ftrHeight = cell.height;
|
|
if (cell.left === 0) {
|
|
data.ftrWidth = cell.width;
|
|
}
|
|
|
|
} else {
|
|
data.itmHeight = cell.height;
|
|
if (cell.left === 0) {
|
|
data.itmWidth = cell.width;
|
|
}
|
|
}
|
|
}
|
|
|
|
cell.reads++;
|
|
}
|
|
}
|
|
|
|
// figure out which cells are currently viewable within the viewport
|
|
let viewableBottom = (data.scrollTop + data.viewHeight);
|
|
data.topViewCell = totalCells;
|
|
data.bottomViewCell = 0;
|
|
|
|
// completely realign position to ensure they're all accurately placed
|
|
for (var i = 1; i < totalCells; i++) {
|
|
cell = cells[i];
|
|
previousCell = cells[i - 1];
|
|
|
|
if (previousCell.left + previousCell.width + cell.width > data.viewWidth) {
|
|
// new row
|
|
cell.row++;
|
|
cell.top = (previousCell.top + previousCell.height);
|
|
cell.left = 0;
|
|
|
|
} else {
|
|
// same row
|
|
cell.row = previousCell.row;
|
|
cell.top = previousCell.top;
|
|
cell.left = (previousCell.left + previousCell.width);
|
|
}
|
|
|
|
// figure out which cells are viewable within the viewport
|
|
if (cell.top + cell.height > data.scrollTop && i < data.topViewCell) {
|
|
data.topViewCell = i;
|
|
|
|
} else if (cell.top < viewableBottom && i > data.bottomViewCell) {
|
|
data.bottomViewCell = i;
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
/**
|
|
* DOM READ
|
|
*/
|
|
function readElements(cell: VirtualCell, element: HTMLElement) {
|
|
// ******** DOM READ ****************
|
|
let styles = window.getComputedStyle(element);
|
|
|
|
// ******** DOM READ ****************
|
|
cell.left = (element.offsetLeft - parseFloat(styles.marginLeft));
|
|
|
|
// ******** DOM READ ****************
|
|
cell.width = (element.offsetWidth + parseFloat(styles.marginLeft) + parseFloat(styles.marginRight));
|
|
|
|
// ******** DOM READ ****************
|
|
cell.height = (element.offsetHeight + parseFloat(styles.marginTop) + parseFloat(styles.marginBottom));
|
|
}
|
|
|
|
|
|
/**
|
|
* DOM WRITE
|
|
*/
|
|
export function writeToNodes(nodes: VirtualNode[], cells: VirtualCell[], totalRecords: number) {
|
|
let node: VirtualNode;
|
|
let element: HTMLElement;
|
|
let cell: VirtualCell;
|
|
let totalCells = Math.max(totalRecords, cells.length).toString();
|
|
let transform: string;
|
|
|
|
for (var i = 0, ilen = nodes.length; i < ilen; i++) {
|
|
node = nodes[i];
|
|
|
|
if (node.hidden) {
|
|
continue;
|
|
}
|
|
|
|
cell = cells[node.cell];
|
|
|
|
transform = `translate3d(${cell.left}px,${cell.top}px,0px)`;
|
|
|
|
if (node.lastTransform === transform) {
|
|
continue;
|
|
}
|
|
|
|
element = getElement(node);
|
|
|
|
if (element) {
|
|
// ******** DOM WRITE ****************
|
|
element.style[CSS.transform] = node.lastTransform = transform;
|
|
|
|
// ******** DOM WRITE ****************
|
|
element.classList.add('virtual-position');
|
|
|
|
if (node.isLastRecord) {
|
|
// its the last record, now with data and safe to show
|
|
// ******** DOM WRITE ****************
|
|
element.classList.remove('virtual-hidden');
|
|
}
|
|
|
|
// https://www.w3.org/TR/wai-aria/states_and_properties#aria-posinset
|
|
// ******** DOM WRITE ****************
|
|
element.setAttribute('aria-posinset', (node.cell + 1).toString());
|
|
|
|
// https://www.w3.org/TR/wai-aria/states_and_properties#aria-setsize
|
|
// ******** DOM WRITE ****************
|
|
element.setAttribute('aria-setsize', totalCells);
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
/**
|
|
* NO DOM
|
|
*/
|
|
export function adjustRendered(cells: VirtualCell[], data: VirtualData) {
|
|
// figure out which cells should be rendered
|
|
let cell: VirtualCell;
|
|
let lastRow = -1;
|
|
let cellsRenderHeight = 0;
|
|
let maxRenderHeight = (data.renderHeight - data.itmHeight);
|
|
let totalCells = cells.length;
|
|
let viewableRenderedPadding = (data.itmHeight < 90 ? VIEWABLE_RENDERED_PADDING : 0);
|
|
|
|
if (data.scrollDiff > 0) {
|
|
// scrolling down
|
|
data.topCell = Math.max(data.topViewCell - viewableRenderedPadding, 0);
|
|
data.bottomCell = Math.min(data.topCell + 2, totalCells - 1);
|
|
|
|
for (var i = data.topCell; i < totalCells; i++) {
|
|
cell = cells[i];
|
|
if (cell.row !== lastRow) {
|
|
cellsRenderHeight += cell.height;
|
|
lastRow = cell.row;
|
|
}
|
|
|
|
if (i > data.bottomCell) {
|
|
data.bottomCell = i;
|
|
}
|
|
|
|
if (cellsRenderHeight >= maxRenderHeight) {
|
|
break;
|
|
}
|
|
}
|
|
|
|
} else {
|
|
// scroll up
|
|
data.bottomCell = Math.min(data.bottomViewCell + viewableRenderedPadding, totalCells - 1);
|
|
data.topCell = Math.max(data.bottomCell - 2, 0);
|
|
|
|
for (var i = data.bottomCell; i >= 0; i--) {
|
|
cell = cells[i];
|
|
if (cell.row !== lastRow) {
|
|
cellsRenderHeight += cell.height;
|
|
lastRow = cell.row;
|
|
}
|
|
|
|
if (i < data.topCell) {
|
|
data.topCell = i;
|
|
}
|
|
|
|
if (cellsRenderHeight >= maxRenderHeight) {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
// console.log(`adjustRendered topCell: ${data.topCell}, bottomCell: ${data.bottomCell}, cellsRenderHeight: ${cellsRenderHeight}, data.renderHeight: ${data.renderHeight}`);
|
|
}
|
|
|
|
|
|
/**
|
|
* NO DOM
|
|
*/
|
|
export function getVirtualHeight(totalRecords: number, lastCell: VirtualCell): number {
|
|
if (lastCell.record >= totalRecords - 1) {
|
|
return (lastCell.top + lastCell.height);
|
|
}
|
|
|
|
let unknownRecords = (totalRecords - lastCell.record - 1);
|
|
let knownHeight = (lastCell.top + lastCell.height);
|
|
|
|
return Math.ceil(knownHeight + ((knownHeight / (totalRecords - unknownRecords)) * unknownRecords));
|
|
}
|
|
|
|
|
|
/**
|
|
* NO DOM
|
|
*/
|
|
export function estimateHeight(totalRecords: number, lastCell: VirtualCell, existingHeight: number, difference: number): number {
|
|
let newHeight = getVirtualHeight(totalRecords, lastCell);
|
|
|
|
let percentToBottom = (lastCell.record / (totalRecords - 1));
|
|
let diff = Math.abs(existingHeight - newHeight);
|
|
|
|
if ((diff > (newHeight * difference)) ||
|
|
(percentToBottom > .995)) {
|
|
return newHeight;
|
|
}
|
|
|
|
return existingHeight;
|
|
}
|
|
|
|
|
|
/**
|
|
* DOM READ
|
|
*/
|
|
export function calcDimensions(data: VirtualData,
|
|
viewportElement: HTMLElement,
|
|
approxItemWidth: string, approxItemHeight: string,
|
|
appoxHeaderWidth: string, approxHeaderHeight: string,
|
|
approxFooterWidth: string, approxFooterHeight: string,
|
|
bufferRatio: number) {
|
|
|
|
// get the parent container's viewport height
|
|
// ******** DOM READ ****************
|
|
data.viewWidth = viewportElement.offsetWidth;
|
|
|
|
// ******** DOM READ ****************
|
|
data.viewHeight = viewportElement.offsetHeight;
|
|
|
|
// the height we'd like to render, which is larger than viewable
|
|
data.renderHeight = (data.viewHeight * bufferRatio);
|
|
|
|
if (data.viewWidth > 0 && data.viewHeight > 0) {
|
|
data.itmWidth = calcWidth(data.viewWidth, approxItemWidth);
|
|
data.itmHeight = calcHeight(data.viewHeight, approxItemHeight);
|
|
data.hdrWidth = calcWidth(data.viewWidth, appoxHeaderWidth);
|
|
data.hdrHeight = calcHeight(data.viewHeight, approxHeaderHeight);
|
|
data.ftrWidth = calcWidth(data.viewWidth, approxFooterWidth);
|
|
data.ftrHeight = calcHeight(data.viewHeight, approxFooterHeight);
|
|
|
|
data.valid = true;
|
|
}
|
|
}
|
|
|
|
|
|
/**
|
|
* NO DOM
|
|
*/
|
|
function calcWidth(viewportWidth: number, approxWidth: string): number {
|
|
if (approxWidth.indexOf('%') > 0) {
|
|
return (viewportWidth * (parseFloat(approxWidth) / 100));
|
|
|
|
} else if (approxWidth.indexOf('px') > 0) {
|
|
return parseFloat(approxWidth);
|
|
}
|
|
|
|
throw 'virtual scroll width can only use "%" or "px" units';
|
|
}
|
|
|
|
|
|
/**
|
|
* NO DOM
|
|
*/
|
|
function calcHeight(viewportHeight: number, approxHeight: string): number {
|
|
if (approxHeight.indexOf('px') > 0) {
|
|
return parseFloat(approxHeight);
|
|
}
|
|
|
|
throw 'virtual scroll height must use "px" units';
|
|
}
|
|
|
|
|
|
/**
|
|
* NO DOM
|
|
*/
|
|
function getElement(node: VirtualNode) {
|
|
let rootNodes = node.view.rootNodes;
|
|
for (var i = 0; i < rootNodes.length; i++) {
|
|
if (rootNodes[i].nodeType === 1) {
|
|
return rootNodes[i];
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
// could be either record data or divider data
|
|
export interface VirtualCell {
|
|
record?: number;
|
|
tmpl?: number;
|
|
data?: any;
|
|
row?: number;
|
|
left?: number;
|
|
width?: number;
|
|
top?: number;
|
|
height?: number;
|
|
reads?: number;
|
|
isLast?: boolean;
|
|
}
|
|
|
|
// one of the rendered nodes
|
|
export interface VirtualNode {
|
|
cell?: number;
|
|
tmpl: number;
|
|
view: EmbeddedViewRef;
|
|
isLastRecord?: boolean;
|
|
hidden?: boolean;
|
|
hasChanges?: boolean;
|
|
lastTransform?: string;
|
|
}
|
|
|
|
|
|
export interface VirtualData {
|
|
scrollTop?: number;
|
|
scrollDiff?: number;
|
|
viewWidth?: number;
|
|
viewHeight?: number;
|
|
renderHeight?: number;
|
|
topCell?: number;
|
|
bottomCell?: number;
|
|
topViewCell?: number;
|
|
bottomViewCell?: number;
|
|
valid?: boolean;
|
|
itmWidth?: number;
|
|
itmHeight?: number;
|
|
hdrWidth?: number;
|
|
hdrHeight?: number;
|
|
ftrWidth?: number;
|
|
ftrHeight?: number;
|
|
}
|
|
|
|
const TEMPLATE_ITEM = 0;
|
|
const TEMPLATE_HEADER = 1;
|
|
const TEMPLATE_FOOTER = 2;
|
|
const VIEWABLE_RENDERED_PADDING = 3;
|
|
const REQUIRED_DOM_READS = 2;
|