import { Component, ComponentInterface, Element, FunctionalComponent, Host, Listen, Method, Prop, State, Watch, forceUpdate, h, readTask, writeTask } from '@stencil/core'; import { Cell, DomRenderFn, FooterHeightFn, HeaderFn, HeaderHeightFn, ItemHeightFn, ItemRenderFn, VirtualNode } from '../../interface'; import { CELL_TYPE_FOOTER, CELL_TYPE_HEADER, CELL_TYPE_ITEM } from './constants'; import { Range, calcCells, calcHeightIndex, doRender, findCellIndex, getRange, getShouldUpdate, getViewport, inplaceUpdate, positionForIndex, resizeBuffer, updateVDom } from './virtual-scroll-utils'; @Component({ tag: 'ion-virtual-scroll', styleUrl: 'virtual-scroll.scss' }) export class VirtualScroll implements ComponentInterface { private contentEl?: HTMLElement; private scrollEl?: HTMLElement; private range: Range = { offset: 0, length: 0 }; private timerUpdate: any; private heightIndex?: Uint32Array; private viewportHeight = 0; private cells: Cell[] = []; private virtualDom: VirtualNode[] = []; private isEnabled = false; private viewportOffset = 0; private currentScrollTop = 0; private indexDirty = 0; private lastItemLen = 0; private rmEvent: (() => void) | undefined; @Element() el!: HTMLIonVirtualScrollElement; @State() totalHeight = 0; /** * It is important to provide this * if virtual item height will be significantly larger than the default * The approximate height of each virtual 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 before the item has been rendered. */ @Prop() approxItemHeight = 45; /** * 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 before the item has been rendered. */ @Prop() approxHeaderHeight = 30; /** * 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 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 before the item has been rendered. */ @Prop() approxFooterHeight = 30; /** * 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. */ @Prop() headerFn?: HeaderFn; /** * 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. */ @Prop() footerFn?: HeaderFn; /** * The data that builds the templates within the virtual scroll. * 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. */ @Prop() items?: any[]; /** * An optional function that maps each item within their height. * When this function is provides, heavy optimizations and fast path can be taked by * `ion-virtual-scroll` leading to massive performance improvements. * * This function allows to skip all DOM reads, which can be Doing so leads * to massive performance */ @Prop() itemHeight?: ItemHeightFn; /** * An optional function that maps each item header within their height. */ @Prop() headerHeight?: HeaderHeightFn; /** * An optional function that maps each item footer within their height. */ @Prop() footerHeight?: FooterHeightFn; /** * NOTE: only JSX API for stencil. * * Provide a render function for the items to be rendered. Returns a JSX virtual-dom. */ @Prop() renderItem?: (item: any, index: number) => any; /** * NOTE: only JSX API for stencil. * * Provide a render function for the header to be rendered. Returns a JSX virtual-dom. */ @Prop() renderHeader?: (item: any, index: number) => any; /** * NOTE: only JSX API for stencil. * * Provide a render function for the footer to be rendered. Returns a JSX virtual-dom. */ @Prop() renderFooter?: (item: any, index: number) => any; /** * NOTE: only Vanilla JS API. */ @Prop() nodeRender?: ItemRenderFn; /** @internal */ @Prop() domRender?: DomRenderFn; @Watch('itemHeight') @Watch('headerHeight') @Watch('footerHeight') @Watch('items') itemsChanged() { this.calcCells(); this.updateVirtualScroll(); } async connectedCallback() { const contentEl = this.el.closest('ion-content'); if (!contentEl) { console.error(' must be used inside an '); return; } this.scrollEl = await contentEl.getScrollElement(); this.contentEl = contentEl; this.calcCells(); this.updateState(); } componentDidUpdate() { this.updateState(); } disconnectedCallback() { this.scrollEl = undefined; } @Listen('resize', { target: 'window' }) onResize() { this.calcCells(); this.updateVirtualScroll(); } /** * Returns the position of the virtual item at the given index. */ @Method() positionForItem(index: number): Promise { return Promise.resolve(positionForIndex(index, this.cells, this.getHeightIndex())); } /** * This method marks a subset of items as dirty, so they can be re-rendered. Items should be marked as * dirty any time the content or their style changes. * * The subset of items to be updated can are specifing by an offset and a length. */ @Method() async checkRange(offset: number, len = -1) { // TODO: kind of hacky how we do in-place updated of the cells // array. this part needs a complete refactor if (!this.items) { return; } const length = (len === -1) ? this.items.length - offset : len; const cellIndex = findCellIndex(this.cells, offset); const cells = calcCells( this.items, this.itemHeight, this.headerHeight, this.footerHeight, this.headerFn, this.footerFn, this.approxHeaderHeight, this.approxFooterHeight, this.approxItemHeight, cellIndex, offset, length ); this.cells = inplaceUpdate(this.cells, cells, cellIndex); this.lastItemLen = this.items.length; this.indexDirty = Math.max(offset - 1, 0); this.scheduleUpdate(); } /** * This method marks the tail the items array as dirty, so they can be re-rendered. * * It's equivalent to calling: * * ```js * virtualScroll.checkRange(lastItemLen); * ``` */ @Method() async checkEnd() { if (this.items) { this.checkRange(this.lastItemLen); } } private onScroll = () => { this.updateVirtualScroll(); } private updateVirtualScroll() { // do nothing if virtual-scroll is disabled if (!this.isEnabled || !this.scrollEl) { return; } // unschedule future updates if (this.timerUpdate) { clearTimeout(this.timerUpdate); this.timerUpdate = undefined; } // schedule DOM operations into the stencil queue readTask(this.readVS.bind(this)); writeTask(this.writeVS.bind(this)); } private readVS() { const { contentEl, scrollEl, el } = this; let topOffset = 0; let node: HTMLElement | null = el; while (node && node !== contentEl) { topOffset += node.offsetTop; node = node.parentElement; } this.viewportOffset = topOffset; if (scrollEl) { this.viewportHeight = scrollEl.offsetHeight; this.currentScrollTop = scrollEl.scrollTop; } } private writeVS() { const dirtyIndex = this.indexDirty; // get visible viewport const scrollTop = this.currentScrollTop - this.viewportOffset; const viewport = getViewport(scrollTop, this.viewportHeight, 100); // compute lazily the height index const heightIndex = this.getHeightIndex(); // get array bounds of visible cells base in the viewport const range = getRange(heightIndex, viewport, 2); // fast path, do nothing const shouldUpdate = getShouldUpdate(dirtyIndex, this.range, range); if (!shouldUpdate) { return; } this.range = range; // in place mutation of the virtual DOM updateVDom( this.virtualDom, heightIndex, this.cells, range ); // Write DOM // Different code paths taken depending of the render API used if (this.nodeRender) { doRender(this.el, this.nodeRender, this.virtualDom, this.updateCellHeight.bind(this)); } else if (this.domRender) { this.domRender(this.virtualDom); } else if (this.renderItem) { forceUpdate(this); } } private updateCellHeight(cell: Cell, node: any) { const update = () => { if ((node as any)['$ionCell'] === cell) { const style = window.getComputedStyle(node); const height = node.offsetHeight + parseFloat(style.getPropertyValue('margin-bottom')); this.setCellHeight(cell, height); } }; if (node && node.componentOnReady) { node.componentOnReady().then(update); } else { update(); } } private setCellHeight(cell: Cell, height: number) { const index = cell.i; // the cell might changed since the height update was scheduled if (cell !== this.cells[index]) { return; } if (cell.height !== height || cell.visible !== true) { cell.visible = true; cell.height = height; this.indexDirty = Math.min(this.indexDirty, index); this.scheduleUpdate(); } } private scheduleUpdate() { clearTimeout(this.timerUpdate); this.timerUpdate = setTimeout(() => this.updateVirtualScroll(), 100); } private updateState() { const shouldEnable = !!( this.scrollEl && this.cells ); if (shouldEnable !== this.isEnabled) { this.enableScrollEvents(shouldEnable); if (shouldEnable) { this.updateVirtualScroll(); } } } private calcCells() { if (!this.items) { return; } this.lastItemLen = this.items.length; this.cells = calcCells( this.items, this.itemHeight, this.headerHeight, this.footerHeight, this.headerFn, this.footerFn, this.approxHeaderHeight, this.approxFooterHeight, this.approxItemHeight, 0, 0, this.lastItemLen ); this.indexDirty = 0; } private getHeightIndex(): Uint32Array { if (this.indexDirty !== Infinity) { this.calcHeightIndex(this.indexDirty); } return this.heightIndex!; } private calcHeightIndex(index = 0) { // TODO: optimize, we don't need to calculate all the cells this.heightIndex = resizeBuffer(this.heightIndex, this.cells.length); this.totalHeight = calcHeightIndex(this.heightIndex, this.cells, index); this.indexDirty = Infinity; } private enableScrollEvents(shouldListen: boolean) { if (this.rmEvent) { this.rmEvent(); this.rmEvent = undefined; } const scrollEl = this.scrollEl; if (scrollEl) { this.isEnabled = shouldListen; scrollEl.addEventListener('scroll', this.onScroll); this.rmEvent = () => { scrollEl.removeEventListener('scroll', this.onScroll); }; } } private renderVirtualNode(node: VirtualNode) { const { type, value, index } = node.cell; switch (type) { case CELL_TYPE_ITEM: return this.renderItem!(value, index); case CELL_TYPE_HEADER: return this.renderHeader!(value, index); case CELL_TYPE_FOOTER: return this.renderFooter!(value, index); } } render() { return ( {this.renderItem && ( {this.virtualDom.map(node => this.renderVirtualNode(node))} )} ); } } const VirtualProxy: FunctionalComponent<{dom: VirtualNode[]}> = ({ dom }, children, utils) => { return utils.map(children, (child, i) => { const node = dom[i]; const vattrs = child.vattrs || {}; let classes = vattrs.class || ''; classes += 'virtual-item '; if (!node.visible) { classes += 'virtual-loading'; } return { ...child, vattrs: { ...vattrs, class: classes, style: { ...vattrs.style, transform: `translate3d(0,${node.top}px,0)` } } }; }); };