Files
ionic-framework/src/components/nav/nav-controller.ts
2016-07-13 22:11:38 +02:00

1638 lines
58 KiB
TypeScript

import { ComponentResolver, ElementRef, EventEmitter, NgZone, provide, ReflectiveInjector, Renderer, ViewContainerRef } from '@angular/core';
import { addSelector } from '../../config/bootstrap';
import { App } from '../app/app';
import { Config } from '../../config/config';
import { GestureController } from '../../gestures/gesture-controller';
import { Ion } from '../ion';
import { isBlank, pascalCaseToDashCase } from '../../util/util';
import { Keyboard } from '../../util/keyboard';
import { NavOptions } from './nav-interfaces';
import { NavParams } from './nav-params';
import { SwipeBackGesture } from './swipe-back';
import { Transition } from '../../transitions/transition';
import { ViewController } from './view-controller';
/**
* @name NavController
* @description
* _For examples on the basic usage of NavController, check out the
* [Navigation section](../../../../components/#navigation) of the Component
* docs._
*
* NavController is the base class for navigation controller components like
* [`Nav`](../Nav/) and [`Tab`](../../Tabs/Tab/). You use navigation controllers
* to navigate to [pages](#creating_pages) in your app. At a basic level, a
* navigation controller is an array of pages representing a particular history
* (of a Tab for example). This array can be manipulated to navigate throughout
* an app by pushing and popping pages or inserting and removing them at
* arbitrary locations in history.
*
* The current page is the last one in the array, or the top of the stack if we
* think of it that way. [Pushing](#push) a new page onto the top of the
* navigation stack causes the new page to be animated in, while [popping](#pop)
* the current page will navigate to the previous page in the stack.
*
* Unless you are using a directive like [NavPush](../NavPush/), or need a
* specific NavController, most times you will inject and use a reference to the
* nearest NavController to manipulate the navigation stack.
*
* ### Injecting NavController
* Injecting NavController will always get you an instance of the nearest
* NavController, regardless of whether it is a Tab or a Nav.
*
* Behind the scenes, when Ionic instantiates a new NavController, it creates an
* injector with NavController bound to that instance (usually either a Nav or
* Tab) and adds the injector to its own providers. For more information on
* providers and dependency injection, see [Providers and DI]().
*
* Instead, you can inject NavController and know that it is the correct
* navigation controller for most situations (for more advanced situations, see
* [Menu](../../Menu/Menu/) and [Tab](../../Tab/Tab/)).
*
* ```ts
* class MyComponent {
* constructor(private nav: NavController) {
*
* }
* }
* ```
*
*
* ## Page creation
* Pages are created when they are added to the navigation stack. For methods
* like [push()](#push), the NavController takes any component class that is
* decorated with `@Component` as its first argument. The NavController then
* compiles that component, adds it to the app and animates it into view.
*
* By default, pages are cached and left in the DOM if they are navigated away
* from but still in the navigation stack (the exiting page on a `push()` for
* example). They are destroyed when removed from the navigation stack (on
* [pop()](#pop) or [setRoot()](#setRoot)).
*
*
* ## Lifecycle events
* Lifecycle events are fired during various stages of navigation. They can be
* defined in any component type which is pushed/popped from a `NavController`.
*
* ```ts
* import {Component } from '@angular/core';
*
* @Component({
* template: 'Hello World'
* })
* class HelloWorld {
* ionViewLoaded() {
* console.log("I'm alive!");
* }
* ionViewWillLeave() {
* console.log("Looks like I'm about to leave :(");
* }
* }
* ```
*
* | Page Event | Description |
* |---------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
* | `ionViewLoaded` | Runs when the page has loaded. This event only happens once per page being created and added to the DOM. If a page leaves but is cached, then this event will not fire again on a subsequent viewing. The `ionViewLoaded` event is good place to put your setup code for the page. |
* | `ionViewWillEnter` | Runs when the page is about to enter and become the active page. |
* | `ionViewDidEnter` | Runs when the page has fully entered and is now the active page. This event will fire, whether it was the first load or a cached page. |
* | `ionViewWillLeave` | Runs when the page is about to leave and no longer be the active page. |
* | `ionViewDidLeave` | Runs when the page has finished leaving and is no longer the active page. |
* | `ionViewWillUnload` | Runs when the page is about to be destroyed and have its elements removed. |
* | `ionViewDidUnload` | Runs after the page has been destroyed and its elements have been removed.
*
*
* ## Nav Transition Promises
*
* Navigation transitions are asynchronous, meaning they take a few moments to finish, and
* the duration of a transition could be any number. In most cases the async nature of a
* transition doesn't cause any problems and the nav controller is pretty good about handling
* which transition was the most recent when multiple transitions have been kicked off.
* However, when an app begins firing off many transitions, on the same stack at
* *roughly* the same time, the nav controller can start to get lost as to which transition
* should be finishing, and which transitions should not be animated.
*
* In cases where an app's navigation can be altered by other async tasks, which may or
* may not take a long time, it's best to rely on each nav transition's returned
* promise. So instead of firing and forgetting multiple `push` or `pop` nav transitions,
* it's better to fire the next nav transition when the previous one has finished.
*
* In the example below, after the async operation has completed, we then want to transition
* to another page. Where the potential problem comes in, is that if the async operation
* completed 100ms after the first transition started, then kicking off another transition
* halfway through the first transition ends up with a janky animation. Instead, it's best
* to always ensure the first transition has already finished before starting the next.
*
* ```ts
* // begin the first transition
* let navTransition = this.nav.push(SomePage);
*
* // start an async call, we're not sure how long it'll take
* someAsyncOperation().then(() => {
* // incase the async operation completed faster than the time
* // it took to finish the first transition, this logic should
* // always ensure that the previous transition has resolved
* // first before kicking off the next transition
* navTransition.then(() => {
* this.nav.push(AnotherPage);
* });
* });
* ```
*
* ## NavOptions
*
* Some methods on `NavController` allow for customizing the current transition.
* To do this, we can pass an object with the modified properites.
*
* | Property | Value | Description |
* |-----------|-----------|------------------------------------------------------------------------------------------------------------|
* | animate | `boolean` | Whether or not the transition should animate. |
* | animation | `string` | What kind of animation should be used. |
* | direction | `string` | The conceptual direction the user is navigating. For example, is the user navigating `forward`, or `back`? |
* | duration | `number` | The length in milliseconds the animation should take. |
* | easing | `string` | The easing for the animation. |
*
* The property 'animation' understands the following values: `md-transition`, `ios-transition` and `wp-transition`.
*
* @see {@link /docs/v2/components#navigation Navigation Component Docs}
*/
export class NavController extends Ion {
private _transIds = 0;
private _init = false;
private _trans: Transition;
private _sbGesture: SwipeBackGesture;
private _sbThreshold: number;
private _viewport: ViewContainerRef;
private _children: any[] = [];
protected _sbEnabled: boolean;
protected _ids: number = -1;
protected _trnsDelay: any;
protected _views: ViewController[] = [];
/**
* Observable to be subscribed to when a component is loaded.
* @returns {Observable} Returns an observable
*/
viewDidLoad: EventEmitter<any>;
/**
* Observable to be subscribed to when a component is about to be loaded.
* @returns {Observable} Returns an observable
*/
viewWillEnter: EventEmitter<any>;
/**
* Observable to be subscribed to when a component has fully become the active component.
* @returns {Observable} Returns an observable
*/
viewDidEnter: EventEmitter<any>;
/**
* Observable to be subscribed to when a component is about to leave, and no longer active.
* @returns {Observable} Returns an observable
*/
viewWillLeave: EventEmitter<any>;
/**
* Observable to be subscribed to when a component has fully left and is no longer active.
* @returns {Observable} Returns an observable
*/
viewDidLeave: EventEmitter<any>;
/**
* Observable to be subscribed to when a component is about to be unloaded and destroyed.
* @returns {Observable} Returns an observable
*/
viewWillUnload: EventEmitter<any>;
/**
* Observable to be subscribed to when a component has fully been unloaded and destroyed.
* @returns {Observable} Returns an observable
*/
viewDidUnload: EventEmitter<any>;
/**
* @private
*/
id: string;
/**
* @private
*/
parent: any;
/**
* @private
*/
config: Config;
/**
* @private
*/
isPortal: boolean = false;
/**
* @private
*/
trnsTime: number = 0;
constructor(
parent: any,
protected _app: App,
config: Config,
protected _keyboard: Keyboard,
elementRef: ElementRef,
protected _zone: NgZone,
protected _renderer: Renderer,
protected _compiler: ComponentResolver,
private _gestureCtrl: GestureController
) {
super(elementRef);
this.parent = parent;
this.config = config;
this._trnsDelay = config.get('pageTransitionDelay');
this._sbEnabled = config.getBoolean('swipeBackEnabled');
this._sbThreshold = config.getNumber('swipeBackThreshold', 40);
this.id = (++ctrlIds).toString();
this.viewDidLoad = new EventEmitter();
this.viewWillEnter = new EventEmitter();
this.viewDidEnter = new EventEmitter();
this.viewWillLeave = new EventEmitter();
this.viewDidLeave = new EventEmitter();
this.viewWillUnload = new EventEmitter();
this.viewDidUnload = new EventEmitter();
}
/**
* @private
*/
setViewport(val: ViewContainerRef) {
this._viewport = val;
}
/**
* Set the root for the current navigation stack.
* @param {Page} page The name of the component you want to push on the navigation stack.
* @param {object} [params={}] Any nav-params you want to pass along to the next view.
* @param {object} [opts={}] Any options you want to use pass to transtion.
* @returns {Promise} Returns a promise which is resolved when the transition has completed.
*/
setRoot(page: any, params?: any, opts?: NavOptions): Promise<any> {
return this.setPages([{page, params}], opts);
}
/**
* Set the views of the current navigation stack and navigate to the
* last view. By default animations are disabled, but they can be enabled
* by passing options to the navigation controller.You can also pass any
* navigation params to the individual pages in the array.
*
* @param {array<Page>} pages An arry of page components and their params to load in the stack.
* @param {object} [opts={}] Nav options to go with this transition.
* @returns {Promise} Returns a promise which is resolved when the transition has completed.
*/
setPages(pages: Array<{page: any, params?: any}>, opts?: NavOptions): Promise<any> {
if (!pages || !pages.length) {
return Promise.resolve(false);
}
if (isBlank(opts)) {
opts = {};
}
// remove existing views
let leavingView = this._remove(0, this._views.length);
// create view controllers out of the pages and insert the new views
let views = pages.map(p => new ViewController(p.page, p.params));
let enteringView = this._insert(0, views);
// if animation wasn't set to true then default it to NOT animate
if (opts.animate !== true) {
opts.animate = false;
}
// set the nav direction to "back" if it wasn't set
opts.direction = opts.direction || DIRECTION_BACK;
let resolve: any;
let promise = new Promise(res => { resolve = res; });
// start the transition, fire resolve when done...
this._transition(enteringView, leavingView, opts, (hasCompleted: boolean) => {
// transition has completed!!
resolve(hasCompleted);
});
return promise;
}
/**
* Push a new component onto the current navication stack. Pass any aditional information
* along as an object. This additional information is acessible through NavParams
*
* @param {Page} page The page component class you want to push on to the navigation stack
* @param {object} [params={}] Any nav-params you want to pass along to the next view
* @param {object} [opts={}] Nav options to go with this transition.
* @returns {Promise} Returns a promise which is resolved when the transition has completed.
*/
push(page: any, params?: any, opts?: NavOptions) {
return this.insertPages(-1, [{page: page, params: params}], opts);
}
/**
* @private
* DEPRECATED: Please use inject the overlays controller and use the present method on the instance instead.
*/
private present(enteringView: ViewController, opts?: NavOptions): Promise<any> {
// deprecated warning: added beta.11 2016-06-27
console.warn('nav.present() has been deprecated.\n' +
'Please inject the overlay\'s controller and use the present method on the instance instead.');
return Promise.resolve();
}
/**
* Inserts a component into the nav stack at the specified index. This is useful if
* you need to add a component at any point in your navigation stack.
*
*
* @param {number} insertIndex The index where to insert the page.
* @param {Page} page The component you want to insert into the nav stack.
* @param {object} [params={}] Any nav-params you want to pass along to the next page.
* @param {object} [opts={}] Nav options to go with this transition.
* @returns {Promise} Returns a promise which is resolved when the transition has completed.
*/
insert(insertIndex: number, page: any, params?: any, opts?: NavOptions): Promise<any> {
return this.insertPages(insertIndex, [{page: page, params: params}], opts);
}
/**
* Inserts an array of components into the nav stack at the specified index.
* The last component in the array will animate in and become the active component
*
* @param {number} insertIndex The index where you want to insert the page.
* @param {array<{page: Page, params=: any}>} insertPages An array of objects, each with a `page` and optionally `params` property.
* @param {object} [opts={}] Nav options to go with this transition.
* @returns {Promise} Returns a promise which is resolved when the transition has completed.
*/
insertPages(insertIndex: number, insertPages: Array<{page: any, params?: any}>, opts?: NavOptions): Promise<any> {
let views = insertPages.map(p => new ViewController(p.page, p.params));
return this.insertViews(insertIndex, views, opts);
}
/**
* @private
*/
insertViews(insertIndex: number, insertViews: ViewController[], opts?: NavOptions): Promise<any> {
if (!insertViews || !insertViews.length) {
return Promise.reject('invalid pages');
}
if (isBlank(opts)) {
opts = {};
}
// insert the new page into the stack
// returns the newly created entering view
let enteringView = this._insert(insertIndex, insertViews);
// set the nav direction to "forward" if it wasn't set
opts.direction = opts.direction || 'forward';
// set which animation it should use if it wasn't set yet
if (!opts.animation) {
opts.animation = enteringView.getTransitionName(opts.direction);
}
let resolve: any;
let promise = new Promise(res => { resolve = res; });
// it's possible that the newly added view doesn't need to
// transition in, but was simply inserted somewhere in the stack
// go backwards through the stack and find the first active view
// which could be active or one ready to enter
for (var i = this._views.length - 1; i >= 0; i--) {
if (this._views[i].state === STATE_ACTIVE || this._views[i].state === STATE_INIT_ENTER) {
// found the view at the end of the stack that's either
// already active or it is about to enter
if (this._views[i] === enteringView) {
// cool, so the last valid view is also our entering view!!
// this means we should animate that bad boy in so it's the active view
// return a promise and resolve when the transition has completed
// get the leaving view which the _insert() already set
let leavingView = this.getByState(STATE_INIT_LEAVE);
// start the transition, fire resolve when done...
this._transition(enteringView, leavingView, opts, (hasCompleted: boolean) => {
// transition has completed!!
resolve(hasCompleted);
});
return promise;
}
break;
}
}
// the page was not pushed onto the end of the stack
// but rather inserted somewhere in the middle or beginning
// Since there are views after this new one, don't transition in
// auto resolve cuz there was is no need for an animation
return Promise.resolve(enteringView);
}
/**
* @private
*/
private _insert(insertIndex: number, insertViews: Array<ViewController>): ViewController {
// when this is done, there should only be at most
// 1 STATE_INIT_ENTER and 1 STATE_INIT_LEAVE
// there should not be any that are STATE_ACTIVE after this is done
// allow -1 to be passed in to auto push it on the end
// and clean up the index if it's larger then the size of the stack
if (insertIndex < 0 || insertIndex > this._views.length) {
insertIndex = this._views.length;
}
// first see if there's an active view
let view = this.getActive();
if (view) {
// there's an active view, set that it's initialized to leave
view.state = STATE_INIT_LEAVE;
} else if (view = this.getByState(STATE_INIT_ENTER)) {
// oh no, there's already a transition initalized ready to enter!
// but it actually hasn't entered yet at all so lets
// just keep it in the array, but not render or animate it in
view.state = STATE_INACTIVE;
}
// insert each of the views in the pages array
let insertView: ViewController = null;
insertViews.forEach((view, i) => {
insertView = view;
// create the new entering view
view.setNav(this);
view.state = STATE_INACTIVE;
// give this inserted view an ID
view.id = this.id + '-' + (++this._ids);
// insert the entering view into the correct index in the stack
this._views.splice(insertIndex + i, 0, view);
});
if (insertView) {
insertView.state = STATE_INIT_ENTER;
}
return insertView;
}
/**
* Call to navigate back from a current component. Similar to `push()`, you
* can also pass navigation options.
*
* @param {object} [opts={}] Nav options to go with this transition.
* @returns {Promise} Returns a promise which is resolved when the transition has completed.
*/
pop(opts?: NavOptions): Promise<any> {
// get the index of the active view
// which will become the view to be leaving
let activeView = this.getByState(STATE_TRANS_ENTER) ||
this.getByState(STATE_INIT_ENTER) ||
this.getActive();
if (isBlank(opts)) {
opts = {};
}
// if not set, by default climb up the nav controllers if
// there isn't a previous view in this nav controller
if (isBlank(opts.climbNav)) {
opts.climbNav = true;
}
return this.remove(this.indexOf(activeView), 1, opts);
}
/**
* Navigate back to the root of the stack, no matter how far back that is.
*
* @param {object} [opts={}] Nav options to go with this transition.
* @returns {Promise} Returns a promise which is resolved when the transition has completed.
*/
popToRoot(opts?: NavOptions): Promise<any> {
return this.popTo(this.first(), opts);
}
/**
* Pop to a specific view in the history stack.
*
* @param {ViewController} view to pop to
* @param {object} [opts={}] Nav options to go with this transition.
* @returns {Promise} Returns a promise which is resolved when the transition has completed.
*/
popTo(view: ViewController, opts?: NavOptions): Promise<any> {
let startIndex = this.indexOf(view);
if (startIndex < 0) {
return Promise.reject('View not found to pop to');
}
let activeView = this.getByState(STATE_TRANS_ENTER) ||
this.getByState(STATE_INIT_ENTER) ||
this.getActive();
let removeCount = this.indexOf(activeView) - startIndex;
return this.remove(startIndex + 1, removeCount, opts);
}
/**
* Removes a page from the nav stack at the specified index.
*
* @param {number} [startIndex] The starting index to remove pages from the stack. Default is the index of the last page.
* @param {number} [removeCount] The number of pages to remove, defaults to remove `1`.
* @param {object} [opts={}] Any options you want to use pass to transtion.
* @returns {Promise} Returns a promise which is resolved when the transition has completed.
*/
remove(startIndex: number = -1, removeCount: number = 1, opts?: NavOptions): Promise<any> {
if (startIndex === -1) {
startIndex = this._views.length - 1;
} else if (startIndex < 0 || startIndex >= this._views.length) {
return Promise.reject('remove index out of range');
}
if (isBlank(opts)) {
opts = {};
}
// default the direction to "back"
opts.direction = opts.direction || DIRECTION_BACK;
// figure out the states of each view in the stack
let leavingView = this._remove(startIndex, removeCount);
if (!leavingView) {
let forcedActive = this.getByState(STATE_FORCE_ACTIVE);
if (forcedActive) {
// this scenario happens when a remove is going on
// during a transition
if (this._trans) {
this._trans.stop();
this._trans.destroy();
this._trans = null;
this._cleanup();
}
return Promise.resolve(false);
}
}
if (leavingView) {
// there is a view ready to leave, meaning that a transition needs
// to happen and the previously active view is going to animate out
// get the view thats ready to enter
let enteringView = this.getByState(STATE_INIT_ENTER);
if (!enteringView && !this.isPortal) {
// oh nos! no entering view to go to!
// if there is no previous view that would enter in this nav stack
// and the option is set to climb up the nav parent looking
// for the next nav we could transition to instead
if (opts.climbNav) {
let parentNav: NavController = this.parent;
while (parentNav) {
if (!parentNav['_tabs']) {
// Tabs can be a parent, but it is not a collection of views
// only we're looking for an actual NavController w/ stack of views
leavingView.fireWillLeave();
this.viewWillLeave.emit(leavingView);
this._app.viewWillLeave.emit(leavingView);
return parentNav.pop(opts).then((rtnVal: boolean) => {
leavingView.fireDidLeave();
this.viewDidLeave.emit(leavingView);
this._app.viewDidLeave.emit(leavingView);
return rtnVal;
});
}
parentNav = parentNav.parent;
}
}
// there's no previous view and there's no valid parent nav
// to climb to so this shouldn't actually remove the leaving
// view because there's nothing that would enter, eww
leavingView.state = STATE_ACTIVE;
return Promise.resolve(false);
}
let resolve: any;
let promise = new Promise(res => { resolve = res; });
if (!opts.animation) {
opts.animation = leavingView.getTransitionName(opts.direction);
}
// start the transition, fire resolve when done...
this._transition(enteringView, leavingView, opts, (hasCompleted: boolean) => {
// transition has completed!!
resolve(hasCompleted);
});
return promise;
}
// no need to transition when the active view isn't being removed
// there's still an active view after _remove() figured out states
// so this means views that were only removed before the active
// view, so auto-resolve since no transition needs to happen
return Promise.resolve(false);
}
/**
* @private
*/
private _remove(startIndex: number, removeCount: number): ViewController {
// when this is done, there should only be at most
// 1 STATE_INIT_ENTER and 1 STATE_INIT_LEAVE
// there should not be any that are STATE_ACTIVE after this is done
let view: ViewController = null;
// loop through each view that is set to be removed
for (var i = startIndex, ii = removeCount + startIndex; i < ii; i++) {
view = this.getByIndex(i);
if (!view) break;
if (view.state === STATE_TRANS_ENTER || view.state === STATE_TRANS_LEAVE) {
// oh no!!! this view should be removed, but it's
// actively transitioning in at the moment!!
// since it's viewable right now, let's just set that
// it should be removed after the transition
view.state = STATE_REMOVE_AFTER_TRANS;
} else if (view.state === STATE_INIT_ENTER) {
// asked to be removed before it even entered!
view.state = STATE_CANCEL_ENTER;
} else {
// if this view is already leaving then no need to immediately
// remove it, otherwise set the remove state
// this is useful if the view being removed isn't going to
// animate out, but just removed from the stack, no transition
view.state = STATE_REMOVE;
}
}
if (view = this.getByState(STATE_INIT_LEAVE)) {
// looks like there's already an active leaving view
// reassign previous entering view to just be inactive
let enteringView = this.getByState(STATE_INIT_ENTER);
if (enteringView) {
enteringView.state = STATE_INACTIVE;
}
// from the index of the leaving view, go backwards and
// find the first view that is inactive
for (var i = this.indexOf(view) - 1; i >= 0; i--) {
if (this._views[i].state === STATE_INACTIVE) {
this._views[i].state = STATE_INIT_ENTER;
break;
}
}
} else if (view = this.getByState(STATE_TRANS_LEAVE)) {
// an active transition is happening, but a new transition
// still needs to happen force this view to be the active one
view.state = STATE_FORCE_ACTIVE;
} else if (view = this.getByState(STATE_REMOVE)) {
// there is no active transition about to happen
// find the first view that is supposed to be removed and
// set that it is the init leaving view
// the first view to be removed, it should init leave
view.state = STATE_INIT_LEAVE;
view.fireWillUnload();
this.viewWillUnload.emit(view);
this._app.viewWillUnload.emit(view);
// from the index of the leaving view, go backwards and
// find the first view that is inactive so it can be the entering
for (var i = this.indexOf(view) - 1; i >= 0; i--) {
if (this._views[i].state === STATE_INACTIVE) {
this._views[i].state = STATE_INIT_ENTER;
break;
}
}
}
// if there is still an active view, then it wasn't one that was
// set to be removed, so there actually won't be a transition at all
view = this.getActive();
if (view) {
// the active view remains untouched, so all the removes
// must have happened before it, so really no need for transition
view = this.getByState(STATE_INIT_ENTER);
if (view) {
// if it was going to enter, then just make inactive
view.state = STATE_INACTIVE;
}
view = this.getByState(STATE_INIT_LEAVE);
if (view) {
// this was going to leave, so just remove it completely
view.state = STATE_REMOVE;
}
}
// remove views that have been set to be removed, but not
// apart of any transitions that will eventually happen
this._views.filter(v => v.state === STATE_REMOVE).forEach(view => {
view.fireWillLeave();
view.fireDidLeave();
this._views.splice(this.indexOf(view), 1);
view.destroy();
});
return this.getByState(STATE_INIT_LEAVE);
}
/**
* @private
*/
private _transition(enteringView: ViewController, leavingView: ViewController, opts: NavOptions, done: Function) {
let transId = ++this._transIds;
if (enteringView === leavingView) {
// if the entering view and leaving view are the same thing don't continue
this._transFinish(transId, enteringView, leavingView, null, false);
return done(false);
}
if (isBlank(opts)) {
opts = {};
}
this._setAnimate(opts);
if (!leavingView) {
// if no leaving view then create a bogus one
leavingView = new ViewController();
}
if (!enteringView) {
// if no entering view then create a bogus one
enteringView = new ViewController();
enteringView.fireLoaded();
}
/* Async steps to complete a transition
1. _render: compile the view and render it in the DOM. Load page if it hasn't loaded already. When done call postRender
2. _postRender: Run willEnter/willLeave, then wait a frame (change detection happens), then call beginTransition
3. _beforeTrans: Create the transition's animation, play the animation, wait for it to end
4. _afterTrans: Run didEnter/didLeave, call _transComplete()
5. _transComplete: Cleanup, remove cache views, then call the final callback
*/
// begin the multiple async process of transitioning to the entering view
this._render(transId, enteringView, leavingView, opts, (hasCompleted: boolean) => {
this._transFinish(transId, enteringView, leavingView, opts.direction, hasCompleted);
done(hasCompleted);
});
}
/**
* @private
*/
private _setAnimate(opts: NavOptions) {
if ((this._views.length === 1 && !this._init && !this.isPortal) || this.config.get('animate') === false) {
opts.animate = false;
}
}
/**
* @private
*/
private _render(transId: number, enteringView: ViewController, leavingView: ViewController, opts: NavOptions, done: Function) {
// compile/load the view into the DOM
if (enteringView.state === STATE_INACTIVE) {
// this entering view is already set to inactive, so this
// transition must be canceled, so don't continue
return done();
}
enteringView.state = STATE_INIT_ENTER;
leavingView.state = STATE_INIT_LEAVE;
// remember if this nav is already transitioning or not
let isAlreadyTransitioning = this.isTransitioning();
if (enteringView.isLoaded()) {
// already compiled this view, do not load again and continue
this._postRender(transId, enteringView, leavingView, isAlreadyTransitioning, opts, done);
} else {
// view has not been compiled/loaded yet
// continue once the view has finished compiling
// DOM WRITE
this.setTransitioning(true, 500);
this.loadPage(enteringView, this._viewport, opts, () => {
enteringView.fireLoaded();
this.viewDidLoad.emit(enteringView);
this._app.viewDidLoad.emit(enteringView);
this._postRender(transId, enteringView, leavingView, isAlreadyTransitioning, opts, done);
});
}
}
/**
* @private
*/
private _postRender(transId: number, enteringView: ViewController, leavingView: ViewController, isAlreadyTransitioning: boolean, opts: NavOptions, done: Function) {
// called after _render has completed and the view is compiled/loaded
if (enteringView.state === STATE_INACTIVE) {
// this entering view is already set to inactive, so this
// transition must be canceled, so don't continue
return done();
}
if (!opts.preload) {
// the enteringView will become the active view, and is not being preloaded
// set the correct zIndex for the entering and leaving views
// if there's already another trans_enter happening then
// the zIndex for the entering view should go off of that one
// DOM WRITE
let lastestLeavingView = this.getByState(STATE_TRANS_ENTER) || leavingView;
this._setZIndex(enteringView, lastestLeavingView, opts.direction);
// make sure the entering and leaving views are showing
// DOM WRITE
if (isAlreadyTransitioning) {
// the previous transition was still going when this one started
// so to be safe, only update showing the entering/leaving
// don't hide the others when they could still be transitioning
enteringView.domShow(true, this._renderer);
leavingView.domShow(true, this._renderer);
} else {
// there are no other transitions happening but this one
// only entering/leaving should show, all others hidden
// also if a view is an overlay or the previous view is an
// overlay then always show the overlay and the view before it
var view: ViewController;
var shouldShow: boolean;
for (var i = 0, ii = this._views.length; i < ii; i++) {
view = this._views[i];
shouldShow = (view === enteringView) ||
(view === leavingView) ||
view.isOverlay ||
(i < ii - 1 ? this._views[i + 1].isOverlay : false);
view.domShow(shouldShow, this._renderer);
}
}
// call each view's lifecycle events
if (leavingView.fireOtherLifecycles) {
// only fire entering lifecycle if the leaving
// view hasn't explicitly set not to
enteringView.fireWillEnter();
this.viewWillEnter.emit(enteringView);
this._app.viewWillEnter.emit(enteringView);
}
if (enteringView.fireOtherLifecycles) {
// only fire leaving lifecycle if the entering
// view hasn't explicitly set not to
leavingView.fireWillLeave();
this.viewWillLeave.emit(leavingView);
this._app.viewWillLeave.emit(leavingView);
}
} else {
// this view is being preloaded, don't call lifecycle events
// transition does not need to animate
opts.animate = false;
}
this._beforeTrans(enteringView, leavingView, opts, done);
}
/**
* @private
*/
private _beforeTrans(enteringView: ViewController, leavingView: ViewController, opts: NavOptions, done: Function) {
// called after one raf from postRender()
// create the transitions animation, play the animation
// when the transition ends call wait for it to end
if (enteringView.state === STATE_INACTIVE || enteringView.state === STATE_CANCEL_ENTER) {
// this entering view is already set to inactive or has been canceled
// so this transition must not begin, so don't continue
return done();
}
enteringView.state = STATE_TRANS_ENTER;
leavingView.state = STATE_TRANS_LEAVE;
// everything during the transition should runOutsideAngular
this._zone.runOutsideAngular(() => {
// init the transition animation
let transitionOpts = {
animation: opts.animation,
direction: opts.direction,
duration: opts.duration,
easing: opts.easing,
renderDelay: opts.transitionDelay || this._trnsDelay,
isRTL: this.config.platform.isRTL(),
ev: opts.ev,
};
let transAnimation = this._createTrans(enteringView, leavingView, transitionOpts);
this._trans && this._trans.destroy();
this._trans = transAnimation;
if (opts.animate === false) {
// force it to not animate the elements, just apply the "to" styles
transAnimation.duration(0);
}
// check if a parent is transitioning and get the time that it ends
let parentTransitionEndTime = this._getLongestTrans(Date.now());
if (parentTransitionEndTime > 0) {
// the parent is already transitioning and has disabled the app
// so just update the local transitioning information
let duration = parentTransitionEndTime - Date.now();
this.setTransitioning(true, duration);
} else {
// this is the only active transition (for now), so disable the app
let keyboardDurationPadding = 0;
if (this._keyboard.isOpen()) {
// add XXms to the duration the app is disabled when the keyboard is open
keyboardDurationPadding = 600;
}
let duration = transAnimation.getDuration() + keyboardDurationPadding;
let enableApp = (duration < 64);
this._app.setEnabled(enableApp, duration);
this.setTransitioning(!enableApp, duration);
}
// create a callback for when the animation is done
transAnimation.onFinish((trans: Transition) => {
// transition animation has ended
// destroy the animation and it's element references
trans.destroy();
this._afterTrans(enteringView, leavingView, opts, trans.hasCompleted, done);
});
// cool, let's do this, start the transition
if (opts.progressAnimation) {
// this is a swipe to go back, just get the transition progress ready
// kick off the swipe animation start
transAnimation.progressStart();
} else {
// this is a normal animation
// kick it off and let it play through
transAnimation.play();
}
});
}
/**
* @private
*/
private _afterTrans(enteringView: ViewController, leavingView: ViewController, opts: NavOptions, hasCompleted: boolean, done: Function) {
// transition has completed, update each view's state
// place back into the zone, run didEnter/didLeave
// call the final callback when done
// run inside of the zone again
this._zone.run(() => {
if (!opts.preload && hasCompleted) {
if (leavingView.fireOtherLifecycles) {
// only fire entering lifecycle if the leaving
// view hasn't explicitly set not to
enteringView.fireDidEnter();
this.viewDidEnter.emit(enteringView);
this._app.viewDidEnter.emit(enteringView);
}
if (enteringView.fireOtherLifecycles && this._init) {
// only fire leaving lifecycle if the entering
// view hasn't explicitly set not to
// and after the nav has initialized
leavingView.fireDidLeave();
this.viewDidLeave.emit(leavingView);
this._app.viewDidLeave.emit(leavingView);
}
}
if (enteringView.state === STATE_INACTIVE) {
// this entering view is already set to inactive, so this
// transition must be canceled, so don't continue
return done(hasCompleted);
}
if (opts.keyboardClose !== false && this._keyboard.isOpen()) {
// the keyboard is still open!
// no problem, let's just close for them
this._keyboard.close();
this._keyboard.onClose(() => {
// keyboard has finished closing, transition complete
done(hasCompleted);
}, 32);
} else {
// all good, transition complete
done(hasCompleted);
}
});
}
/**
* @private
*/
private _transFinish(transId: number, enteringView: ViewController, leavingView: ViewController, direction: string, hasCompleted: boolean) {
// a transition has completed, but not sure if it's the last one or not
// check if this transition is the most recent one or not
if (enteringView.state === STATE_CANCEL_ENTER) {
// this view was told to leave before it finished entering
this.remove(enteringView.index, 1);
}
if (transId === this._transIds) {
// ok, good news, there were no other transitions that kicked
// off during the time this transition started and ended
if (hasCompleted) {
// this transition has completed as normal
// so the entering one is now the active view
// and the leaving view is now just inactive
if (enteringView.state !== STATE_REMOVE_AFTER_TRANS) {
enteringView.state = STATE_ACTIVE;
}
if (leavingView.state !== STATE_REMOVE_AFTER_TRANS) {
leavingView.state = STATE_INACTIVE;
}
// only need to do all this clean up if the transition
// completed, otherwise nothing actually changed
// destroy all of the views that come after the active view
this._cleanup();
// make sure only this entering view and PREVIOUS view are the
// only two views that are not display:none
// do not make any changes to the stack's current visibility
// if there is an overlay somewhere in the stack
leavingView = this.getPrevious(enteringView);
if (this.hasOverlay()) {
// ensure the entering view is showing
enteringView.domShow(true, this._renderer);
} else {
// only possibly hide a view if there are no overlays in the stack
this._views.forEach(view => {
let shouldShow = (view === enteringView) || (view === leavingView);
view.domShow(shouldShow, this._renderer);
});
}
// this check only needs to happen once, which will add the css
// class to the nav when it's finished its first transition
this._init = true;
} else {
// this transition has not completed, meaning the
// entering view did not end up as the active view
// this would happen when swipe to go back started
// but the user did not complete the swipe and the
// what was the active view stayed as the active view
leavingView.state = STATE_ACTIVE;
enteringView.state = STATE_INACTIVE;
}
// check if there is a parent actively transitioning
let transitionEndTime = this._getLongestTrans(Date.now());
// if transitionEndTime is greater than 0, there is a parent transition occurring
// so delegate enabling the app to the parent. If it <= 0, go ahead and enable the app
if (transitionEndTime <= 0) {
this._app && this._app.setEnabled(true);
}
// update that this nav is not longer actively transitioning
this.setTransitioning(false);
// see if we should add the swipe back gesture listeners or not
this._sbCheck();
} else {
// darn, so this wasn't the most recent transition
// so while this one did end, there's another more recent one
// still going on. Because a new transition is happening,
// then this entering view isn't actually going to be the active
// one, so only update the state to active/inactive if the state
// wasn't already updated somewhere else during its transition
if (enteringView.state === STATE_TRANS_ENTER) {
enteringView.state = STATE_INACTIVE;
}
if (leavingView.state === STATE_TRANS_LEAVE) {
leavingView.state = STATE_INACTIVE;
}
}
}
/**
*@private
* This method is just a wrapper to the Transition function of same name
* to make it easy/possible to mock the method call by overriding the function.
* In testing we don't want to actually do the animation, we want to return a stub instead
*/
private _createTrans(enteringView: ViewController, leavingView: ViewController, transitionOpts: any) {
return Transition.createTransition(enteringView, leavingView, transitionOpts);
}
private _cleanup() {
// ok, cleanup time!! Destroy all of the views that are
// INACTIVE and come after the active view
let activeViewIndex = this.indexOf(this.getActive());
let destroys = this._views.filter(v => v.state === STATE_REMOVE_AFTER_TRANS);
for (var i = activeViewIndex + 1; i < this._views.length; i++) {
if (this._views[i].state === STATE_INACTIVE) {
destroys.push(this._views[i]);
}
}
// all pages being destroyed should be removed from the list of
// pages and completely removed from the dom
destroys.forEach(view => {
this._views.splice(this.indexOf(view), 1);
view.destroy();
this.viewDidUnload.emit(view);
this._app.viewDidUnload.emit(view);
});
// if any z-index goes under 0, then reset them all
let shouldResetZIndex = this._views.some(v => v.zIndex < 0);
if (shouldResetZIndex) {
this._views.forEach(view => {
view.setZIndex(view.zIndex + INIT_ZINDEX + 1, this._renderer);
});
}
}
/**
* @private
*/
getActiveChildNav(): any {
return this._children[this._children.length - 1];
}
/**
* @private
*/
registerChildNav(nav: any) {
this._children.push(nav);
}
/**
* @private
*/
unregisterChildNav(nav: any) {
let index = this._children.indexOf(nav);
if (index > -1) {
this._children.splice(index, 1);
}
}
/**
* @private
*/
ngOnDestroy() {
for (var i = this._views.length - 1; i >= 0; i--) {
this._views[i].destroy();
}
this._views.length = 0;
if (this.parent && this.parent.unregisterChildNav) {
this.parent.unregisterChildNav(this);
}
}
/**
* @private
*/
loadPage(view: ViewController, viewport: ViewContainerRef, opts: NavOptions, done: Function) {
if (!viewport || !view.componentType) {
return;
}
// TEMPORARY: automatically set selector w/ dah reflector
// TODO: use componentFactory.create once fixed
addSelector(view.componentType, 'ion-page');
this._compiler.resolveComponent(view.componentType).then(componentFactory => {
if (view.state === STATE_CANCEL_ENTER) {
// view may have already been removed from the stack
// if so, don't even bother adding it
view.destroy();
this._views.splice(view.index, 1);
return;
}
// add more providers to just this page
let componentProviders = ReflectiveInjector.resolve([
provide(NavController, {useValue: this}),
provide(ViewController, {useValue: view}),
provide(NavParams, {useValue: view.getNavParams()})
]);
let childInjector = ReflectiveInjector.fromResolvedProviders(componentProviders, this._viewport.parentInjector);
let componentRef = componentFactory.create(childInjector, null, null);
viewport.insert(componentRef.hostView, viewport.length);
// a new ComponentRef has been created
// set the ComponentRef's instance to its ViewController
view.setInstance(componentRef.instance);
// the component has been loaded, so call the view controller's loaded method to load any dependencies into the dom
view.loaded(() => {
// the ElementRef of the actual ion-page created
let pageElementRef = componentRef.location;
// remember the ChangeDetectorRef for this ViewController
view.setChangeDetector(componentRef.changeDetectorRef);
// remember the ElementRef to the ion-page elementRef that was just created
view.setPageRef(pageElementRef);
// auto-add page css className created from component JS class name
let cssClassName = pascalCaseToDashCase(view.componentType.name);
this._renderer.setElementClass(pageElementRef.nativeElement, cssClassName, true);
view.onDestroy(() => {
// ensure the element is cleaned up for when the view pool reuses this element
this._renderer.setElementAttribute(pageElementRef.nativeElement, 'class', null);
this._renderer.setElementAttribute(pageElementRef.nativeElement, 'style', null);
componentRef.destroy();
});
// our job is done here
done(view);
});
});
}
/**
* @private
*/
swipeBackStart() {
// default the direction to "back"
let opts: NavOptions = {
direction: DIRECTION_BACK,
progressAnimation: true
};
// figure out the states of each view in the stack
let leavingView = this._remove(this._views.length - 1, 1);
if (leavingView) {
opts.animation = leavingView.getTransitionName(opts.direction);
// get the view thats ready to enter
let enteringView = this.getByState(STATE_INIT_ENTER);
// start the transition, fire callback when done...
this._transition(enteringView, leavingView, opts, (hasCompleted: boolean) => {
// swipe back has finished!!
console.debug('swipeBack, hasCompleted', hasCompleted);
});
}
}
/**
* @private
*/
swipeBackProgress(stepValue: number) {
if (this._trans && this._sbGesture) {
// continue to disable the app while actively dragging
this._app.setEnabled(false, 4000);
this.setTransitioning(true, 4000);
// set the transition animation's progress
this._trans.progressStep(stepValue);
}
}
/**
* @private
*/
swipeBackEnd(shouldComplete: boolean, currentStepValue: number) {
if (this._trans && this._sbGesture) {
// the swipe back gesture has ended
this._trans.progressEnd(shouldComplete, currentStepValue);
}
}
/**
* @private
*/
private _sbCheck() {
if (this._sbEnabled) {
// this nav controller can have swipe to go back
if (!this._sbGesture) {
// create the swipe back gesture if we haven't already
let opts = {
edge: 'left',
threshold: this._sbThreshold
};
this._sbGesture = new SwipeBackGesture(this.getNativeElement(), opts, this, this._gestureCtrl);
}
if (this.canSwipeBack()) {
// it is be possible to swipe back
if (!this._sbGesture.isListening) {
this._zone.runOutsideAngular(() => {
// start listening if it's not already
console.debug('swipeBack gesture, listen');
this._sbGesture.listen();
});
}
} else if (this._sbGesture.isListening) {
// it should not be possible to swipe back
// but the gesture is still listening
console.debug('swipeBack gesture, unlisten');
this._sbGesture.unlisten();
}
}
}
/**
* If it's possible to use swipe back or not. If it's not possible
* to go back, or swipe back is not enabled, then this will return `false`.
* If it is possible to go back, and swipe back is enabled, then this
* will return `true`.
* @returns {boolean}
*/
canSwipeBack(): boolean {
return (this._sbEnabled && !this.isTransitioning() && this._app.isEnabled() && this.canGoBack());
}
/**
* Returns `true` if there's a valid previous page that we can pop
* back to. Otherwise returns `false`.
* @returns {boolean}
*/
canGoBack(): boolean {
let activeView = this.getActive();
if (activeView) {
return activeView.enableBack();
}
return false;
}
/**
* Returns if the nav controller is actively transitioning or not.
* @return {boolean}
*/
isTransitioning(includeAncestors?: boolean): boolean {
let now = Date.now();
if (includeAncestors && this._getLongestTrans(now) > 0) {
return true;
}
return (this.trnsTime > now);
}
/**
* @private
*/
setTransitioning(isTransitioning: boolean, fallback: number = 700) {
this.trnsTime = (isTransitioning ? Date.now() + fallback : 0);
}
/**
* @private
* This method traverses the tree of parents upwards
* and looks at the time the transition ends (if it's transitioning)
* and returns the value that is the furthest into the future
* thus giving us the longest transition duration
*/
private _getLongestTrans(now: number) {
let parentNav = <NavController>this.parent;
let transitionEndTime = -1;
while (parentNav) {
if (parentNav.trnsTime > transitionEndTime) {
transitionEndTime = parentNav.trnsTime;
}
parentNav = parentNav.parent;
}
// only check if the transitionTime is greater than the current time once
return transitionEndTime > 0 && transitionEndTime > now ? transitionEndTime : 0;
}
/**
* @private
*/
hasOverlay(): boolean {
for (var i = this._views.length - 1; i >= 0; i--) {
if (this._views[i].isOverlay) {
return true;
}
}
return false;
}
/**
* @private
*/
getByState(state: number): ViewController {
for (var i = this._views.length - 1; i >= 0; i--) {
if (this._views[i].state === state) {
return this._views[i];
}
}
return null;
}
/**
* @param {number} index The index of the page to get.
* @returns {ViewController} Returns the view controller that matches the given index.
*/
getByIndex(index: number): ViewController {
return (index < this._views.length && index > -1 ? this._views[index] : null);
}
/**
* @returns {ViewController} Returns the active page's view controller.
*/
getActive(): ViewController {
return this.getByState(STATE_ACTIVE);
}
/**
* @param {ViewController} view
* @returns {boolean}
*/
isActive(view: ViewController): boolean {
return !!(view && view.state === STATE_ACTIVE);
}
/**
* Returns the view controller which is before the given view controller.
* @param {ViewController} view
* @returns {viewController}
*/
getPrevious(view: ViewController): ViewController {
return this.getByIndex(this.indexOf(view) - 1);
}
/**
* Returns the first view controller in this nav controller's stack.
* @returns {ViewController}
*/
first(): ViewController {
return (this._views.length ? this._views[0] : null);
}
/**
* Returns the last page in this nav controller's stack.
* @returns {ViewController}
*/
last(): ViewController {
return (this._views.length ? this._views[this._views.length - 1] : null);
}
/**
* Returns the index number of the given view controller.
* @param {ViewController} view
* @returns {number}
*/
indexOf(view: ViewController): number {
return this._views.indexOf(view);
}
/**
* Returns the number of views in this nav controller.
* @returns {number} The number of views in this stack, including the current view.
*/
length(): number {
return this._views.length;
}
/**
* @private
*/
isSwipeBackEnabled(): boolean {
return this._sbEnabled;
}
/**
* Returns the root `NavController`.
* @returns {NavController}
*/
get rootNav(): NavController {
let nav = this;
while (nav.parent) {
nav = nav.parent;
}
return nav;
}
/**
* @private
* Dismiss all pages which have set the `dismissOnPageChange` property.
*/
dismissPageChangeViews() {
this._views.forEach(view => {
if (view.data && view.data.dismissOnPageChange) {
view.dismiss();
}
});
}
/**
* @private
*/
private _setZIndex(enteringView: ViewController, leavingView: ViewController, direction: string) {
if (enteringView) {
// get the leaving view, which could be in various states
if (!leavingView || !leavingView.isLoaded()) {
// the leavingView is a mocked view, either we're
// actively transitioning or it's the initial load
var previousView = this.getPrevious(enteringView);
if (previousView && previousView.isLoaded()) {
// we found a better previous view to reference
// use this one instead
enteringView.setZIndex(previousView.zIndex + 1, this._renderer);
} else {
// this is the initial view
enteringView.setZIndex(this.isPortal ? PORTAL_ZINDEX : INIT_ZINDEX, this._renderer);
}
} else if (direction === DIRECTION_BACK) {
// moving back
enteringView.setZIndex(leavingView.zIndex - 1, this._renderer);
} else {
// moving forward
enteringView.setZIndex(leavingView.zIndex + 1, this._renderer);
}
}
}
}
const STATE_ACTIVE = 1;
const STATE_INACTIVE = 2;
const STATE_INIT_ENTER = 3;
const STATE_INIT_LEAVE = 4;
const STATE_TRANS_ENTER = 5;
const STATE_TRANS_LEAVE = 6;
const STATE_REMOVE = 7;
const STATE_REMOVE_AFTER_TRANS = 8;
const STATE_CANCEL_ENTER = 9;
const STATE_FORCE_ACTIVE = 10;
export const DIRECTION_BACK = 'back';
export const DIRECTION_FORWARD = 'forward';
const INIT_ZINDEX = 100;
const PORTAL_ZINDEX = 9999;
let ctrlIds = -1;