mirror of
https://github.com/ionic-team/ionic-framework.git
synced 2025-08-23 05:58:26 +08:00
feat(virtual-scroll): adds virtual-scroll (part 1)
This commit is contained in:
51
packages/core/src/components.d.ts
vendored
51
packages/core/src/components.d.ts
vendored
@ -40,6 +40,13 @@ import {
|
|||||||
import {
|
import {
|
||||||
SelectPopoverOption,
|
SelectPopoverOption,
|
||||||
} from './components/select-popover/select-popover';
|
} from './components/select-popover/select-popover';
|
||||||
|
import {
|
||||||
|
DomRenderFn,
|
||||||
|
HeaderFn,
|
||||||
|
ItemHeightFn,
|
||||||
|
ItemRenderFn,
|
||||||
|
NodeHeightFn,
|
||||||
|
} from './components/virtual-scroll/virtual-scroll-utils';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
ActionSheetController as IonActionSheetController
|
ActionSheetController as IonActionSheetController
|
||||||
@ -1660,7 +1667,7 @@ declare global {
|
|||||||
enterAnimation?: AnimationBuilder;
|
enterAnimation?: AnimationBuilder;
|
||||||
leaveAnimation?: AnimationBuilder;
|
leaveAnimation?: AnimationBuilder;
|
||||||
modalId?: number;
|
modalId?: number;
|
||||||
mode?: string;
|
mode?: 'ios' | 'md';
|
||||||
showBackdrop?: boolean;
|
showBackdrop?: boolean;
|
||||||
willAnimate?: boolean;
|
willAnimate?: boolean;
|
||||||
}
|
}
|
||||||
@ -1925,7 +1932,7 @@ declare global {
|
|||||||
enterAnimation?: AnimationBuilder;
|
enterAnimation?: AnimationBuilder;
|
||||||
ev?: Event;
|
ev?: Event;
|
||||||
leaveAnimation?: AnimationBuilder;
|
leaveAnimation?: AnimationBuilder;
|
||||||
mode?: string;
|
mode?: 'ios' | 'md';
|
||||||
popoverId?: string;
|
popoverId?: string;
|
||||||
showBackdrop?: boolean;
|
showBackdrop?: boolean;
|
||||||
translucent?: 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 {} } }
|
||||||
|
390
packages/core/src/components/virtual-scroll/readme.md
Normal file
390
packages/core/src/components/virtual-scroll/readme.md
Normal file
@ -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
|
||||||
|
<ion-list [virtualScroll]="items">
|
||||||
|
<ion-item *virtualItem="let item">
|
||||||
|
{% raw %}{{ item }}{% endraw %}
|
||||||
|
</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="let header">
|
||||||
|
Header: {% raw %}{{ header }}{% endraw %}
|
||||||
|
</ion-item-divider>
|
||||||
|
<ion-item *virtualItem="let item">
|
||||||
|
Item: {% raw %}{{ item }}{% endraw %}
|
||||||
|
</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
|
||||||
|
|
||||||
|
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 `<ion-img>`
|
||||||
|
to manage HTTP requests and image rendering. While scrolling through items
|
||||||
|
quickly, `<ion-img>` 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 `<img>` are not desirable
|
||||||
|
features. We recommend using the `<ion-img>` component 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. Additionally, `<img>`
|
||||||
|
renders whenever it wants which could be while the user is scrolling. However,
|
||||||
|
`<ion-img>` is governed by the containing `ion-content` and does not render
|
||||||
|
images while scrolling quickly.
|
||||||
|
|
||||||
|
```html
|
||||||
|
<ion-list [virtualScroll]="items">
|
||||||
|
<ion-item *virtualItem="let item">
|
||||||
|
<ion-avatar item-start>
|
||||||
|
<ion-img [src]="item.avatarUrl"></ion-img>
|
||||||
|
</ion-avatar>
|
||||||
|
{% raw %} {{ item.firstName }} {{ item.lastName }}{% endraw %}
|
||||||
|
</ion-item>
|
||||||
|
</ion-list>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Custom Components
|
||||||
|
|
||||||
|
If a custom component is going to be used within Virtual Scroll, it's best
|
||||||
|
to wrap it with a good old `<div>` to ensure the component is rendered
|
||||||
|
correctly. Since each custom component's implementation and internals can be
|
||||||
|
quite different, wrapping within a `<div>` is a safe way to make sure
|
||||||
|
dimensions are measured correctly.
|
||||||
|
|
||||||
|
```html
|
||||||
|
<ion-list [virtualScroll]="items">
|
||||||
|
<div *virtualItem="let item">
|
||||||
|
<my-custom-item [item]="item">
|
||||||
|
{% raw %} {{ item }}{% endraw %}
|
||||||
|
</my-custom-item>
|
||||||
|
</div>
|
||||||
|
</ion-list>
|
||||||
|
```
|
||||||
|
|
||||||
|
## 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 `<img>` 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.
|
||||||
|
|
||||||
|
|
||||||
|
<!-- Auto Generated Below -->
|
||||||
|
|
||||||
|
|
||||||
|
## 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/)*
|
66
packages/core/src/components/virtual-scroll/test/basic.html
Normal file
66
packages/core/src/components/virtual-scroll/test/basic.html
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html dir="ltr">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Ionic Item Sliding</title>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||||
|
<script src="/dist/ionic.js"></script>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<ion-app>
|
||||||
|
<ion-page>
|
||||||
|
|
||||||
|
<ion-header>
|
||||||
|
<ion-toolbar>
|
||||||
|
<ion-title>Ionic CDN demo</ion-title>
|
||||||
|
</ion-toolbar>
|
||||||
|
</ion-header>
|
||||||
|
|
||||||
|
<ion-content>
|
||||||
|
<ion-virtual-scroll id="virtual"></ion-virtual-scroll>
|
||||||
|
</ion-content>
|
||||||
|
|
||||||
|
</ion-page>
|
||||||
|
|
||||||
|
</ion-app>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
async function init() {
|
||||||
|
const virtual = await document.getElementById('virtual').componentOnReady();
|
||||||
|
virtual.itemHeight = () => 45;
|
||||||
|
virtual.headerFn = (item, index) => {
|
||||||
|
if (index % 20 === 0) {
|
||||||
|
return 'Header ' + index;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderItem(el, item) {
|
||||||
|
if (!el) {
|
||||||
|
el = document.createElement('ion-item');
|
||||||
|
}
|
||||||
|
el.textContent = item;
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderHeader(el, item) {
|
||||||
|
if (!el) {
|
||||||
|
el = document.createElement('ion-item-divider');
|
||||||
|
}
|
||||||
|
el.textContent = item;
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
|
virtual.itemRender = (el, item, type) => {
|
||||||
|
if (type === 0) return renderItem(el, item);
|
||||||
|
return renderHeader(el, item);
|
||||||
|
};
|
||||||
|
virtual.items = Array.from({length: 1000}, (x, i) => i);
|
||||||
|
};
|
||||||
|
setTimeout(init, 200);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
105
packages/core/src/components/virtual-scroll/test/cards.html
Normal file
105
packages/core/src/components/virtual-scroll/test/cards.html
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html dir="ltr">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Ionic Item Sliding</title>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||||
|
<script src="/dist/ionic.js"></script>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<ion-app>
|
||||||
|
<ion-page>
|
||||||
|
|
||||||
|
<ion-header>
|
||||||
|
<ion-toolbar>
|
||||||
|
<ion-title>Ionic CDN demo</ion-title>
|
||||||
|
</ion-toolbar>
|
||||||
|
</ion-header>
|
||||||
|
|
||||||
|
<ion-content>
|
||||||
|
<ion-virtual-scroll id="virtual"></ion-virtual-scroll>
|
||||||
|
</ion-content>
|
||||||
|
|
||||||
|
</ion-page>
|
||||||
|
|
||||||
|
</ion-app>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
|
||||||
|
|
||||||
|
async function init() {
|
||||||
|
const virtual = await document.getElementById('virtual').componentOnReady();
|
||||||
|
virtual.approxItemHeight = 200;
|
||||||
|
// virtual.itemHeight = (item) => 69 + 40 + 22 * Math.ceil(item.content.length/30);
|
||||||
|
virtual.headerFn = (item, index) => {
|
||||||
|
if (index % 20 === 0) {
|
||||||
|
return 'Header ' + index;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function renderItem(el, item) {
|
||||||
|
if (!el) {
|
||||||
|
el = document.createElement('ion-card');
|
||||||
|
}
|
||||||
|
el.innerHTML = `
|
||||||
|
<ion-card-header>
|
||||||
|
<ion-card-title>Card Header</ion-card-title>
|
||||||
|
</ion-card-header>
|
||||||
|
<ion-card-content>${item.content}</ion-card-content>`;
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderHeader(el, item) {
|
||||||
|
if (!el) {
|
||||||
|
el = document.createElement('ion-item-divider');
|
||||||
|
}
|
||||||
|
el.textContent = item;
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
|
virtual.itemRender = (el, item, type) => {
|
||||||
|
if (type === 0) return renderItem(el, item);
|
||||||
|
return renderHeader(el, item);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
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 = [
|
||||||
|
"http://images.all-free-download.com/images/graphiclarge/travel_icons_6813629.jpg",
|
||||||
|
"https://images.unsplash.com/photo-1500531279542-fc8490c8ea4d?auto=format&fit=crop&w=1502&q=60&ixid=dW5zcGxhc2guY29tOzs7Ozs%3D",
|
||||||
|
"https://images.unsplash.com/photo-1483354483454-4cd359948304?dpr=1&auto=format&fit=crop&w=1000&q=80&cs=tinysrgb&ixid=dW5zcGxhc2guY29tOzs7Ozs%3D",
|
||||||
|
];
|
||||||
|
|
||||||
|
function getImgSrc() {
|
||||||
|
let src = images[rotateImg];
|
||||||
|
rotateImg++;
|
||||||
|
if (rotateImg === images.length) rotateImg = 0;
|
||||||
|
return src;
|
||||||
|
}
|
||||||
|
|
||||||
|
let rotateImg = 0;
|
||||||
|
|
||||||
|
const items = [];
|
||||||
|
for (var i = 0; i < 1000; i++) {
|
||||||
|
items.push({
|
||||||
|
name: i + ' - ' + images[rotateImg],
|
||||||
|
imgSrc: getImgSrc(),
|
||||||
|
avatarSrc: getImgSrc(),
|
||||||
|
imgHeight: Math.floor((Math.random() * 50) + 150),
|
||||||
|
content: lorem.substring(0, (Math.random() * (lorem.length - 100)) + 100)
|
||||||
|
});
|
||||||
|
rotateImg++;
|
||||||
|
if (rotateImg === images.length) rotateImg = 0;
|
||||||
|
}
|
||||||
|
virtual.items = items;
|
||||||
|
};
|
||||||
|
setTimeout(init, 200);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
329
packages/core/src/components/virtual-scroll/virtual-scroll.tsx
Normal file
329
packages/core/src/components/virtual-scroll/virtual-scroll.tsx
Normal file
@ -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);
|
||||||
|
}
|
||||||
|
}
|
Reference in New Issue
Block a user