From 0480fa3b50839dc19e60cd2ba83d934465b2a5de Mon Sep 17 00:00:00 2001 From: Adam Bradley Date: Mon, 29 Feb 2016 11:13:27 -0600 Subject: [PATCH] feat(infiniteScroll): add infinite scroll Closes #5415 --- ionic/components.core.scss | 1 + ionic/components.ts | 2 + ionic/components/content/content.ts | 17 +- .../infinite-scroll-content.ts | 48 ++++ .../infinite-scroll/infinite-scroll.scss | 44 +++ .../infinite-scroll/infinite-scroll.ts | 256 ++++++++++++++++++ .../infinite-scroll/test/basic/index.ts | 49 ++++ .../infinite-scroll/test/basic/main.html | 18 ++ .../test/infinite-scroll.spec.ts | 152 +++++++++++ ionic/config/directives.ts | 6 + 10 files changed, 583 insertions(+), 10 deletions(-) create mode 100644 ionic/components/infinite-scroll/infinite-scroll-content.ts create mode 100644 ionic/components/infinite-scroll/infinite-scroll.scss create mode 100644 ionic/components/infinite-scroll/infinite-scroll.ts create mode 100644 ionic/components/infinite-scroll/test/basic/index.ts create mode 100644 ionic/components/infinite-scroll/test/basic/main.html create mode 100644 ionic/components/infinite-scroll/test/infinite-scroll.spec.ts diff --git a/ionic/components.core.scss b/ionic/components.core.scss index 98d29b38217..7d1105d6cb8 100644 --- a/ionic/components.core.scss +++ b/ionic/components.core.scss @@ -16,6 +16,7 @@ @import "components/grid/grid", "components/icon/icon", + "components/infinite-scroll/infinite-scroll", "components/menu/menu", "components/modal/modal", "components/refresher/refresher", diff --git a/ionic/components.ts b/ionic/components.ts index 971e535087e..362c737a3b9 100644 --- a/ionic/components.ts +++ b/ionic/components.ts @@ -7,6 +7,8 @@ export * from './components/button/button' export * from './components/checkbox/checkbox' export * from './components/content/content' export * from './components/icon/icon' +export * from './components/infinite-scroll/infinite-scroll' +export * from './components/infinite-scroll/infinite-scroll-content' export * from './components/input/input' export * from './components/item/item' export * from './components/item/item-sliding' diff --git a/ionic/components/content/content.ts b/ionic/components/content/content.ts index 0983ba4e01d..f6b2ad2fb80 100644 --- a/ionic/components/content/content.ts +++ b/ionic/components/content/content.ts @@ -35,8 +35,8 @@ import {ScrollTo} from '../../animations/scroll-to'; }) export class Content extends Ion { private _padding: number = 0; - private _onScroll: any; private _scrollTo: ScrollTo; + private _scLsn: Function; /** * @private @@ -65,13 +65,11 @@ export class Content extends Ion { let self = this; self.scrollElement = self._elementRef.nativeElement.children[0]; - self._onScroll = function(ev) { - self._app.setScrolling(); - }; - if (self._config.get('tapPolyfill') === true) { self._zone.runOutsideAngular(function() { - self.scrollElement.addEventListener('scroll', self._onScroll); + self._scLsn = self.addScrollListener(function() { + self._app.setScrolling(); + }); }); } } @@ -80,8 +78,8 @@ export class Content extends Ion { * @private */ ngOnDestroy() { - this.scrollElement.removeEventListener('scroll', this._onScroll.bind(this)); - this.scrollElement = null; + this._scLsn && this._scLsn(); + this.scrollElement = this._scLsn = null; } /** @@ -298,7 +296,6 @@ export class Content extends Ion { } /** - * @private * Returns the content and scroll elements' dimensions. * @returns {object} dimensions The content and scroll elements' dimensions * {number} dimensions.contentHeight content offsetHeight @@ -334,7 +331,7 @@ export class Content extends Ion { scrollWidth: _scrollEle.scrollWidth, scrollLeft: _scrollEle.scrollLeft, scrollRight: _scrollEle.scrollLeft + _scrollEle.scrollWidth, - } + }; } /** diff --git a/ionic/components/infinite-scroll/infinite-scroll-content.ts b/ionic/components/infinite-scroll/infinite-scroll-content.ts new file mode 100644 index 00000000000..ec938d472ed --- /dev/null +++ b/ionic/components/infinite-scroll/infinite-scroll-content.ts @@ -0,0 +1,48 @@ +import {Component, Input} from 'angular2/core' +import {NgIf} from 'angular2/common'; + +import {Config} from '../../config/config'; +import {InfiniteScroll} from './infinite-scroll'; +import {Spinner} from '../spinner/spinner'; + + +/** + * @private + */ +@Component({ + selector: 'ion-infinite-content', + template: + '
' + + '
' + + '' + + '
' + + '
' + + '
', + directives: [NgIf, Spinner], + host: { + '[attr.state]': 'inf.state' + } +}) +export class InfiniteScrollContent { + + /** + * @input {string} An animated SVG spinner that shows while loading. + */ + @Input() loadingSpinner: string; + + /** + * @input {string} Optional text to display while loading. + */ + @Input() loadingText: string; + + constructor(private inf: InfiniteScroll, private _config: Config) {} + + /** + * @private + */ + ngOnInit() { + if (!this.loadingSpinner) { + this.loadingSpinner = this._config.get('infiniteLoadingSpinner', this._config.get('spinner', 'ios')); + } + } +} diff --git a/ionic/components/infinite-scroll/infinite-scroll.scss b/ionic/components/infinite-scroll/infinite-scroll.scss new file mode 100644 index 00000000000..4cca90b588e --- /dev/null +++ b/ionic/components/infinite-scroll/infinite-scroll.scss @@ -0,0 +1,44 @@ +@import "../../globals.core"; + +// Infinite Scroll +// -------------------------------------------------- + +$infinite-scroll-loading-margin: 0px 0px 32px 0px !default; +$infinite-scroll-loading-color: #666 !default; +$infinite-scroll-loading-text-margin: 4px 32px 0 32px !default; + + +ion-infinite { + display: block; + width: 100%; +} + + +// Infinite Scroll Content +// -------------------------------------------------- + +ion-infinite-content { + display: flex; + flex-direction: column; + justify-content: center; + height: 100%; + text-align: center; +} + +.infinite-loading { + width: 100%; + margin: $infinite-scroll-loading-margin; +} + +.infinite-loading-text { + margin: $infinite-scroll-loading-text-margin; + color: $infinite-scroll-loading-color; +} + + +// Infinite Scroll Content States +// -------------------------------------------------- + +ion-infinite-content[state=disabled] .infinite-loading { + display: none; +} diff --git a/ionic/components/infinite-scroll/infinite-scroll.ts b/ionic/components/infinite-scroll/infinite-scroll.ts new file mode 100644 index 00000000000..94b9e03169f --- /dev/null +++ b/ionic/components/infinite-scroll/infinite-scroll.ts @@ -0,0 +1,256 @@ +import {Directive, Input, Output, EventEmitter, Host, NgZone, ElementRef} from 'angular2/core'; + +import {Content} from '../content/content'; + + +/** + * @name InfiniteScroll + * @description + * The infinite scroll allows you to call a method whenever the user + * gets to the bottom of the page or near the bottom of the page. + * + * The expression you add to the `infinite` output event is called when + * the user scrolls greater than distance away from the bottom of the + * content. Once your `infinite` handler is done loading new data, it + * should call the `endLoading()` method on the infinite scroll instance. + * + * @usage + * ```html + * + * + * + * {{i}} + * + * + * + * + * + * + * + * ``` + * + * ```ts + * @Page({...}) + * export class NewsFeedPage { + * + * constructor() { + * this.items = []; + * for (var i = 0; i < 30; i++) { + * this.items.push( this.items.length ); + * } + * } + * + * doInfinite(infiniteScroll) { + * console.log('Begin async operation'); + * + * setTimeout(() => { + * for (var i = 0; i < 30; i++) { + * this.items.push( this.items.length ); + * } + * + * console.log('Async operation has ended'); + * infiniteScroll.endLoading(); + * }, 500); + * } + * + * } + * ``` + * + * + * ## Infinite Scroll Content + * + * By default, Ionic provides the infinite scroll spinner that looks + * best for the platform the user is on. However, you can change the + * default spinner, along with adding text by adding properties to + * the child `ion-infinite-content` component. + * + * ```html + * + * + * + * + * + * + * + * + * ``` + * + * + * ## Further Customizing Infinite Scroll Content + * + * The `ion-infinite` component holds the infinite scroll logic, and it + * requires a child infinite scroll content component for its display. + * The `ion-infinite-content` component is Ionic's default that shows + * the actual display of the infinite scroll and changes its look depending + * on the infinite scroll's state. With this separation, it also allows + * developers to create their own infinite scroll content components. + * Ideas include having some cool SVG or CSS animations that are + * customized to your app and animates to your liking. + * + */ +@Directive({ + selector: 'ion-infinite' +}) +export class InfiniteScroll { + private _lastCheck: number = 0; + private _highestY: number = 0; + private _scLsn: Function; + private _thr: string = '15%'; + private _thrPx: number = 0; + private _thrPc: number = 0.15; + private _init: boolean = false; + + state: string = STATE_ENABLED; + + /** + * @input {string} The threshold distance from the bottom + * of the content to call the `infinite` output event when scrolled. + * The threshold input 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 scroll has 10% of the scroll + * left until it reaches the bottom. Use the value `100px` when the + * scroll is within 100 pixels from the bottom of the content. + * Default is `15%`. + */ + @Input() + get threshold(): string { + return this._thr; + } + set threshold(val: string) { + this._thr = val; + if (val.indexOf('%') > -1) { + this._thrPx = 0; + this._thrPc = (parseFloat(val) / 100); + + } else { + this._thrPx = parseFloat(val); + this._thrPc = 0; + } + } + + /** + * @output {event} The expression to call when the scroll reaches + * the threshold input distance. From within your infinite handler, + * you must call the infinite scroll's `endLoading()` method when + * your async operation has completed. + */ + @Output() infinite: EventEmitter = new EventEmitter(); + + constructor( + @Host() private _content: Content, + private _zone: NgZone, + private _elementRef: ElementRef + ) { + _content.addCssClass('has-infinite-scroll'); + } + + private _onScroll(ev) { + if (this.state === STATE_LOADING || this.state === STATE_DISABLED) { + return 1; + } + + let now = Date.now(); + + if (this._lastCheck + 32 > now) { + // no need to check less than every XXms + return 2; + } + this._lastCheck = now; + + let infiniteHeight = this._elementRef.nativeElement.scrollHeight; + if (!infiniteHeight) { + // if there is no height of this element then do nothing + return 3; + } + + let d = this._content.getContentDimensions(); + + if (d.scrollTop <= this._highestY) { + // don't bother if scrollY is less than the highest Y seen + return 4; + } + this._highestY = d.scrollTop; + + let reloadY = d.contentHeight; + if (this._thrPc) { + reloadY += (reloadY * this._thrPc); + } else { + reloadY += this._thrPx + } + + let distanceFromInfinite = ((d.scrollHeight - infiniteHeight) - d.scrollTop) - reloadY; + if (distanceFromInfinite < 0) { + this._zone.run(() => { + console.debug('infinite scroll'); + this.state = STATE_LOADING; + this.infinite.emit(this); + }); + return 5; + } + + return 6; + } + + /** + * Call `endLoading()` 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`. + */ + endLoading() { + this.state = STATE_ENABLED; + } + + /** + * 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. + */ + enable(shouldEnable: boolean) { + this.state = (shouldEnable ? STATE_ENABLED : STATE_DISABLED); + this._setListeners(shouldEnable); + } + + private _setListeners(shouldListen: boolean) { + if (this._init) { + if (shouldListen) { + if (!this._scLsn) { + this._zone.runOutsideAngular(() => { + this._scLsn = this._content.addScrollListener( this._onScroll.bind(this) ); + }); + } + } else { + this._scLsn && this._scLsn(); + this._scLsn = null; + } + } + } + + /** + * @private + */ + ngAfterContentInit() { + this._init = true; + this._setListeners(this.state !== STATE_DISABLED); + } + + /** + * @private + */ + ngOnDestroy() { + this._setListeners(false); + } + +} + +const STATE_ENABLED = 'enabled'; +const STATE_DISABLED = 'disabled'; +const STATE_LOADING = 'loading'; diff --git a/ionic/components/infinite-scroll/test/basic/index.ts b/ionic/components/infinite-scroll/test/basic/index.ts new file mode 100644 index 00000000000..ed23a6fa90e --- /dev/null +++ b/ionic/components/infinite-scroll/test/basic/index.ts @@ -0,0 +1,49 @@ +import {App, InfiniteScroll} from 'ionic-angular'; + + +@App({ + templateUrl: 'main.html' +}) +class E2EApp { + items = []; + + constructor() { + for (var i = 0; i < 30; i++) { + this.items.push( this.items.length ); + } + } + + doInfinite(infiniteScroll: InfiniteScroll) { + console.log('Begin async operation'); + + getAsyncData().then(newData => { + for (var i = 0; i < newData.length; i++) { + this.items.push( this.items.length ); + } + + console.log('Finished receiving data, async operation complete'); + infiniteScroll.endLoading(); + + if (this.items.length > 90) { + infiniteScroll.enable(false); + } + }); + } + +} + +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); + + }); +} diff --git a/ionic/components/infinite-scroll/test/basic/main.html b/ionic/components/infinite-scroll/test/basic/main.html new file mode 100644 index 00000000000..6c7a1610c10 --- /dev/null +++ b/ionic/components/infinite-scroll/test/basic/main.html @@ -0,0 +1,18 @@ +Infinite Scroll + + + + + + {{ item }} + + + + + + + + + diff --git a/ionic/components/infinite-scroll/test/infinite-scroll.spec.ts b/ionic/components/infinite-scroll/test/infinite-scroll.spec.ts new file mode 100644 index 00000000000..89bd7c035d9 --- /dev/null +++ b/ionic/components/infinite-scroll/test/infinite-scroll.spec.ts @@ -0,0 +1,152 @@ +import {InfiniteScroll, Content, Config} from 'ionic-angular'; + +export function run() { + +describe('Infinite Scroll', () => { + + describe('_onScroll', () => { + + it('should not set loading state when does not meet threshold', () => { + setInfiniteScrollHeight(25); + content.getContentDimensions = function() { + return { scrollHeight: 1000, scrollTop: 350, contentHeight: 500 }; + }; + inf._highestY = 0; + inf.threshold = '100px'; + + setInfiniteScrollTop(300); + + var result = inf._onScroll(scrollEv()); + expect(result).toEqual(6); + }); + + it('should set loading state when meets threshold', () => { + setInfiniteScrollHeight(25); + content.getContentDimensions = function() { + return { scrollHeight: 1000, scrollTop: 500, contentHeight: 500 }; + }; + inf._highestY = 0; + inf.threshold = '100px'; + + setInfiniteScrollTop(300); + + var result = inf._onScroll(scrollEv()); + expect(result).toEqual(5); + }); + + it('should not continue if the scrolltop is <= the highest Y', () => { + inf._highestY = 100; + setInfiniteScrollTop(50); + setInfiniteScrollHeight(100); + content.getContentDimensions = function() { + return { scrollTop: 50 }; + }; + + var result = inf._onScroll(scrollEv()); + expect(result).toEqual(4); + }); + + it('should not run if there is not infinite element height', () => { + setInfiniteScrollTop(0); + var result = inf._onScroll(scrollEv()); + expect(result).toEqual(3); + }); + + it('should not run again if ran less than 32ms ago', () => { + inf._lastCheck = Date.now(); + var result = inf._onScroll(scrollEv()); + expect(result).toEqual(2); + }); + + it('should not run if state is disabled', () => { + inf.state = 'disabled'; + var result = inf._onScroll(scrollEv()); + expect(result).toEqual(1); + }); + + it('should not run if state is loading', () => { + inf.state = 'loading'; + var result = inf._onScroll(scrollEv()); + expect(result).toEqual(1); + }); + + it('should not run if not enabled', () => { + inf.state = 'disabled'; + var result = inf._onScroll(scrollEv()); + expect(result).toEqual(1); + }); + + }); + + describe('threshold', () => { + + it('should set by percent', () => { + inf.threshold = '10%'; + expect(inf._thr).toEqual('10%'); + expect(inf._thrPx).toEqual(0); + expect(inf._thrPc).toEqual(0.1); + }); + + it('should set by pixels', () => { + inf.threshold = '10'; + expect(inf._thr).toEqual('10'); + expect(inf._thrPx).toEqual(10); + expect(inf._thrPc).toEqual(0); + + inf.threshold = '10px'; + expect(inf._thr).toEqual('10px'); + expect(inf._thrPx).toEqual(10); + expect(inf._thrPc).toEqual(0); + }); + + }); + + + let config = new Config(); + let inf: InfiniteScroll; + let content: Content; + let contentElementRef; + let infiniteElementRef; + let zone = { + run: function(cb) {cb()}, + runOutsideAngular: function(cb) {cb()} + }; + + beforeEach(() => { + contentElementRef = mockElementRef(); + content = new Content(contentElementRef, config, null, null, null); + content.scrollElement = document.createElement('scroll-content'); + + infiniteElementRef = mockElementRef(); + inf = new InfiniteScroll(content, zone, infiniteElementRef); + }); + + function scrollEv() { + return {} + } + + function mockElementRef() { + return { + nativeElement: { + classList: { add: function(){}, remove: function(){} }, + scrollTop: 0, + hasAttribute: function(){} + } + } + } + + function setInfiniteScrollTop(scrollTop) { + infiniteElementRef.nativeElement.scrollTop = scrollTop; + } + + function setInfiniteScrollHeight(scrollHeight) { + infiniteElementRef.nativeElement.scrollHeight = scrollHeight; + } + + function getScrollElementStyles() { + return content.scrollElement.style; + } + +}); + +} diff --git a/ionic/config/directives.ts b/ionic/config/directives.ts index d4cc4af2729..8fd313a2389 100644 --- a/ionic/config/directives.ts +++ b/ionic/config/directives.ts @@ -10,6 +10,8 @@ import {Button} from '../components/button/button'; import {Blur} from '../components/blur/blur'; import {Content} from '../components/content/content'; import {Scroll} from '../components/scroll/scroll'; +import {InfiniteScroll} from '../components/infinite-scroll/infinite-scroll'; +import {InfiniteScrollContent} from '../components/infinite-scroll/infinite-scroll-content'; import {Refresher} from '../components/refresher/refresher'; import {RefresherContent} from '../components/refresher/refresher-content'; import {Slides, Slide, SlideLazy} from '../components/slides/slides'; @@ -58,6 +60,8 @@ import {ShowWhen, HideWhen} from '../components/show-hide-when/show-hide-when'; * - Blur * - Content * - Scroll + * - InfiniteScroll + * - InfiniteScrollContent * - Refresher * - RefresherContent * @@ -126,6 +130,8 @@ export const IONIC_DIRECTIVES = [ Blur, Content, Scroll, + InfiniteScroll, + InfiniteScrollContent, Refresher, RefresherContent,