From a39c56f48d5dc0ec567a2fa1da74b7b5b04cea17 Mon Sep 17 00:00:00 2001 From: "Manu Mtz.-Almeida" Date: Sun, 22 Oct 2017 15:12:30 +0200 Subject: [PATCH] feat(infinite-scroll): adds infinite-scroll --- packages/core/src/components.d.ts | 93 ++++- .../core/src/components/content/content.tsx | 28 +- .../infinite-scroll-content.tsx | 45 ++ .../infinite-scroll/infinite-scroll.scss | 106 +++++ .../infinite-scroll/infinite-scroll.tsx | 386 ++++++++++++++++++ .../infinite-scroll/test/basic.html | 90 ++++ .../core/src/components/scroll/scroll.tsx | 34 +- packages/core/stencil.config.js | 1 + 8 files changed, 725 insertions(+), 58 deletions(-) create mode 100644 packages/core/src/components/infinite-scroll/infinite-scroll-content.tsx create mode 100644 packages/core/src/components/infinite-scroll/infinite-scroll.scss create mode 100644 packages/core/src/components/infinite-scroll/infinite-scroll.tsx create mode 100644 packages/core/src/components/infinite-scroll/test/basic.html diff --git a/packages/core/src/components.d.ts b/packages/core/src/components.d.ts index 511c4996f3..ad9d824b73 100644 --- a/packages/core/src/components.d.ts +++ b/packages/core/src/components.d.ts @@ -596,10 +596,7 @@ declare global { mode?: string, color?: string, - ionScrollStart?: any, - ionScroll?: any, - ionScrollEnd?: any, - fullscreen?: boolean + fullscreen?: boolean | "true" | "false" } } } @@ -629,14 +626,13 @@ declare global { mode?: string, color?: string, - pickerCtrl?: any, - disabled?: boolean, - min?: string, - max?: string, - displayFormat?: string, - pickerFormat?: string, - cancelText?: string, - doneText?: string, + disabled?: boolean | "true" | "false", + min?: any, + max?: any, + displayFormat?: any, + pickerFormat?: any, + cancelText?: any, + doneText?: any, yearValues?: any, monthValues?: any, dayValues?: any, @@ -647,8 +643,7 @@ declare global { dayNames?: any, dayShortNames?: any, pickerOptions?: any, - placeholder?: string, - value?: string + placeholder?: any } } } @@ -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'; interface HTMLIonInputElement extends IonInput, HTMLElement { @@ -2346,11 +2401,11 @@ declare global { mode?: string, color?: string, - enabled?: boolean, - jsScroll?: boolean, - ionScrollStart?: any, - ionScroll?: any, - ionScrollEnd?: any + enabled?: boolean | "true" | "false", + jsScroll?: boolean | "true" | "false", + onionScrollStart?: any, + onionScroll?: any, + onionScrollEnd?: any } } } diff --git a/packages/core/src/components/content/content.tsx b/packages/core/src/components/content/content.tsx index 98de804b42..bf28feda19 100644 --- a/packages/core/src/components/content/content.tsx +++ b/packages/core/src/components/content/content.tsx @@ -24,21 +24,6 @@ export class Content { $siblingHeader: 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; @@ -79,7 +64,6 @@ export class Content { protected render() { - const props: any = {}; const scrollStyle: any = {}; const pageChildren: HTMLElement[] = getParentElement(this.el).children; @@ -94,16 +78,6 @@ export class Content { 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 hostClasses = getElementClassObject(this.el.classList); @@ -114,7 +88,7 @@ export class Content { }; return ( - + ); diff --git a/packages/core/src/components/infinite-scroll/infinite-scroll-content.tsx b/packages/core/src/components/infinite-scroll/infinite-scroll-content.tsx new file mode 100644 index 0000000000..cc67fe5261 --- /dev/null +++ b/packages/core/src/components/infinite-scroll/infinite-scroll-content.tsx @@ -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 ( +
+ {this.loadingSpinner && +
+ +
+ } + {this.loadingText && +
+ } +
+ ); + } +} diff --git a/packages/core/src/components/infinite-scroll/infinite-scroll.scss b/packages/core/src/components/infinite-scroll/infinite-scroll.scss new file mode 100644 index 0000000000..5c6b5f6d72 --- /dev/null +++ b/packages/core/src/components/infinite-scroll/infinite-scroll.scss @@ -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; +} diff --git a/packages/core/src/components/infinite-scroll/infinite-scroll.tsx b/packages/core/src/components/infinite-scroll/infinite-scroll.tsx new file mode 100644 index 0000000000..51519bd288 --- /dev/null +++ b/packages/core/src/components/infinite-scroll/infinite-scroll.tsx @@ -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 + * + * + * + * {% raw %}{{i}}{% endraw %} + * + * + * + * + * + * + * + * ``` + * + * ```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 + * + * + * + * {{item}} + * + * + * + * + * + * + * + * ``` + * + * ```ts + * @Component({...}) + * export class NewsFeedPage { + * items = []; + * + * constructor() { + * for (var i = 0; i < 30; i++) { + * this.items.push( this.items.length ); + * } + * } + * + * doInfinite(): Promise { + * 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 + * + * + * + * + * + * + * + * + * ``` + * + * + * ## 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) { + 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 ; + } + +} diff --git a/packages/core/src/components/infinite-scroll/test/basic.html b/packages/core/src/components/infinite-scroll/test/basic.html new file mode 100644 index 0000000000..321a290da5 --- /dev/null +++ b/packages/core/src/components/infinite-scroll/test/basic.html @@ -0,0 +1,90 @@ + + + + + + Ionic Item Sliding + + + + + + + + + + + Ionic CDN demo + + + + + + + Toggle InfiniteScroll Enabled + + + + + + + + + + + + + + + + + + + + diff --git a/packages/core/src/components/scroll/scroll.tsx b/packages/core/src/components/scroll/scroll.tsx index c266a8cff2..70d5e86c33 100644 --- a/packages/core/src/components/scroll/scroll.tsx +++ b/packages/core/src/components/scroll/scroll.tsx @@ -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 { GestureController, GestureDelegate } from '../gesture-controller/gesture-controller'; @@ -22,9 +22,14 @@ export class Scroll { @Prop({ context: 'config'}) config: Config; @Prop() enabled: boolean = true; @Prop() jsScroll: boolean = false; - @Prop() ionScrollStart: ScrollCallback; - @Prop() ionScroll: ScrollCallback; - @Prop() ionScrollEnd: ScrollCallback; + + @Prop() onionScrollStart: ScrollCallback; + @Prop() onionScroll: ScrollCallback; + @Prop() onionScrollEnd: ScrollCallback; + + @Event() ionScrollStart: EventEmitter; + @Event() ionScroll: EventEmitter; + @Event() ionScrollEnd: EventEmitter; protected ionViewDidLoad() { if (Context.isServer) return; @@ -77,8 +82,10 @@ export class Scroll { detail.velocityY = detail.velocityX = detail.deltaY = detail.deltaX = positions.length = 0; // emit only on the first scroll event - if (self.ionScrollStart) { - self.ionScrollStart(detail); + if (self.onionScrollStart) { + self.onionScrollStart(detail); + } else { + self.ionScrollStart.emit(detail); } } @@ -125,21 +132,24 @@ export class Scroll { }, 80); // emit on each scroll event - if (self.ionScrollStart) { - self.ionScroll(detail); + if (self.onionScroll) { + self.onionScroll(detail); + } else { + self.ionScroll.emit(detail); } } onEnd(timeStamp: number) { - const self = this; - const detail = self.detail; + const detail = this.detail; detail.timeStamp = timeStamp || Date.now(); // emit that the scroll has ended - if (self.ionScrollEnd) { - self.ionScrollEnd(detail); + if (this.onionScrollEnd) { + this.onionScrollEnd(detail); + } else { + this.ionScrollEnd.emit(detail); } } diff --git a/packages/core/stencil.config.js b/packages/core/stencil.config.js index 284e952653..fbd9bcb430 100644 --- a/packages/core/stencil.config.js +++ b/packages/core/stencil.config.js @@ -17,6 +17,7 @@ exports.config = { { components: ['ion-gesture', 'ion-scroll'], priority: 'low' }, { 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-infinite-scroll', 'ion-infinite-scroll-content'] }, { components: ['ion-input', 'ion-textarea'] }, { components: ['ion-loading', 'ion-loading-controller'] }, { components: ['ion-menu', 'ion-menu-controller'], priority: 'low' },