diff --git a/packages/core/src/components.d.ts b/packages/core/src/components.d.ts index 920f5ab493..1b3e085452 100644 --- a/packages/core/src/components.d.ts +++ b/packages/core/src/components.d.ts @@ -40,6 +40,13 @@ import { import { SelectPopoverOption, } from './components/select-popover/select-popover'; +import { + DomRenderFn, + HeaderFn, + ItemHeightFn, + ItemRenderFn, + NodeHeightFn, +} from './components/virtual-scroll/virtual-scroll-utils'; import { ActionSheetController as IonActionSheetController @@ -1660,7 +1667,7 @@ declare global { enterAnimation?: AnimationBuilder; leaveAnimation?: AnimationBuilder; modalId?: number; - mode?: string; + mode?: 'ios' | 'md'; showBackdrop?: boolean; willAnimate?: boolean; } @@ -1925,7 +1932,7 @@ declare global { enterAnimation?: AnimationBuilder; ev?: Event; leaveAnimation?: AnimationBuilder; - mode?: string; + mode?: 'ios' | 'md'; popoverId?: string; showBackdrop?: boolean; translucent?: boolean; @@ -3210,3 +3217,43 @@ declare global { } } + +import { + VirtualScroll as IonVirtualScroll +} from './components/virtual-scroll/virtual-scroll'; + +declare global { + interface HTMLIonVirtualScrollElement extends IonVirtualScroll, HTMLElement { + } + var HTMLIonVirtualScrollElement: { + prototype: HTMLIonVirtualScrollElement; + new (): HTMLIonVirtualScrollElement; + }; + interface HTMLElementTagNameMap { + "ion-virtual-scroll": HTMLIonVirtualScrollElement; + } + interface ElementTagNameMap { + "ion-virtual-scroll": HTMLIonVirtualScrollElement; + } + namespace JSX { + interface IntrinsicElements { + "ion-virtual-scroll": JSXElements.IonVirtualScrollAttributes; + } + } + namespace JSXElements { + export interface IonVirtualScrollAttributes extends HTMLAttributes { + approxFooterHeight?: number; + approxHeaderHeight?: number; + approxItemHeight?: number; + domRender?: DomRenderFn; + footerFn?: HeaderFn; + headerFn?: HeaderFn; + itemHeight?: ItemHeightFn; + itemRender?: ItemRenderFn; + items?: any[]; + nodeHeight?: NodeHeightFn; + } + } +} + +declare global { namespace JSX { interface StencilJSX {} } } diff --git a/packages/core/src/components/virtual-scroll/readme.md b/packages/core/src/components/virtual-scroll/readme.md new file mode 100644 index 0000000000..7b783e745d --- /dev/null +++ b/packages/core/src/components/virtual-scroll/readme.md @@ -0,0 +1,390 @@ +#ion-virtual-scroll + +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 + + + {% raw %}{{ item }}{% endraw %} + + +``` + +### 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 + + + Header: {% raw %}{{ header }}{% endraw %} + + + Item: {% raw %}{{ item }}{% endraw %} + + +``` + +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 `` 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 + +If the height of items in the virtual scroll are not close to the +default size of 40px, it is extremely important to provide a value for +approxItemHeight height. An exact pixel-perfect size is not necessary, +but without an estimate the virtual scroll will not render correctly. + +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. + +### Images Within Virtual Scroll + +HTTP requests, image decoding, and image rendering can cause jank while +scrolling. In order to better control images, Ionic provides `` +to manage HTTP requests and image rendering. While scrolling through items +quickly, `` knows when and when not to make requests, when and +when not to render images, and only loads the images that are viewable +after scrolling. [Read more about `ion-img`.](../../img/Img/) + +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. Simply put, to ensure rendering bugs are not introduced, +it's vital that elements within a virtual item does not dynamically change. + +For virtual scrolling, the natural effects of the `` are not desirable +features. We recommend using the `` component over the native +`` element because when an `` element is added to the DOM, it +immediately makes a HTTP request for the image file. Additionally, `` +renders whenever it wants which could be while the user is scrolling. However, +`` is governed by the containing `ion-content` and does not render +images while scrolling quickly. + +```html + + + + + + {% raw %} {{ item.firstName }} {{ item.lastName }}{% endraw %} + + +``` + +### Custom Components + +If a custom component is going to be used within Virtual Scroll, it's best +to wrap it with a good old `
` to ensure the component is rendered +correctly. Since each custom component's implementation and internals can be +quite different, wrapping within a `
` is a safe way to make sure +dimensions are measured correctly. + +```html + +
+ + {% raw %} {{ item }}{% endraw %} + +
+
+``` + +## Virtual Scroll Performance Tips + +#### iOS Cordova WKWebView + +When deploying to iOS with Cordova, it's highly recommended to use the +[WKWebView plugin](http://blog.ionic.io/cordova-ios-performance-improvements-drop-in-speed-with-wkwebview/) +in order to take advantage of iOS's higher performimg webview. Additionally, +WKWebView is superior at scrolling efficiently in comparision to the older +UIWebView. + +#### Lock in element dimensions and locations + +In order for virtual scroll to efficiently size and locate every item, it's +very important every element within each virtual item does not dynamically +change its dimensions or location. The best way to ensure size and location +does not change, it's recommended each virtual item has locked in its size +via CSS. + +#### Use `ion-img` for images + +When including images within Virtual Scroll, be sure to use +[`ion-img`](../img/Img/) rather than the standard `` HTML element. +With `ion-img`, images are lazy loaded so only the viewable ones are +rendered, and HTTP requests are efficiently controlled while scrolling. + +#### Set Approximate Widths and Heights + +As mentioned above, all elements should lock in their dimensions. However, +virtual scroll isn't aware of the dimensions until after they have been +rendered. For the initial render, virtual scroll still needs to set +how many items should be built. With "approx" property inputs, such as +`approxItemHeight`, we're able to give virtual scroll an approximate size, +therefore allowing virtual scroll to decide how many items should be +created. + +#### Changing dataset should use `virtualTrackBy` + +It is possible for the identities of elements in the iterator to change +while the data does not. This can happen, for example, if the iterator +produced from an RPC to the server, and that RPC is re-run. Even if the +"data" hasn't changed, the second response will produce objects with +different identities, and Ionic will tear down the entire DOM and rebuild +it. This is an expensive operation and should be avoided if possible. + +#### Efficient headers and footer functions +Each virtual item must stay extremely efficient, but one way to really +kill its performance is to perform any DOM operations within section header +and footer functions. These functions are called for every record in the +dataset, so please make sure they're performant. + + + + + +## Properties + +#### approxFooterHeight + +number + +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 before the item has been rendered. Default is `100%`. + + +#### approxHeaderHeight + +number + +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. Default is `40px`. + + +#### approxItemHeight + +number + +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. Default is +`45`. + + +#### domRender + + + + +#### footerFn + + + +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. + + +#### headerFn + + + +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. + + +#### itemHeight + + + + +#### itemRender + + + + +#### items + + + +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. + + +#### nodeHeight + + + + +## Attributes + +#### approxFooterHeight + +number + +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 before the item has been rendered. Default is `100%`. + + +#### approxHeaderHeight + +number + +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. Default is `40px`. + + +#### approxItemHeight + +number + +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. Default is +`45`. + + +#### domRender + + + + +#### footerFn + + + +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. + + +#### headerFn + + + +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. + + +#### itemHeight + + + + +#### itemRender + + + + +#### items + + + +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. + + +#### nodeHeight + + + + +## Methods + +#### positionForItem() + + + +---------------------------------------------- + +*Built by [StencilJS](https://stenciljs.com/)* diff --git a/packages/core/src/components/virtual-scroll/test/basic.html b/packages/core/src/components/virtual-scroll/test/basic.html new file mode 100644 index 0000000000..621e6a9463 --- /dev/null +++ b/packages/core/src/components/virtual-scroll/test/basic.html @@ -0,0 +1,66 @@ + + + + + + Ionic Item Sliding + + + + + + + + + + + Ionic CDN demo + + + + + + + + + + + + + + + diff --git a/packages/core/src/components/virtual-scroll/test/cards.html b/packages/core/src/components/virtual-scroll/test/cards.html new file mode 100644 index 0000000000..ee7c5f74b3 --- /dev/null +++ b/packages/core/src/components/virtual-scroll/test/cards.html @@ -0,0 +1,105 @@ + + + + + + Ionic Item Sliding + + + + + + + + + + + Ionic CDN demo + + + + + + + + + + + + + + + diff --git a/packages/core/src/components/virtual-scroll/virtual-scroll-utils.tsx b/packages/core/src/components/virtual-scroll/virtual-scroll-utils.tsx new file mode 100644 index 0000000000..99e11d76e1 --- /dev/null +++ b/packages/core/src/components/virtual-scroll/virtual-scroll-utils.tsx @@ -0,0 +1,193 @@ + +export const enum CellType { + Item, + Header, + Footer +} + +export interface Cell { + type: CellType; + value: any; + i: number; + index: number; + height: number; + reads: number; + visible: boolean; +} + +export interface VirtualNode { + cell: Cell; + top: number; + change: number; + _d: boolean; +} + +export type NodeHeightFn = (node: VirtualNode, index: number) => number; +export type HeaderFn = (item: any, index: number, items: any[]) => string | null; +export type ItemHeightFn = (item: any, index?: number) => number; +export type ItemRenderFn = (el: HTMLElement|null, item: any, type: CellType, index?: number) => HTMLElement; +export type DomRenderFn = (dom: VirtualNode[], height: number) => void; + +export function updateVDom(dom: VirtualNode[], heightIndex: Uint32Array, cells: Cell[], top: number, bottom: number) { + // reset dom + for (const node of dom) { + node.top = -9999; + node.change = 0; + node._d = true; + } + + // try to match into exisiting dom + const toMutate = []; + const end = bottom + 1; + + for (let i = top; i < end; i++) { + const cell = cells[i]; + const node = dom.find((n) => n._d && n.cell === cell); + if (node) { + node._d = false; + node.change = 1; + node.top = heightIndex[i]; + } else { + toMutate.push(cell); + } + } + + // needs to append + const pool = dom.filter((n) => n._d); + + // console.log('toMutate', toMutate.length); + for (const cell of toMutate) { + const node = pool.find(n => n._d && n.cell.type === cell.type); + const index = cell.index; + if (node) { + node._d = false; + node.change = 2; + node.cell = cell; + node.top = heightIndex[index]; + } else { + dom.push({ + _d: false, + change: 2, + cell: cell, + top: heightIndex[index], + }); + } + } +} + +export function doRender(el: HTMLElement, itemRender: ItemRenderFn, dom: VirtualNode[], updateCellHeight: Function, total: number) { + const children = el.children; + let child: HTMLElement; + for (let i = 0; i < dom.length; i++) { + const node = dom[i]; + const cell = node.cell; + if (node.change === 2) { + if (i < children.length) { + child = children[i] as HTMLElement; + itemRender(child, cell.value, cell.type, cell.index); + } else { + child = itemRender(null, cell.value, cell.type, cell.index); + child.classList.add('virtual-item'); + el.appendChild(child); + } + } else { + child = children[i] as HTMLElement; + } + (child as any)['$ionCell'] = cell; + if (node.change !== 0) { + child.style.transform = `translate3d(0,${node.top}px,0)`; + } + if (cell.visible) { + child.classList.remove('virtual-loading'); + } else { + child.classList.add('virtual-loading'); + } + if (cell.reads > 0) { + updateCellHeight(cell, child); + } + } + el.style.height = total + 'px'; +} + +export function doHeight(el: HTMLElement, index: number) { + const e = (el.children[index] as HTMLElement); + // const style = window.getComputedStyle(e); + return e.offsetHeight; +} + +export function getTotalHeight(heightIndex: Uint32Array) { + return heightIndex[heightIndex.length - 1]; +} + +export interface Viewport { + top: number; + bottom: number; +} + +export function getViewport(scrollTop: number, vierportHeight: number, margin: number): Viewport { + return { + top: scrollTop - margin, + bottom: scrollTop + vierportHeight + margin + }; +} + +export function getBounds(heightIndex: Uint32Array, viewport: Viewport, buffer: number) { + const topPos = viewport.top; + const bottomPos = viewport.bottom; + + // find top index + let i = 0; + for (; i < heightIndex.length; i++) { + if (heightIndex[i] > topPos) { + break; + } + } + const top = Math.max(i - buffer, 0); + + // find bottom index + for (; i < heightIndex.length; i++) { + if (heightIndex[i] > bottomPos) { + break; + } + } + const bottom = Math.min(i + buffer, heightIndex.length - 1); + return { top, bottom }; +} + +export function getShouldUpdate(dirtyIndex: number, currentTop: number, currentBottom: number, top: number, bottom: number) { + return ( + dirtyIndex < bottom || + currentTop !== top || + currentBottom !== bottom + ); +} + + +export function calcHeightIndex(buf: Uint32Array, cells: Cell[], index: number, bottom: number) { + if (!cells) { + return buf; + } + buf = resizeBuffer(buf, cells.length); + + let acum = buf[index]; + for (; index < buf.length; index++) { + buf[index] = acum; + acum += cells[index].height; + // if (acum > bottom) { + // break; + // } + } + return buf; +} + + +export function resizeBuffer(buf: Uint32Array, len: number) { + if (!buf) { + return new Uint32Array(len); + } + if (buf.length === len) { + return buf; + } + return buf; +} + diff --git a/packages/core/src/components/virtual-scroll/virtual-scroll.scss b/packages/core/src/components/virtual-scroll/virtual-scroll.scss new file mode 100644 index 0000000000..c3795d9b4a --- /dev/null +++ b/packages/core/src/components/virtual-scroll/virtual-scroll.scss @@ -0,0 +1,21 @@ +@import "../../themes/ionic.globals"; + +ion-virtual-scroll { + display: block; + position: relative; + width: 100%; + + // contain: strict; +} + +.virtual-loading { + opacity: 0; +} + +.virtual-item { + @include position(0, 0, null, 0); + + will-change: transform; + position: absolute; + // contain: strict; +} diff --git a/packages/core/src/components/virtual-scroll/virtual-scroll.tsx b/packages/core/src/components/virtual-scroll/virtual-scroll.tsx new file mode 100644 index 0000000000..399c651b5e --- /dev/null +++ b/packages/core/src/components/virtual-scroll/virtual-scroll.tsx @@ -0,0 +1,329 @@ +import { Component, Element, EventListenerEnable, Listen, Method, Prop, Watch } from '@stencil/core'; +import { DomController } from '../../index'; +import { Cell, CellType, DomRenderFn, HeaderFn, ItemHeightFn, ItemRenderFn, NodeHeightFn, + Viewport, VirtualNode, calcHeightIndex, doRender, getBounds, getShouldUpdate, getViewport, updateVDom } from './virtual-scroll-utils'; + + +const MIN_READS = 2; + +@Component({ + tag: 'ion-virtual-scroll', + styleUrl: 'virtual-scroll.scss' +}) +export class VirtualScroll { + + private scrollEl: HTMLElement; + private topIndex = -100; + private bottomIndex = -100; + private timerUpdate: any; + private heightIndex: Uint32Array; + private viewportHeight: number; + private cells: Cell[] = []; + private virtualDom: VirtualNode[] = []; + private isEnabled = false; + private currentScrollTop = 0; + private indexDirty = 0; + private totalHeight = 0; + + @Element() el: HTMLElement; + + @Prop({context: 'dom'}) dom: DomController; + @Prop({context: 'enableListener'}) enableListener: EventListenerEnable; + + + /** + * 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. Default is + * `45`. + */ + @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. Default is `40px`. + */ + @Prop() approxHeaderHeight = 40; + + /** + * 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 before the item has been rendered. Default is `100%`. + */ + @Prop() approxFooterHeight = 40; + + /** + * 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. + * 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. + */ + @Prop() items: any[]; + + @Prop() nodeHeight: NodeHeightFn; + @Prop() itemHeight: ItemHeightFn; + @Prop() itemRender: ItemRenderFn; + @Prop() domRender: DomRenderFn; + + @Watch('itemHeight') + @Watch('items') + itemsChanged() { + this.calcCells(); + } + + componentDidLoad() { + this.scrollEl = this.el.closest('ion-scroll') as HTMLElement; + if (!this.scrollEl) { + console.error('virtual-scroll must be used inside ion-scroll/ion-content'); + return; + } + this.calcDimensions(); + this.calcCells(); + this.updateState(); + } + + componentDidUpdate() { + this.updateState(); + } + + componentDidUnload() { + this.scrollEl = null; + } + + @Listen('scroll', {enabled: false, passive: false}) + onScroll() { + this.updateVirtualScroll(); + } + + @Listen('window:resize') + onResize() { + this.indexDirty = 0; + this.calcDimensions(); + this.calcCells(); + this.updateVirtualScroll(); + } + + @Method() + positionForItem(index: number): number { + const cell = this.cells.find(cell => cell.type === CellType.Item && cell.index === index); + if (cell) { + return this.heightIndex[cell.i]; + } + return -1; + } + + private updateVirtualScroll() { + // do nothing if there is a scheduled update + if (!this.isEnabled) { + return; + } + if (this.timerUpdate) { + clearTimeout(this.timerUpdate); + this.timerUpdate = null; + } + + this.dom.read(() => { + this.currentScrollTop = this.scrollEl.scrollTop; + }); + + this.dom.write(() => { + const dirtyIndex = this.indexDirty; + + // get visible viewport + const viewport = getViewport(this.currentScrollTop, this.viewportHeight, 100); + + // compute lazily the height index + const heightIndex = this.getHeightIndex(viewport); + + // get array bounds of visible cells base in the viewport + const {top, bottom} = getBounds(heightIndex, viewport, 2); + + // fast path, do nothing + const shouldUpdate = getShouldUpdate(dirtyIndex, this.topIndex, this.bottomIndex, top, bottom); + if (!shouldUpdate) { + return; + } + this.topIndex = top; + this.bottomIndex = bottom; + + // in place mutation of the virtual DOM + updateVDom( + this.virtualDom, + heightIndex, + this.cells, + top, + bottom); + + this.fireDomUpdate(); + }); + } + + private fireDomUpdate() { + if (this.itemRender) { + doRender(this.el, this.itemRender, this.virtualDom, this.updateCellHeight.bind(this), this.totalHeight); + } else if (this.domRender) { + this.domRender(this.virtualDom, this.totalHeight); + } + } + + updateCellHeight(cell: Cell, node: HTMLElement) { + (node as any).componentOnReady(() => { + // let's give some additional time to read the height size + setTimeout(() => this.dom.read(() => { + if ((node as any)['$ionCell'] === cell) { + const style = window.getComputedStyle(node); + const height = node.offsetHeight + parseFloat(style.getPropertyValue('margin-bottom')); + this.setCellHeight(cell, height); + } + })); + }); + } + + 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; + } + cell.visible = true; + cell.reads--; + if (cell.height !== height) { + console.debug(`[${cell.reads}] cell size ${cell.height} -> ${height}`); + cell.height = height; + clearTimeout(this.timerUpdate); + this.indexDirty = Math.min(this.indexDirty, index); + this.timerUpdate = setTimeout(() => this.updateVirtualScroll(), 100); + } + } + + private updateState() { + const shouldEnable = !!( + this.scrollEl && + this.items && + (this.itemRender || this.domRender) && + this.viewportHeight > 1 + ); + if (shouldEnable !== this.isEnabled) { + this.enableScrollEvents(shouldEnable); + if (shouldEnable) { + this.updateVirtualScroll(); + } + } + } + + + private calcCells() { + if (!this.items) { + return; + } + const items = this.items; + const cells = this.cells; + const headerFn = this.headerFn; + const footerFn = this.footerFn; + + cells.length = 0; + this.indexDirty = 0; + let j = 0; + + for (let i = 0; i < items.length; i++) { + const item = items[i]; + if (headerFn) { + const value = headerFn(item, i, this.items); + if (value != null) { + cells.push({ + i: j++, + type: CellType.Header, + value: value, + index: i, + height: this.approxHeaderHeight, + reads: MIN_READS, + visible: false, + }); + } + } + + cells.push({ + i: j++, + type: CellType.Item, + value: item, + index: i, + height: this.itemHeight ? this.itemHeight(item, i) : this.approxItemHeight, + reads: this.itemHeight ? 0 : MIN_READS, + visible: !!this.itemHeight, + }); + + if (footerFn) { + const value = footerFn(item, i, this.items); + if (value != null) { + cells.push({ + i: j++, + type: CellType.Footer, + value: value, + index: i, + height: this.approxFooterHeight, + reads: 2, + visible: false, + }); + } + } + } + } + + private getHeightIndex(viewport: Viewport): Uint32Array { + if (this.indexDirty !== Infinity) { + this.calcHeightIndex(this.indexDirty, viewport.bottom); + } + return this.heightIndex; + } + + private calcHeightIndex(index = 0, bottom = Infinity) { + this.heightIndex = calcHeightIndex(this.heightIndex, this.cells, index, bottom); + this.totalHeight = this.heightIndex[this.heightIndex.length - 1]; + this.indexDirty = Infinity; + } + + private calcDimensions() { + this.viewportHeight = this.scrollEl.offsetHeight; + } + + private enableScrollEvents(shouldListen: boolean) { + this.isEnabled = shouldListen; + this.enableListener(this, 'scroll', shouldListen, this.scrollEl); + } +}