diff --git a/ionic/components.core.scss b/ionic/components.core.scss
index 98d29b3821..7d1105d6cb 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 971e535087..362c737a3b 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 0983ba4e01..f6b2ad2fb8 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 0000000000..ec938d472e
--- /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 0000000000..4cca90b588
--- /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 0000000000..94b9e03169
--- /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 0000000000..ed23a6fa90
--- /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 0000000000..6c7a1610c1
--- /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 0000000000..89bd7c035d
--- /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 d4cc4af272..8fd313a238 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,