mirror of
https://github.com/ionic-team/ionic-framework.git
synced 2025-08-17 02:31:34 +08:00
feat(infinite-scroll): adds infinite-scroll
This commit is contained in:
93
packages/core/src/components.d.ts
vendored
93
packages/core/src/components.d.ts
vendored
@ -596,10 +596,7 @@ declare global {
|
|||||||
mode?: string,
|
mode?: string,
|
||||||
color?: string,
|
color?: string,
|
||||||
|
|
||||||
ionScrollStart?: any,
|
fullscreen?: boolean | "true" | "false"
|
||||||
ionScroll?: any,
|
|
||||||
ionScrollEnd?: any,
|
|
||||||
fullscreen?: boolean
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -629,14 +626,13 @@ declare global {
|
|||||||
mode?: string,
|
mode?: string,
|
||||||
color?: string,
|
color?: string,
|
||||||
|
|
||||||
pickerCtrl?: any,
|
disabled?: boolean | "true" | "false",
|
||||||
disabled?: boolean,
|
min?: any,
|
||||||
min?: string,
|
max?: any,
|
||||||
max?: string,
|
displayFormat?: any,
|
||||||
displayFormat?: string,
|
pickerFormat?: any,
|
||||||
pickerFormat?: string,
|
cancelText?: any,
|
||||||
cancelText?: string,
|
doneText?: any,
|
||||||
doneText?: string,
|
|
||||||
yearValues?: any,
|
yearValues?: any,
|
||||||
monthValues?: any,
|
monthValues?: any,
|
||||||
dayValues?: any,
|
dayValues?: any,
|
||||||
@ -647,8 +643,7 @@ declare global {
|
|||||||
dayNames?: any,
|
dayNames?: any,
|
||||||
dayShortNames?: any,
|
dayShortNames?: any,
|
||||||
pickerOptions?: any,
|
pickerOptions?: any,
|
||||||
placeholder?: string,
|
placeholder?: any
|
||||||
value?: string
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -997,6 +992,66 @@ declare global {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
import { InfiniteScrollContent as IonInfiniteScrollContent } from './components/infinite-scroll/infinite-scroll-content';
|
||||||
|
|
||||||
|
interface HTMLIonInfiniteScrollContentElement extends IonInfiniteScrollContent, HTMLElement {
|
||||||
|
}
|
||||||
|
declare var HTMLIonInfiniteScrollContentElement: {
|
||||||
|
prototype: HTMLIonInfiniteScrollContentElement;
|
||||||
|
new (): HTMLIonInfiniteScrollContentElement;
|
||||||
|
};
|
||||||
|
declare global {
|
||||||
|
interface HTMLElementTagNameMap {
|
||||||
|
"ion-infinite-scroll-content": HTMLIonInfiniteScrollContentElement;
|
||||||
|
}
|
||||||
|
interface ElementTagNameMap {
|
||||||
|
"ion-infinite-scroll-content": HTMLIonInfiniteScrollContentElement;
|
||||||
|
}
|
||||||
|
namespace JSX {
|
||||||
|
interface IntrinsicElements {
|
||||||
|
"ion-infinite-scroll-content": JSXElements.IonInfiniteScrollContentAttributes;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
namespace JSXElements {
|
||||||
|
export interface IonInfiniteScrollContentAttributes extends HTMLAttributes {
|
||||||
|
|
||||||
|
loadingSpinner?: any,
|
||||||
|
loadingText?: any
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
import { InfiniteScroll as IonInfiniteScroll } from './components/infinite-scroll/infinite-scroll';
|
||||||
|
|
||||||
|
interface HTMLIonInfiniteScrollElement extends IonInfiniteScroll, HTMLElement {
|
||||||
|
}
|
||||||
|
declare var HTMLIonInfiniteScrollElement: {
|
||||||
|
prototype: HTMLIonInfiniteScrollElement;
|
||||||
|
new (): HTMLIonInfiniteScrollElement;
|
||||||
|
};
|
||||||
|
declare global {
|
||||||
|
interface HTMLElementTagNameMap {
|
||||||
|
"ion-infinite-scroll": HTMLIonInfiniteScrollElement;
|
||||||
|
}
|
||||||
|
interface ElementTagNameMap {
|
||||||
|
"ion-infinite-scroll": HTMLIonInfiniteScrollElement;
|
||||||
|
}
|
||||||
|
namespace JSX {
|
||||||
|
interface IntrinsicElements {
|
||||||
|
"ion-infinite-scroll": JSXElements.IonInfiniteScrollAttributes;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
namespace JSXElements {
|
||||||
|
export interface IonInfiniteScrollAttributes extends HTMLAttributes {
|
||||||
|
|
||||||
|
complete?: any,
|
||||||
|
threshold?: any,
|
||||||
|
enabled?: boolean | "true" | "false",
|
||||||
|
position?: any
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
import { Input as IonInput } from './components/input/input';
|
import { Input as IonInput } from './components/input/input';
|
||||||
|
|
||||||
interface HTMLIonInputElement extends IonInput, HTMLElement {
|
interface HTMLIonInputElement extends IonInput, HTMLElement {
|
||||||
@ -2346,11 +2401,11 @@ declare global {
|
|||||||
mode?: string,
|
mode?: string,
|
||||||
color?: string,
|
color?: string,
|
||||||
|
|
||||||
enabled?: boolean,
|
enabled?: boolean | "true" | "false",
|
||||||
jsScroll?: boolean,
|
jsScroll?: boolean | "true" | "false",
|
||||||
ionScrollStart?: any,
|
onionScrollStart?: any,
|
||||||
ionScroll?: any,
|
onionScroll?: any,
|
||||||
ionScrollEnd?: any
|
onionScrollEnd?: any
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -24,21 +24,6 @@ export class Content {
|
|||||||
$siblingHeader: HTMLElement;
|
$siblingHeader: HTMLElement;
|
||||||
$siblingFooter: HTMLElement;
|
$siblingFooter: HTMLElement;
|
||||||
|
|
||||||
/**
|
|
||||||
* @output {ScrollEvent} Emitted when the scrolling first starts.
|
|
||||||
*/
|
|
||||||
@Prop() ionScrollStart: Function;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @output {ScrollEvent} Emitted on every scroll event.
|
|
||||||
*/
|
|
||||||
@Prop() ionScroll: Function;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @output {ScrollEvent} Emitted when scrolling ends.
|
|
||||||
*/
|
|
||||||
@Prop() ionScrollEnd: Function;
|
|
||||||
|
|
||||||
headerHeight: string;
|
headerHeight: string;
|
||||||
|
|
||||||
|
|
||||||
@ -79,7 +64,6 @@ export class Content {
|
|||||||
|
|
||||||
|
|
||||||
protected render() {
|
protected render() {
|
||||||
const props: any = {};
|
|
||||||
const scrollStyle: any = {};
|
const scrollStyle: any = {};
|
||||||
|
|
||||||
const pageChildren: HTMLElement[] = getParentElement(this.el).children;
|
const pageChildren: HTMLElement[] = getParentElement(this.el).children;
|
||||||
@ -94,16 +78,6 @@ export class Content {
|
|||||||
scrollStyle.marginBottom = footerHeight;
|
scrollStyle.marginBottom = footerHeight;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.ionScrollStart) {
|
|
||||||
props['ionScrollStart'] = this.ionScrollStart.bind(this);
|
|
||||||
}
|
|
||||||
if (this.ionScroll) {
|
|
||||||
props['ionScroll'] = this.ionScroll.bind(this);
|
|
||||||
}
|
|
||||||
if (this.ionScrollEnd) {
|
|
||||||
props['ionScrollEnd'] = this.ionScrollEnd.bind(this);
|
|
||||||
}
|
|
||||||
|
|
||||||
const themedClasses = createThemedClasses(this.mode, this.color, 'content');
|
const themedClasses = createThemedClasses(this.mode, this.color, 'content');
|
||||||
const hostClasses = getElementClassObject(this.el.classList);
|
const hostClasses = getElementClassObject(this.el.classList);
|
||||||
|
|
||||||
@ -114,7 +88,7 @@ export class Content {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ion-scroll {...props} style={scrollStyle} class={scrollClasses}>
|
<ion-scroll style={scrollStyle} class={scrollClasses}>
|
||||||
<slot></slot>
|
<slot></slot>
|
||||||
</ion-scroll>
|
</ion-scroll>
|
||||||
);
|
);
|
||||||
|
@ -0,0 +1,45 @@
|
|||||||
|
import { Component, Prop } from '@stencil/core';
|
||||||
|
import { Config } from '../../index';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @hidden
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
tag: 'ion-infinite-scroll-content'
|
||||||
|
})
|
||||||
|
export class InfiniteScrollContent {
|
||||||
|
|
||||||
|
@Prop({ context: 'config' }) config: Config;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @input {string} An animated SVG spinner that shows while loading.
|
||||||
|
*/
|
||||||
|
@Prop({mutable: true}) loadingSpinner: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @input {string} Optional text to display while loading.
|
||||||
|
*/
|
||||||
|
@Prop() loadingText: string;
|
||||||
|
|
||||||
|
|
||||||
|
protected ionViewDidLoad() {
|
||||||
|
if (!this.loadingSpinner) {
|
||||||
|
this.loadingSpinner = this.config.get('infiniteLoadingSpinner', this.config.get('spinner', 'lines'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected render() {
|
||||||
|
return (
|
||||||
|
<div class='infinite-loading'>
|
||||||
|
{this.loadingSpinner &&
|
||||||
|
<div class='infinite-loading-spinner'>
|
||||||
|
<ion-spinner name={this.loadingSpinner}></ion-spinner>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
{this.loadingText &&
|
||||||
|
<div class='infinite-loading-text' innerHTML={this.loadingText}></div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,106 @@
|
|||||||
|
@import "../../themes/ionic.globals";
|
||||||
|
|
||||||
|
// Infinite Scroll
|
||||||
|
// --------------------------------------------------
|
||||||
|
|
||||||
|
// deprecated
|
||||||
|
$infinite-scroll-loading-margin: null !default;
|
||||||
|
|
||||||
|
/// @prop - Minimun height of ion-infinite-scroll-content
|
||||||
|
$infinite-scroll-content-min-height: 84px !default;
|
||||||
|
|
||||||
|
/// @prop - Margin top of the infinite scroll loading icon
|
||||||
|
$infinite-scroll-loading-margin-top: 0 !default;
|
||||||
|
|
||||||
|
/// @prop - Margin end of the infinite scroll loading icon
|
||||||
|
$infinite-scroll-loading-margin-end: 0 !default;
|
||||||
|
|
||||||
|
/// @prop - Margin bottom of the infinite scroll loading icon
|
||||||
|
$infinite-scroll-loading-margin-bottom: 32px !default;
|
||||||
|
|
||||||
|
/// @prop - Margin start of the infinite scroll loading icon
|
||||||
|
$infinite-scroll-loading-margin-start: 0 !default;
|
||||||
|
|
||||||
|
/// @prop - Color of the infinite scroll loading indicator
|
||||||
|
$infinite-scroll-loading-color: #666 !default;
|
||||||
|
|
||||||
|
/// @prop - Text color of the infinite scroll loading indicator
|
||||||
|
$infinite-scroll-loading-text-color: $infinite-scroll-loading-color !default;
|
||||||
|
|
||||||
|
// deprecated
|
||||||
|
$infinite-scroll-loading-text-margin: null !default;
|
||||||
|
|
||||||
|
/// @prop - Margin top of the infinite scroll loading text
|
||||||
|
$infinite-scroll-loading-text-margin-top: 4px !default;
|
||||||
|
|
||||||
|
/// @prop - Margin end of the infinite scroll loading text
|
||||||
|
$infinite-scroll-loading-text-margin-end: 32px !default;
|
||||||
|
|
||||||
|
/// @prop - Margin bottom of the infinite scroll loading text
|
||||||
|
$infinite-scroll-loading-text-margin-bottom:0 !default;
|
||||||
|
|
||||||
|
/// @prop - Margin start of the infinite scroll loading text
|
||||||
|
$infinite-scroll-loading-text-margin-start: 32px !default;
|
||||||
|
|
||||||
|
ion-infinite-scroll {
|
||||||
|
display: none;
|
||||||
|
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.infinite-scroll-enabled {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Infinite Scroll Content
|
||||||
|
// --------------------------------------------------
|
||||||
|
|
||||||
|
ion-infinite-scroll-content {
|
||||||
|
@include text-align(center);
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
min-height: $infinite-scroll-content-min-height;
|
||||||
|
}
|
||||||
|
|
||||||
|
.infinite-loading {
|
||||||
|
display: none;
|
||||||
|
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
@include deprecated-variable(margin, $infinite-scroll-loading-margin) {
|
||||||
|
@include margin($infinite-scroll-loading-margin-top, $infinite-scroll-loading-margin-end, $infinite-scroll-loading-margin-bottom, $infinite-scroll-loading-margin-start);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.infinite-loading-text {
|
||||||
|
color: $infinite-scroll-loading-text-color;
|
||||||
|
|
||||||
|
@include deprecated-variable(margin, $infinite-scroll-loading-text-margin) {
|
||||||
|
@include margin($infinite-scroll-loading-text-margin-top, $infinite-scroll-loading-text-margin-end, $infinite-scroll-loading-text-margin-bottom, $infinite-scroll-loading-text-margin-start);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.infinite-loading-spinner .spinner-ios line,
|
||||||
|
.infinite-loading-spinner .spinner-ios-small line,
|
||||||
|
.infinite-loading-spinner .spinner-crescent circle {
|
||||||
|
stroke: $infinite-scroll-loading-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
.infinite-loading-spinner .spinner-bubbles circle,
|
||||||
|
.infinite-loading-spinner .spinner-circles circle,
|
||||||
|
.infinite-loading-spinner .spinner-dots circle {
|
||||||
|
fill: $infinite-scroll-loading-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Infinite Scroll Content States
|
||||||
|
// --------------------------------------------------
|
||||||
|
.infinite-scroll-loading ion-infinite-scroll-content > .infinite-loading {
|
||||||
|
display: block;
|
||||||
|
}
|
386
packages/core/src/components/infinite-scroll/infinite-scroll.tsx
Normal file
386
packages/core/src/components/infinite-scroll/infinite-scroll.tsx
Normal file
@ -0,0 +1,386 @@
|
|||||||
|
import { Component, Element, Event, EventEmitter, HostElement, Method, Prop, PropDidChange, State } from '@stencil/core';
|
||||||
|
import { ScrollDetail } from '../../index';
|
||||||
|
|
||||||
|
const enum Position {
|
||||||
|
Top = 'top',
|
||||||
|
Bottom = 'bottom',
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @name InfiniteScroll
|
||||||
|
* @description
|
||||||
|
* The Infinite Scroll allows you to perform an action when the user
|
||||||
|
* scrolls a specified distance from the bottom or top of the page.
|
||||||
|
*
|
||||||
|
* The expression assigned to the `infinite` event is called when
|
||||||
|
* the user scrolls to the specified distance. When this expression
|
||||||
|
* has finished its tasks, it should call the `complete()` method
|
||||||
|
* on the infinite scroll instance.
|
||||||
|
*
|
||||||
|
* @usage
|
||||||
|
* ```html
|
||||||
|
* <ion-content>
|
||||||
|
*
|
||||||
|
* <ion-list>
|
||||||
|
* <ion-item *ngFor="let i of items">{% raw %}{{i}}{% endraw %}</ion-item>
|
||||||
|
* </ion-list>
|
||||||
|
*
|
||||||
|
* <ion-infinite-scroll (ionInfinite)="doInfinite($event)">
|
||||||
|
* <ion-infinite-scroll-content></ion-infinite-scroll-content>
|
||||||
|
* </ion-infinite-scroll>
|
||||||
|
*
|
||||||
|
* </ion-content>
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* ```ts
|
||||||
|
* @Component({...})
|
||||||
|
* export class NewsFeedPage {
|
||||||
|
* items = [];
|
||||||
|
*
|
||||||
|
* constructor() {
|
||||||
|
* for (let i = 0; i < 30; i++) {
|
||||||
|
* this.items.push( this.items.length );
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* doInfinite(infiniteScroll) {
|
||||||
|
* console.log('Begin async operation');
|
||||||
|
*
|
||||||
|
* setTimeout(() => {
|
||||||
|
* for (let i = 0; i < 30; i++) {
|
||||||
|
* this.items.push( this.items.length );
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* console.log('Async operation has ended');
|
||||||
|
* infiniteScroll.complete();
|
||||||
|
* }, 500);
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* ## `waitFor` method of InfiniteScroll
|
||||||
|
*
|
||||||
|
* In case if your async operation returns promise you can utilize
|
||||||
|
* `waitFor` method inside your template.
|
||||||
|
*
|
||||||
|
* ```html
|
||||||
|
* <ion-content>
|
||||||
|
*
|
||||||
|
* <ion-list>
|
||||||
|
* <ion-item *ngFor="let item of items">{{item}}</ion-item>
|
||||||
|
* </ion-list>
|
||||||
|
*
|
||||||
|
* <ion-infinite-scroll (ionInfinite)="$event.waitFor(doInfinite())">
|
||||||
|
* <ion-infinite-scroll-content></ion-infinite-scroll-content>
|
||||||
|
* </ion-infinite-scroll>
|
||||||
|
*
|
||||||
|
* </ion-content>
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* ```ts
|
||||||
|
* @Component({...})
|
||||||
|
* export class NewsFeedPage {
|
||||||
|
* items = [];
|
||||||
|
*
|
||||||
|
* constructor() {
|
||||||
|
* for (var i = 0; i < 30; i++) {
|
||||||
|
* this.items.push( this.items.length );
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* doInfinite(): Promise<any> {
|
||||||
|
* console.log('Begin async operation');
|
||||||
|
*
|
||||||
|
* return new Promise((resolve) => {
|
||||||
|
* setTimeout(() => {
|
||||||
|
* for (var i = 0; i < 30; i++) {
|
||||||
|
* this.items.push( this.items.length );
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* console.log('Async operation has ended');
|
||||||
|
* resolve();
|
||||||
|
* }, 500);
|
||||||
|
* })
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* ## Infinite Scroll Content
|
||||||
|
*
|
||||||
|
* By default, Ionic uses the infinite scroll spinner that looks
|
||||||
|
* best for the platform the user is on. However, you can change the
|
||||||
|
* default spinner or add text by adding properties to the
|
||||||
|
* `ion-infinite-scroll-content` component.
|
||||||
|
*
|
||||||
|
* ```html
|
||||||
|
* <ion-content>
|
||||||
|
*
|
||||||
|
* <ion-infinite-scroll (ionInfinite)="doInfinite($event)">
|
||||||
|
* <ion-infinite-scroll-content
|
||||||
|
* loadingSpinner="bubbles"
|
||||||
|
* loadingText="Loading more data...">
|
||||||
|
* </ion-infinite-scroll-content>
|
||||||
|
* </ion-infinite-scroll>
|
||||||
|
*
|
||||||
|
* </ion-content>
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
*
|
||||||
|
* ## Further Customizing Infinite Scroll Content
|
||||||
|
*
|
||||||
|
* The `ion-infinite-scroll` component holds the infinite scroll logic.
|
||||||
|
* It requires a child component in order to display the content.
|
||||||
|
* Ionic uses `ion-infinite-scroll-content` by default. This component
|
||||||
|
* displays the infinite scroll and changes the look depending
|
||||||
|
* on the infinite scroll's state. Separating these components allows
|
||||||
|
* developers to create their own infinite scroll content components.
|
||||||
|
* You could replace our default content with custom SVG or CSS animations.
|
||||||
|
*
|
||||||
|
* @demo /docs/demos/src/infinite-scroll/
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
tag: 'ion-infinite-scroll',
|
||||||
|
styleUrl: 'infinite-scroll.scss'
|
||||||
|
})
|
||||||
|
export class InfiniteScroll {
|
||||||
|
|
||||||
|
private rmListener: Function;
|
||||||
|
private thrPx: number = 0;
|
||||||
|
private thrPc: number = 0.15;
|
||||||
|
private init: boolean = false;
|
||||||
|
private scrollEl: HTMLElement;
|
||||||
|
private didFire = false;
|
||||||
|
private isBusy = false;
|
||||||
|
|
||||||
|
@Element() private el: HTMLElement;
|
||||||
|
@State() isLoading: boolean = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @input {string} The threshold distance from the bottom
|
||||||
|
* of the content to call the `infinite` output event when scrolled.
|
||||||
|
* The threshold value can be either a percent, or
|
||||||
|
* in pixels. For example, use the value of `10%` for the `infinite`
|
||||||
|
* output event to get called when the user has scrolled 10%
|
||||||
|
* from the bottom of the page. Use the value `100px` when the
|
||||||
|
* scroll is within 100 pixels from the bottom of the page.
|
||||||
|
* Default is `15%`.
|
||||||
|
*/
|
||||||
|
@Prop() threshold: string = '15%';
|
||||||
|
@PropDidChange('threshold')
|
||||||
|
thresholdChanged(val: string) {
|
||||||
|
if (val.lastIndexOf('%') > -1) {
|
||||||
|
this.thrPx = 0;
|
||||||
|
this.thrPc = (parseFloat(val) / 100);
|
||||||
|
|
||||||
|
} else {
|
||||||
|
this.thrPx = parseFloat(val);
|
||||||
|
this.thrPc = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @input {boolean} If true, Whether or not the infinite scroll should be
|
||||||
|
* enabled or not. Setting to `false` will remove scroll event listeners
|
||||||
|
* and hide the display.
|
||||||
|
*
|
||||||
|
* Call `enable(false)` to disable the infinite scroll from actively
|
||||||
|
* trying to receive new data while scrolling. This method is useful
|
||||||
|
* when it is known that there is no more data that can be added, and
|
||||||
|
* the infinite scroll is no longer needed.
|
||||||
|
* @param {boolean} shouldEnable If the infinite scroll should be
|
||||||
|
* enabled or not. Setting to `false` will remove scroll event listeners
|
||||||
|
* and hide the display.
|
||||||
|
*/
|
||||||
|
@Prop() enabled: boolean = true;
|
||||||
|
@PropDidChange('enabled')
|
||||||
|
enabledChanged(val: boolean) {
|
||||||
|
this.enableScrollEvents(val);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @input {string} The position of the infinite scroll element.
|
||||||
|
* The value can be either `top` or `bottom`.
|
||||||
|
* Default is `bottom`.
|
||||||
|
*/
|
||||||
|
@Prop() position: Position = Position.Bottom;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @output {event} Emitted when the scroll reaches
|
||||||
|
* the threshold distance. From within your infinite handler,
|
||||||
|
* you must call the infinite scroll's `complete()` method when
|
||||||
|
* your async operation has completed.
|
||||||
|
*/
|
||||||
|
@Event() private ionInfinite: EventEmitter;
|
||||||
|
|
||||||
|
ionViewDidLoad() {
|
||||||
|
const scrollEl = this.scrollEl = this.el.closest('ion-scroll') as HostElement;
|
||||||
|
if (!scrollEl) {
|
||||||
|
console.error('ion-infinite-scroll must be used ion-content');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.init = true;
|
||||||
|
this.enableScrollEvents(this.enabled);
|
||||||
|
if (this.position === Position.Top) {
|
||||||
|
// scrollEl.scrollDownOnLoad = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ionViewDidUnload() {
|
||||||
|
this.enableScrollEvents(false);
|
||||||
|
this.scrollEl = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ******** DOM READ ****************
|
||||||
|
private onScroll(ev: CustomEvent) {
|
||||||
|
const detail = ev.detail as ScrollDetail;
|
||||||
|
if (!this.canStart()) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const infiniteHeight = this.el.offsetHeight;
|
||||||
|
if (!infiniteHeight) {
|
||||||
|
// if there is no height of this element then do nothing
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
const scrollTop = detail.scrollTop;
|
||||||
|
const scrollHeight = this.scrollEl.scrollHeight;
|
||||||
|
const height = this.scrollEl.offsetHeight;
|
||||||
|
const threshold = this.thrPc ? (height * this.thrPc) : this.thrPx;
|
||||||
|
|
||||||
|
let distanceFromInfinite: number;
|
||||||
|
|
||||||
|
if (this.position === Position.Bottom) {
|
||||||
|
distanceFromInfinite = scrollHeight - infiniteHeight - scrollTop - threshold - height;
|
||||||
|
} else {
|
||||||
|
// assert(this.position === Position.Top, '_position should be top');
|
||||||
|
distanceFromInfinite = scrollTop - infiniteHeight - threshold;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (distanceFromInfinite < 0) {
|
||||||
|
if (!this.didFire) {
|
||||||
|
this.isLoading = true;
|
||||||
|
this.didFire = true;
|
||||||
|
this.ionInfinite.emit(this);
|
||||||
|
return 3;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.didFire = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
private canStart(): boolean {
|
||||||
|
return (
|
||||||
|
this.enabled &&
|
||||||
|
!this.isBusy &&
|
||||||
|
this.scrollEl &&
|
||||||
|
!this.isLoading);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Call `complete()` within the `infinite` output event handler when
|
||||||
|
* your async operation has completed. For example, the `loading`
|
||||||
|
* state is while the app is performing an asynchronous operation,
|
||||||
|
* such as receiving more data from an AJAX request to add more items
|
||||||
|
* to a data list. Once the data has been received and UI updated, you
|
||||||
|
* then call this method to signify that the loading has completed.
|
||||||
|
* This method will change the infinite scroll's state from `loading`
|
||||||
|
* to `enabled`.
|
||||||
|
*/
|
||||||
|
@Method()
|
||||||
|
complete() {
|
||||||
|
if (!this.isLoading) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.isLoading = false;
|
||||||
|
|
||||||
|
if (this.position === Position.Top) {
|
||||||
|
/** New content is being added at the top, but the scrollTop position stays the same,
|
||||||
|
* which causes a scroll jump visually. This algorithm makes sure to prevent this.
|
||||||
|
* (Frame 1)
|
||||||
|
* - complete() is called, but the UI hasn't had time to update yet.
|
||||||
|
* - Save the current content dimensions.
|
||||||
|
* - Wait for the next frame using _dom.read, so the UI will be updated.
|
||||||
|
* (Frame 2)
|
||||||
|
* - Read the new content dimensions.
|
||||||
|
* - Calculate the height difference and the new scroll position.
|
||||||
|
* - Delay the scroll position change until other possible dom reads are done using _dom.write to be performant.
|
||||||
|
* (Still frame 2, if I'm correct)
|
||||||
|
* - Change the scroll position (= visually maintain the scroll position).
|
||||||
|
* - Change the state to re-enable the InfiniteScroll.
|
||||||
|
* - This should be after changing the scroll position, or it could
|
||||||
|
* cause the InfiniteScroll to be triggered again immediately.
|
||||||
|
* (Frame 3)
|
||||||
|
* Done.
|
||||||
|
*/
|
||||||
|
this.isBusy = true;
|
||||||
|
// ******** DOM READ ****************
|
||||||
|
// Save the current content dimensions before the UI updates
|
||||||
|
const prev = this.scrollEl.scrollHeight - this.scrollEl.scrollTop;
|
||||||
|
|
||||||
|
// ******** DOM READ ****************
|
||||||
|
Context.dom.read(() => {
|
||||||
|
// UI has updated, save the new content dimensions
|
||||||
|
const scrollHeight = this.scrollEl.scrollHeight;
|
||||||
|
// New content was added on top, so the scroll position should be changed immediately to prevent it from jumping around
|
||||||
|
const newScrollTop = scrollHeight - prev;
|
||||||
|
|
||||||
|
// ******** DOM WRITE ****************
|
||||||
|
Context.dom.write(() => {
|
||||||
|
this.scrollEl.scrollTop = newScrollTop;
|
||||||
|
this.isBusy = false;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pass a promise inside `waitFor()` within the `infinite` output event handler in order to
|
||||||
|
* change state of infiniteScroll to "complete"
|
||||||
|
*/
|
||||||
|
waitFor(action: Promise<any>) {
|
||||||
|
const enable = this.complete.bind(this);
|
||||||
|
action.then(enable, enable);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @hidden
|
||||||
|
*/
|
||||||
|
private enableScrollEvents(shouldListen: boolean) {
|
||||||
|
if (!this.init) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (shouldListen) {
|
||||||
|
if (!this.rmListener) {
|
||||||
|
const onScroll = this.onScroll.bind(this);
|
||||||
|
this.scrollEl.addEventListener('ionScroll', onScroll);
|
||||||
|
this.rmListener = () => {
|
||||||
|
this.scrollEl.removeEventListener('ionScroll', onScroll);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.rmListener && this.rmListener();
|
||||||
|
this.rmListener = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
hostData() {
|
||||||
|
return {
|
||||||
|
class: {
|
||||||
|
'infinite-scroll-loading': this.isLoading,
|
||||||
|
'infinite-scroll-enabled': this.enabled
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
protected render() {
|
||||||
|
return <slot></slot>;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
90
packages/core/src/components/infinite-scroll/test/basic.html
Normal file
90
packages/core/src/components/infinite-scroll/test/basic.html
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
<!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 main class="show-page">
|
||||||
|
|
||||||
|
<ion-header>
|
||||||
|
<ion-toolbar>
|
||||||
|
<ion-title>Ionic CDN demo</ion-title>
|
||||||
|
</ion-toolbar>
|
||||||
|
</ion-header>
|
||||||
|
|
||||||
|
<ion-content padding>
|
||||||
|
|
||||||
|
<ion-button onclick="toggleInfiniteScroll()" block>
|
||||||
|
Toggle InfiniteScroll Enabled
|
||||||
|
</ion-button>
|
||||||
|
|
||||||
|
<ion-list id="list">
|
||||||
|
|
||||||
|
</ion-list>
|
||||||
|
|
||||||
|
<ion-infinite-scroll threshold="100px" id="infinite-scroll">
|
||||||
|
<ion-infinite-scroll-content
|
||||||
|
loadingSpinner="bubbles"
|
||||||
|
loadingText="Loading more data...">
|
||||||
|
</ion-infinite-scroll-content>
|
||||||
|
</ion-infinite-scroll>
|
||||||
|
</ion-content>
|
||||||
|
|
||||||
|
</ion-page>
|
||||||
|
|
||||||
|
</ion-app>
|
||||||
|
<ion-menu-controller></ion-menu-controller>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
let items = [];
|
||||||
|
for (var i = 0; i < 30; i++) {
|
||||||
|
items.push( i+1 );
|
||||||
|
}
|
||||||
|
const list = document.getElementById('list');
|
||||||
|
const infiniteScroll = document.getElementById('infinite-scroll');
|
||||||
|
|
||||||
|
function toggleInfiniteScroll() {
|
||||||
|
infiniteScroll.enabled = !infiniteScroll.enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
infiniteScroll.addEventListener('ionInfinite', async function() {
|
||||||
|
console.log('Loading data...');
|
||||||
|
const data = await getAsyncData();
|
||||||
|
items = items.concat(data);
|
||||||
|
infiniteScroll.complete();
|
||||||
|
render();
|
||||||
|
console.log('Done');
|
||||||
|
});
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
let html = '';
|
||||||
|
for(let item of items) {
|
||||||
|
html += `<ion-item>${item}</ion-item>`;
|
||||||
|
}
|
||||||
|
list.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAsyncData() {
|
||||||
|
// async return mock data
|
||||||
|
return new Promise(resolve => {
|
||||||
|
setTimeout(() => {
|
||||||
|
let data = [];
|
||||||
|
for (var i = 0; i < 30; i++) {
|
||||||
|
data.push(i);
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve(data);
|
||||||
|
}, 500);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
render();
|
||||||
|
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
@ -1,4 +1,4 @@
|
|||||||
import { Component, Element, Listen, Prop } from '@stencil/core';
|
import { Component, Element, Event, EventEmitter, Listen, Prop } from '@stencil/core';
|
||||||
import { Config, GestureDetail } from '../../index';
|
import { Config, GestureDetail } from '../../index';
|
||||||
import { GestureController, GestureDelegate } from '../gesture-controller/gesture-controller';
|
import { GestureController, GestureDelegate } from '../gesture-controller/gesture-controller';
|
||||||
|
|
||||||
@ -22,9 +22,14 @@ export class Scroll {
|
|||||||
@Prop({ context: 'config'}) config: Config;
|
@Prop({ context: 'config'}) config: Config;
|
||||||
@Prop() enabled: boolean = true;
|
@Prop() enabled: boolean = true;
|
||||||
@Prop() jsScroll: boolean = false;
|
@Prop() jsScroll: boolean = false;
|
||||||
@Prop() ionScrollStart: ScrollCallback;
|
|
||||||
@Prop() ionScroll: ScrollCallback;
|
@Prop() onionScrollStart: ScrollCallback;
|
||||||
@Prop() ionScrollEnd: ScrollCallback;
|
@Prop() onionScroll: ScrollCallback;
|
||||||
|
@Prop() onionScrollEnd: ScrollCallback;
|
||||||
|
|
||||||
|
@Event() ionScrollStart: EventEmitter;
|
||||||
|
@Event() ionScroll: EventEmitter;
|
||||||
|
@Event() ionScrollEnd: EventEmitter;
|
||||||
|
|
||||||
protected ionViewDidLoad() {
|
protected ionViewDidLoad() {
|
||||||
if (Context.isServer) return;
|
if (Context.isServer) return;
|
||||||
@ -77,8 +82,10 @@ export class Scroll {
|
|||||||
detail.velocityY = detail.velocityX = detail.deltaY = detail.deltaX = positions.length = 0;
|
detail.velocityY = detail.velocityX = detail.deltaY = detail.deltaX = positions.length = 0;
|
||||||
|
|
||||||
// emit only on the first scroll event
|
// emit only on the first scroll event
|
||||||
if (self.ionScrollStart) {
|
if (self.onionScrollStart) {
|
||||||
self.ionScrollStart(detail);
|
self.onionScrollStart(detail);
|
||||||
|
} else {
|
||||||
|
self.ionScrollStart.emit(detail);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -125,21 +132,24 @@ export class Scroll {
|
|||||||
}, 80);
|
}, 80);
|
||||||
|
|
||||||
// emit on each scroll event
|
// emit on each scroll event
|
||||||
if (self.ionScrollStart) {
|
if (self.onionScroll) {
|
||||||
self.ionScroll(detail);
|
self.onionScroll(detail);
|
||||||
|
} else {
|
||||||
|
self.ionScroll.emit(detail);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
onEnd(timeStamp: number) {
|
onEnd(timeStamp: number) {
|
||||||
const self = this;
|
const detail = this.detail;
|
||||||
const detail = self.detail;
|
|
||||||
|
|
||||||
detail.timeStamp = timeStamp || Date.now();
|
detail.timeStamp = timeStamp || Date.now();
|
||||||
|
|
||||||
// emit that the scroll has ended
|
// emit that the scroll has ended
|
||||||
if (self.ionScrollEnd) {
|
if (this.onionScrollEnd) {
|
||||||
self.ionScrollEnd(detail);
|
this.onionScrollEnd(detail);
|
||||||
|
} else {
|
||||||
|
this.ionScrollEnd.emit(detail);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -17,6 +17,7 @@ exports.config = {
|
|||||||
{ components: ['ion-gesture', 'ion-scroll'], priority: 'low' },
|
{ components: ['ion-gesture', 'ion-scroll'], priority: 'low' },
|
||||||
{ components: ['ion-grid', 'ion-row', 'ion-col'] },
|
{ components: ['ion-grid', 'ion-row', 'ion-col'] },
|
||||||
{ components: ['ion-item', 'ion-item-divider', 'ion-item-sliding', 'ion-item-options', 'ion-item-option', 'ion-label', 'ion-list', 'ion-list-header', 'ion-skeleton-text'] },
|
{ components: ['ion-item', 'ion-item-divider', 'ion-item-sliding', 'ion-item-options', 'ion-item-option', 'ion-label', 'ion-list', 'ion-list-header', 'ion-skeleton-text'] },
|
||||||
|
{ components: ['ion-infinite-scroll', 'ion-infinite-scroll-content'] },
|
||||||
{ components: ['ion-input', 'ion-textarea'] },
|
{ components: ['ion-input', 'ion-textarea'] },
|
||||||
{ components: ['ion-loading', 'ion-loading-controller'] },
|
{ components: ['ion-loading', 'ion-loading-controller'] },
|
||||||
{ components: ['ion-menu', 'ion-menu-controller'], priority: 'low' },
|
{ components: ['ion-menu', 'ion-menu-controller'], priority: 'low' },
|
||||||
|
Reference in New Issue
Block a user