From ed221eff1d77f47220468f4f65028d3296df046b Mon Sep 17 00:00:00 2001 From: Adam Bradley Date: Tue, 13 Sep 2016 17:13:43 -0500 Subject: [PATCH] refactor(nav): queue based transitions --- src/components/nav/nav-controller-base.ts | 1328 ----------------- src/components/nav/nav-interfaces.ts | 18 - src/components/nav/nav-pop.ts | 51 +- src/components/nav/nav-push.ts | 45 +- src/components/nav/nav.ts | 68 +- .../nav/{nav-portal.ts => overlay-portal.ts} | 17 +- .../nav/test/view-controller.spec.ts | 116 -- src/components/tabs/tab.ts | 127 +- src/components/tabs/tabs.ts | 342 ++--- src/navigation/nav-controller-base.ts | 959 ++++++++++++ .../nav => navigation}/nav-controller.ts | 99 +- .../nav => navigation}/nav-params.ts | 0 src/navigation/nav-util.ts | 176 +++ .../nav => navigation}/swipe-back.ts | 9 +- src/navigation/test/nav-controller.spec.ts | 994 ++++++++++++ src/navigation/test/nav-util.spec.ts | 129 ++ src/navigation/test/view-controller.spec.ts | 98 ++ .../nav => navigation}/view-controller.ts | 477 +++--- 18 files changed, 2970 insertions(+), 2083 deletions(-) delete mode 100644 src/components/nav/nav-controller-base.ts delete mode 100644 src/components/nav/nav-interfaces.ts rename src/components/nav/{nav-portal.ts => overlay-portal.ts} (58%) delete mode 100644 src/components/nav/test/view-controller.spec.ts create mode 100644 src/navigation/nav-controller-base.ts rename src/{components/nav => navigation}/nav-controller.ts (90%) rename src/{components/nav => navigation}/nav-params.ts (100%) create mode 100644 src/navigation/nav-util.ts rename src/{components/nav => navigation}/swipe-back.ts (82%) create mode 100644 src/navigation/test/nav-controller.spec.ts create mode 100644 src/navigation/test/nav-util.spec.ts create mode 100644 src/navigation/test/view-controller.spec.ts rename src/{components/nav => navigation}/view-controller.ts (58%) diff --git a/src/components/nav/nav-controller-base.ts b/src/components/nav/nav-controller-base.ts deleted file mode 100644 index adca27b24f..0000000000 --- a/src/components/nav/nav-controller-base.ts +++ /dev/null @@ -1,1328 +0,0 @@ -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, isPresent, pascalCaseToDashCase } from '../../util/util'; -import { Keyboard } from '../../util/keyboard'; -import { NavController } from './nav-controller'; -import { NavOptions, DIRECTION_BACK, DIRECTION_FORWARD } from './nav-interfaces'; -import { NavParams } from './nav-params'; -import { SwipeBackGesture } from './swipe-back'; -import { Transition } from '../../transitions/transition'; -import { ViewController } from './view-controller'; - - -/** - * This class is for internal use only. It is not exported publicly. - */ -export class NavControllerBase extends Ion implements NavController { - _transIds = 0; - _init = false; - _isPortal: boolean; - _trans: Transition; - _sbGesture: SwipeBackGesture; - _sbThreshold: number; - _viewport: ViewContainerRef; - _children: any[] = []; - _sbEnabled: boolean; - _ids: number = -1; - _trnsDelay: any; - _views: ViewController[] = []; - - viewDidLoad: EventEmitter; - viewWillEnter: EventEmitter; - viewDidEnter: EventEmitter; - viewWillLeave: EventEmitter; - viewDidLeave: EventEmitter; - viewWillUnload: EventEmitter; - viewDidUnload: EventEmitter; - - id: string; - parent: any; - config: Config; - trnsTime: number = 0; - - constructor( - parent: any, - public _app: App, - config: Config, - public _keyboard: Keyboard, - elementRef: ElementRef, - public _zone: NgZone, - public _renderer: Renderer, - public _compiler: ComponentResolver, - public _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 = 'n' + (++ctrlIds); - - 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(); - } - - setViewport(val: ViewContainerRef) { - this._viewport = val; - } - - setRoot(page: any, params?: any, opts?: NavOptions): Promise { - return this.setPages([{page, params}], opts); - } - - setPages(pages: Array<{page: any, params?: any}>, opts?: NavOptions): Promise { - 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(page: any, params?: any, opts?: NavOptions, done?: Function): Promise { - return this.insertPages(-1, [{page: page, params: params}], opts, done); - } - - /** - * DEPRECATED: Please use inject the overlays controller and use the present method on the instance instead. - */ - private present(enteringView: ViewController, opts?: NavOptions): Promise { - // 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(); - } - - insert(insertIndex: number, page: any, params?: any, opts?: NavOptions, done?: Function): Promise { - return this.insertPages(insertIndex, [{page: page, params: params}], opts, done); - } - - insertPages(insertIndex: number, insertPages: Array<{page: any, params?: any}>, opts?: NavOptions, done?: Function): Promise { - let views = insertPages.map(p => new ViewController(p.page, p.params)); - return this.insertViews(insertIndex, views, opts, done); - } - - insertViews(insertIndex: number, insertViews: ViewController[], opts: NavOptions = {}, done?: Function) { - let promise: Promise; - if (!done) { - // only create a promise if a done callback wasn't provided - promise = new Promise(res => { done = res; }); - } - - if (!insertViews || !insertViews.length) { - done(false); - return promise; - } - - if (isBlank(opts)) { - opts = {}; - } - - // insert the new page into the stack - // returns the newly created entering view - let enteringView = this._insert(insertIndex, insertViews); - - // manually set the new view's id if an id was passed in the options - if (isPresent(opts.id)) { - enteringView.id = opts.id; - } - - // 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); - } - - // 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); - if (!leavingView && this._isPortal) { - // if we didn't find an active view, and this is a portal - let activeNav = this._app.getActiveNav(); - if (activeNav) { - leavingView = activeNav.getByState(STATE_INIT_LEAVE); - } - } - - // start the transition, fire resolve when done... - this._transition(enteringView, leavingView, opts, done); - 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 - done(enteringView); - - return promise; - } - - _insert(insertIndex: number, insertViews: 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 && this._isPortal) { - // if we didn't find an active view, and this is a portal - let activeNav = this._app.getActiveNav(); - if (activeNav) { - view = activeNav.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; - } - - pop(opts?: NavOptions, done?: Function): Promise { - // 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(); - - return this.remove(this.indexOf(activeView), 1, opts, done); - } - - popToRoot(opts?: NavOptions, done?: Function): Promise { - return this.popTo(this.first(), opts, done); - } - - popTo(view: ViewController, opts?: NavOptions, done?: Function): Promise { - 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, done); - } - - remove(startIndex: number = -1, removeCount: number = 1, opts?: NavOptions, done?: Function): Promise { - let promise: Promise; - - if (!done) { - promise = new Promise(resolve => { done = resolve; }); - } - - if (startIndex === -1) { - startIndex = (this._views.length - 1); - - } else if (startIndex < 0 || startIndex >= this._views.length) { - console.error('index out of range removing view from nav'); - done(false); - return promise; - } - - 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; - } - - // 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(); - } - - done(false); - return promise; - } - } - - 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) { - // if we didn't find an active view, and this is a portal - let activeNav = this._app.getActiveNav(); - if (activeNav) { - enteringView = activeNav.last(); - if (enteringView) { - enteringView.state = 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 (!isTabs(parentNav)) { - // 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; - done(false); - - return promise; - } - - if (!opts.animation) { - opts.animation = leavingView.getTransitionName(opts.direction); - } - - // start the transition, fire resolve when done... - this._transition(enteringView, leavingView, opts, done); - - 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 - done(false); - return promise; - } - - /** - * @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 - */ - _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, false); - done(false); - return; - } - - 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, false, hasCompleted); - done(hasCompleted); - }); - } - - /** - * @private - */ - _setAnimate(opts: NavOptions) { - if ((this._views.length === 1 && !this._init && !this._isPortal) || this.config.get('animate') === false) { - opts.animate = false; - } - } - - /** - * @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 - */ - _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 - this._views.forEach(view => { - view.domShow(this._isPortal || (view === enteringView) || (view === leavingView), 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 - */ - _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 - */ - _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 - */ - _transFinish(transId: number, enteringView: ViewController, leavingView: ViewController, direction: string, updateUrl: boolean, 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._isPortal) { - // 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 => { - view.domShow((view === enteringView) || (view === leavingView), 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 - */ - _createTrans(enteringView: ViewController, leavingView: ViewController, transitionOpts: any): Transition { - return Transition.createTransition(enteringView, leavingView, transitionOpts); - } - - _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); - }); - } - } - - 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 - */ - _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(); - } - } - } - - canSwipeBack(): boolean { - return (this._sbEnabled && !this.isTransitioning() && this._app.isEnabled() && this.canGoBack()); - } - - canGoBack(): boolean { - let activeView = this.getActive(); - if (activeView) { - return activeView.enableBack(); - } - return false; - } - - isTransitioning(includeAncestors?: boolean): boolean { - let now = Date.now(); - if (includeAncestors && this.getLongestTrans(now) > 0) { - return true; - } - return (this.trnsTime > now); - } - - setTransitioning(isTransitioning: boolean, fallback: number = 700) { - this.trnsTime = (isTransitioning ? Date.now() + fallback : 0); - } - - getLongestTrans(now: number) { - // traverses 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 - let parentNav = 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; - } - - 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; - } - - getByIndex(index: number): ViewController { - return (index < this._views.length && index > -1 ? this._views[index] : null); - } - - getActive(): ViewController { - return this.getByState(STATE_ACTIVE); - } - - isActive(view: ViewController): boolean { - // returns if the given view is the active view or not - return !!(view && view.state === STATE_ACTIVE); - } - - getPrevious(view: ViewController): ViewController { - // returns the view controller which is before the given view controller. - return this.getByIndex(this.indexOf(view) - 1); - } - - first(): ViewController { - // returns the first view controller in this nav controller's stack. - return (this._views.length ? this._views[0] : null); - } - - last(): ViewController { - // returns the last page in this nav controller's stack. - return (this._views.length ? this._views[this._views.length - 1] : null); - } - - indexOf(view: ViewController): number { - // returns the index number of the given view controller. - return this._views.indexOf(view); - } - - length(): number { - return this._views.length; - } - - isSwipeBackEnabled(): boolean { - return this._sbEnabled; - } - - /** - * DEPRECATED: Please use app.getRootNav() instead - */ - private get rootNav(): NavController { - // deprecated 07-14-2016 beta.11 - console.warn('nav.rootNav() has been deprecated, please use app.getRootNav() instead'); - return this._app.getRootNav(); - } - - /** - * @private - * Dismiss all pages which have set the `dismissOnPageChange` property. - */ - dismissPageChangeViews() { - this._views.forEach(view => { - if (view.data && view.data.dismissOnPageChange) { - view.dismiss(); - } - }); - } - - /** - * @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); - } - } - } - -} - -export const isTabs = (nav: any) => { - // Tabs (ion-tabs) - return !!nav.getSelected; -}; - -export const isTab = (nav: any) => { - // Tab (ion-tab) - return isPresent(nav._tabId); -}; - -export const isNav = function(nav: any) { - // Nav (ion-nav), Tab (ion-tab), Portal (ion-portal) - return isPresent(nav.push); -}; - - -export const STATE_ACTIVE = 1; -export const STATE_INACTIVE = 2; -export const STATE_INIT_ENTER = 3; -export const STATE_INIT_LEAVE = 4; -export const STATE_TRANS_ENTER = 5; -export const STATE_TRANS_LEAVE = 6; -export const STATE_REMOVE = 7; -export const STATE_REMOVE_AFTER_TRANS = 8; -export const STATE_CANCEL_ENTER = 9; -export const STATE_FORCE_ACTIVE = 10; - -const INIT_ZINDEX = 100; -const PORTAL_ZINDEX = 9999; - -let ctrlIds = -1; \ No newline at end of file diff --git a/src/components/nav/nav-interfaces.ts b/src/components/nav/nav-interfaces.ts deleted file mode 100644 index 55f2278053..0000000000 --- a/src/components/nav/nav-interfaces.ts +++ /dev/null @@ -1,18 +0,0 @@ - -export interface NavOptions { - animate?: boolean; - animation?: string; - direction?: string; - duration?: number; - easing?: string; - id?: string; - keyboardClose?: boolean; - preload?: boolean; - transitionDelay?: number; - progressAnimation?: boolean; - climbNav?: boolean; - ev?: any; -} - -export const DIRECTION_BACK = 'back'; -export const DIRECTION_FORWARD = 'forward'; diff --git a/src/components/nav/nav-pop.ts b/src/components/nav/nav-pop.ts index 3c9c42653a..d9801b35d4 100644 --- a/src/components/nav/nav-pop.ts +++ b/src/components/nav/nav-pop.ts @@ -1,6 +1,9 @@ -import { Directive, HostListener, Input, Optional } from '@angular/core'; -import { NavController } from './nav-controller'; -import { noop } from '../../util/util'; +import { AfterViewInit, Directive, HostBinding, HostListener, OnChanges, Optional } from '@angular/core'; + +import { DeepLinker } from '../../navigation/deep-linker'; +import { NavController } from '../../navigation/nav-controller'; +import { ViewController } from '../../navigation/view-controller'; + /** * @name NavPop @@ -27,9 +30,9 @@ import { noop } from '../../util/util'; }) export class NavPop { - constructor(@Optional() private _nav: NavController) { + constructor(@Optional() public _nav: NavController) { if (!_nav) { - console.error('nav-pop must be within a NavController'); + console.error('navPop must be within a NavController'); } } @@ -37,7 +40,7 @@ export class NavPop { onClick(): boolean { // If no target, or if target is _self, prevent default browser behavior if (this._nav) { - this._nav.pop(null, noop); + this._nav.pop(null, null); return false; } @@ -45,3 +48,39 @@ export class NavPop { } } + + +/** + * @private + */ +@Directive({ + selector: 'a[navPop]' +}) +export class NavPopAnchor implements OnChanges, AfterViewInit { + + constructor( + @Optional() public host: NavPop, + public linker: DeepLinker, + @Optional() public viewCtrl: ViewController) {} + + @HostBinding() href: string; + + updateHref() { + if (this.host && this.viewCtrl) { + const previousView = this.host._nav.getPrevious(this.viewCtrl); + this.href = (previousView && this.linker.createUrl(this.host._nav, this.viewCtrl.component, this.viewCtrl.data)) || '#'; + + } else { + this.href = '#'; + } + } + + ngOnChanges() { + this.updateHref(); + } + + ngAfterViewInit() { + this.updateHref(); + } + +} diff --git a/src/components/nav/nav-push.ts b/src/components/nav/nav-push.ts index ca1c14e408..99c9dc54f3 100644 --- a/src/components/nav/nav-push.ts +++ b/src/components/nav/nav-push.ts @@ -1,7 +1,7 @@ -import { Directive, HostListener, Input, Optional } from '@angular/core'; +import { AfterViewInit, Directive, Host, HostBinding, HostListener, Input, Optional, OnChanges } from '@angular/core'; -import { NavController } from './nav-controller'; -import { noop } from '../../util/util'; +import { DeepLinker } from '../../navigation/deep-linker'; +import { NavController } from '../../navigation/nav-controller'; /** * @name NavPush @@ -60,7 +60,7 @@ export class NavPush { @Input() navParams: {[k: string]: any}; - constructor(@Optional() private _nav: NavController) { + constructor(@Optional() public _nav: NavController) { if (!_nav) { console.error('navPush must be within a NavController'); } @@ -68,13 +68,44 @@ export class NavPush { @HostListener('click') onClick(): boolean { - // If no target, or if target is _self, prevent default browser behavior if (this._nav) { - this._nav.push(this.navPush, this.navParams, noop); + this._nav.push(this.navPush, this.navParams, null); return false; } - return true; } } + +/** + * @private + */ +@Directive({ + selector: 'a[navPush]' +}) +export class NavPushAnchor implements OnChanges, AfterViewInit { + + constructor( + @Host() public host: NavPush, + @Optional() public linker: DeepLinker) {} + + @HostBinding() href: string; + + updateHref() { + if (this.host && this.linker) { + this.href = this.linker.createUrl(this.host._nav, this.host.navPush, this.host.navParams) || '#'; + + } else { + this.href = '#'; + } + } + + ngOnChanges() { + this.updateHref(); + } + + ngAfterViewInit() { + this.updateHref(); + } + +} diff --git a/src/components/nav/nav.ts b/src/components/nav/nav.ts index ba6b4e4159..4044f285f5 100644 --- a/src/components/nav/nav.ts +++ b/src/components/nav/nav.ts @@ -1,12 +1,15 @@ -import { AfterViewInit, Component, ComponentResolver, ElementRef, Input, Optional, NgZone, Renderer, ViewChild, ViewContainerRef, ViewEncapsulation } from '@angular/core'; +import { AfterViewInit, Component, ComponentFactoryResolver, ElementRef, Input, Optional, NgZone, Renderer, ViewChild, ViewContainerRef, ViewEncapsulation } from '@angular/core'; import { App } from '../app/app'; import { Config } from '../../config/config'; -import { Keyboard } from '../../util/keyboard'; +import { DeepLinker } from '../../navigation/deep-linker'; import { GestureController } from '../../gestures/gesture-controller'; import { isTrueProperty } from '../../util/util'; -import { NavControllerBase } from './nav-controller-base'; -import { ViewController } from './view-controller'; +import { Keyboard } from '../../util/keyboard'; +import { NavControllerBase } from '../../navigation/nav-controller-base'; +import { NavOptions } from '../../navigation/nav-util'; +import { TransitionController } from '../../transitions/transition-controller'; +import { ViewController } from '../../navigation/view-controller'; /** * @name Nav @@ -24,32 +27,27 @@ import { ViewController } from './view-controller'; * * ```ts * import { Component } from '@angular/core'; - * import { ionicBootstrap } from 'ionic-angular'; * import { GettingStartedPage } from './getting-started'; * * @Component({ * template: `` * }) * class MyApp { - * private root: any = GettingStartedPage; + * root = GettingStartedPage; * * constructor(){ * } * } - * - * ionicBootstrap(MyApp); * ``` * - * * @demo /docs/v2/demos/navigation/ * @see {@link /docs/v2/components#navigation Navigation Component Docs} */ @Component({ selector: 'ion-nav', - template: ` -
- - `, + template: + '
' + + '', encapsulation: ViewEncapsulation.None, }) export class Nav extends NavControllerBase implements AfterViewInit { @@ -65,16 +63,17 @@ export class Nav extends NavControllerBase implements AfterViewInit { elementRef: ElementRef, zone: NgZone, renderer: Renderer, - compiler: ComponentResolver, - gestureCtrl: GestureController + cfr: ComponentFactoryResolver, + gestureCtrl: GestureController, + transCtrl: TransitionController, + @Optional() linker: DeepLinker ) { - super(parent, app, config, keyboard, elementRef, zone, renderer, compiler, gestureCtrl); + super(parent, app, config, keyboard, elementRef, zone, renderer, cfr, gestureCtrl, transCtrl, linker); if (viewCtrl) { // an ion-nav can also act as an ion-page within a parent ion-nav // this would happen when an ion-nav nests a child ion-nav. - viewCtrl.setContent(this); - viewCtrl.setContentRef(elementRef); + viewCtrl._setContent(this); } if (parent) { @@ -89,7 +88,7 @@ export class Nav extends NavControllerBase implements AfterViewInit { } else if (app && !app.getRootNav()) { // a root nav has not been registered yet with the app // this is the root navcontroller for the entire app - app.setRootNav(this); + app._setRootNav(this); } } @@ -101,17 +100,26 @@ export class Nav extends NavControllerBase implements AfterViewInit { this.setViewport(val); } - /** - * @private - */ ngAfterViewInit() { this._hasInit = true; - if (this._root) { - this.push(this._root); + let navSegment = this._linker.initNav(this); + if (navSegment && navSegment.component) { + // there is a segment match in the linker + this.setPages(this._linker.initViews(navSegment), null, null); + + } else if (this._root) { + // no segment match, so use the root property + this.push(this._root, this.rootParams, { + isNavRoot: (this._app.getRootNav() === this) + }, null); } } + goToRoot(opts: NavOptions) { + this.setRoot(this._root, this.rootParams, opts, null); + } + /** * @input {Page} The Page component to load as the root page within this nav. */ @@ -127,6 +135,11 @@ export class Nav extends NavControllerBase implements AfterViewInit { } } + /** + * @input {object} Any nav-params to pass to the root page of this nav. + */ + @Input() rootParams: any; + /** * @input {boolean} Whether it's possible to swipe-to-go-back on this nav controller or not. */ @@ -138,4 +151,11 @@ export class Nav extends NavControllerBase implements AfterViewInit { this._sbEnabled = isTrueProperty(val); } + /** + * @private + */ + destroy() { + this.destroy(); + } + } diff --git a/src/components/nav/nav-portal.ts b/src/components/nav/overlay-portal.ts similarity index 58% rename from src/components/nav/nav-portal.ts rename to src/components/nav/overlay-portal.ts index 49314018e8..2dc638ae07 100644 --- a/src/components/nav/nav-portal.ts +++ b/src/components/nav/overlay-portal.ts @@ -1,18 +1,20 @@ -import { ComponentResolver, Directive, ElementRef, forwardRef, Inject, NgZone, Optional, Renderer, ViewContainerRef } from '@angular/core'; +import { ComponentFactoryResolver, Directive, ElementRef, forwardRef, Inject, NgZone, Optional, Renderer, ViewContainerRef } from '@angular/core'; import { App } from '../app/app'; import { Config } from '../../config/config'; +import { DeepLinker } from '../../navigation/deep-linker'; import { GestureController } from '../../gestures/gesture-controller'; import { Keyboard } from '../../util/keyboard'; -import { NavControllerBase } from '../nav/nav-controller-base'; +import { NavControllerBase } from '../../navigation/nav-controller-base'; +import { TransitionController } from '../../transitions/transition-controller'; /** * @private */ @Directive({ - selector: '[nav-portal]' + selector: '[overlay-portal]' }) -export class NavPortal extends NavControllerBase { +export class OverlayPortal extends NavControllerBase { constructor( @Inject(forwardRef(() => App)) app: App, config: Config, @@ -20,15 +22,16 @@ export class NavPortal extends NavControllerBase { elementRef: ElementRef, zone: NgZone, renderer: Renderer, - compiler: ComponentResolver, + cfr: ComponentFactoryResolver, gestureCtrl: GestureController, + transCtrl: TransitionController, + @Optional() linker: DeepLinker, viewPort: ViewContainerRef ) { - super(null, app, config, keyboard, elementRef, zone, renderer, compiler, gestureCtrl); + super(null, app, config, keyboard, elementRef, zone, renderer, cfr, gestureCtrl, transCtrl, linker); this._isPortal = true; this._init = true; this.setViewport(viewPort); - app.setPortal(this); // on every page change make sure the portal has // dismissed any views that should be auto dismissed on page change diff --git a/src/components/nav/test/view-controller.spec.ts b/src/components/nav/test/view-controller.spec.ts deleted file mode 100644 index 3a923bbba9..0000000000 --- a/src/components/nav/test/view-controller.spec.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { LifeCycleEvent, ViewController } from '../../../../src'; - -export function run() { - describe('ViewController', () => { - - afterEach(() => { - if ( subscription ) { - subscription.unsubscribe(); - } - }); - - describe('willEnter', () => { - it('should emit LifeCycleEvent when called with component data', (done) => { - // arrange - let viewController = new ViewController(FakePage); - subscription = viewController.willEnter.subscribe((event: LifeCycleEvent) => { - // assert - expect(event).toEqual(null); - done(); - }, (err: any) => { - done(err); - }); - - // act - viewController.fireWillEnter(); - }, 10000); - }); - - describe('didEnter', () => { - it('should emit LifeCycleEvent when called with component data', (done) => { - // arrange - let viewController = new ViewController(FakePage); - subscription = viewController.didEnter.subscribe((event: LifeCycleEvent) => { - // assert - expect(event).toEqual(null); - done(); - }, (err: any) => { - done(err); - }); - - // act - viewController.fireDidEnter(); - }, 10000); - }); - - describe('willLeave', () => { - it('should emit LifeCycleEvent when called with component data', (done) => { - // arrange - let viewController = new ViewController(FakePage); - subscription = viewController.willLeave.subscribe((event: LifeCycleEvent) => { - // assert - expect(event).toEqual(null); - done(); - }, (err: any) => { - done(err); - }); - - // act - viewController.fireWillLeave(); - }, 10000); - }); - - describe('didLeave', () => { - it('should emit LifeCycleEvent when called with component data', (done) => { - // arrange - let viewController = new ViewController(FakePage); - subscription = viewController.didLeave.subscribe((event: LifeCycleEvent) => { - // assert - expect(event).toEqual(null); - done(); - }, (err: any) => { - done(err); - }); - - // act - viewController.fireDidLeave(); - }, 10000); - }); - - describe('willUnload', () => { - it('should emit LifeCycleEvent when called with component data', (done) => { - // arrange - let viewController = new ViewController(FakePage); - subscription = viewController.willUnload.subscribe((event: LifeCycleEvent) => { - expect(event).toEqual(null); - done(); - }, (err: any) => { - done(err); - }); - - // act - viewController.fireWillUnload(); - }, 10000); - }); - - describe('destroy', () => { - it('should emit LifeCycleEvent when called with component data', (done) => { - // arrange - let viewController = new ViewController(FakePage); - subscription = viewController.didUnload.subscribe((event: LifeCycleEvent) => { - // assert - expect(event).toEqual(null); - done(); - }, (err: any) => { - done(err); - }); - - // act - viewController.destroy(); - }, 10000); - }); - }); - - let subscription: any = null; - class FakePage {} -} diff --git a/src/components/tabs/tab.ts b/src/components/tabs/tab.ts index 8a1f4cc619..12c3a01980 100644 --- a/src/components/tabs/tab.ts +++ b/src/components/tabs/tab.ts @@ -1,15 +1,17 @@ -import { ChangeDetectorRef, Component, ComponentResolver, ElementRef, EventEmitter, forwardRef, Input, Inject, NgZone, Optional, Output, Renderer, ViewChild, ViewEncapsulation, ViewContainerRef } from '@angular/core'; +import { ChangeDetectorRef, Component, ComponentFactoryResolver, ComponentRef, ElementRef, EventEmitter, Input, NgZone, Optional, Output, Renderer, ViewChild, ViewEncapsulation, ViewContainerRef } from '@angular/core'; import { App } from '../app/app'; import { Config } from '../../config/config'; +import { DeepLinker } from '../../navigation/deep-linker'; import { GestureController } from '../../gestures/gesture-controller'; import { isTrueProperty } from '../../util/util'; import { Keyboard } from '../../util/keyboard'; -import { NavControllerBase } from '../nav/nav-controller-base'; -import { NavOptions } from '../nav/nav-interfaces'; +import { NavControllerBase } from '../../navigation/nav-controller-base'; +import { NavOptions } from '../../navigation/nav-util'; import { TabButton } from './tab-button'; import { Tabs } from './tabs'; -import { ViewController } from '../nav/view-controller'; +import { TransitionController } from '../../transitions/transition-controller'; +import { ViewController } from '../../navigation/view-controller'; /** @@ -93,14 +95,14 @@ import { ViewController } from '../nav/view-controller'; * the tabs. * * ```html - * + * * * * ``` * * ```ts * export class Tabs { - * constructor(private modalCtrl: ModalController) { + * constructor(public modalCtrl: ModalController) { * * } * @@ -120,23 +122,40 @@ import { ViewController } from '../nav/view-controller'; */ @Component({ selector: 'ion-tab', + template: + '
', host: { - '[class.show-tab]': 'isSelected', '[attr.id]': '_tabId', '[attr.aria-labelledby]': '_btnId', 'role': 'tabpanel' }, - template: '
', encapsulation: ViewEncapsulation.None, }) export class Tab extends NavControllerBase { - private _isInitial: boolean; - private _isEnabled: boolean = true; - private _isShown: boolean = true; - private _tabId: string; - private _btnId: string; - private _loaded: boolean; - private _loadTmr: any; + /** + * @private + */ + _isInitial: boolean; + /** + * @private + */ + _isEnabled: boolean = true; + /** + * @private + */ + _isShown: boolean = true; + /** + * @private + */ + _tabId: string; + /** + * @private + */ + _btnId: string; + /** + * @private + */ + _loaded: boolean; /** * @private @@ -158,6 +177,11 @@ export class Tab extends NavControllerBase { */ @Input() rootParams: any; + /** + * @input {string} The URL path name to represent this tab within the URL. + */ + @Input() tabUrlPath: string; + /** * @input {string} The title of the tab button. */ @@ -221,21 +245,23 @@ export class Tab extends NavControllerBase { @Output() ionSelect: EventEmitter = new EventEmitter(); constructor( - @Inject(forwardRef(() => Tabs)) public parent: Tabs, + parent: Tabs, app: App, config: Config, keyboard: Keyboard, elementRef: ElementRef, zone: NgZone, renderer: Renderer, - compiler: ComponentResolver, + cfr: ComponentFactoryResolver, private _cd: ChangeDetectorRef, - gestureCtrl: GestureController + gestureCtrl: GestureController, + transCtrl: TransitionController, + @Optional() private linker: DeepLinker ) { // A Tab is a NavController for its child pages - super(parent, app, config, keyboard, elementRef, zone, renderer, compiler, gestureCtrl); + super(parent, app, config, keyboard, elementRef, zone, renderer, cfr, gestureCtrl, transCtrl, linker); - parent.add(this); + this.id = parent.add(this); this._tabId = 'tabpanel-' + this.id; this._btnId = 'tab-' + this.id; @@ -261,52 +287,33 @@ export class Tab extends NavControllerBase { */ load(opts: NavOptions, done?: Function) { if (!this._loaded && this.root) { - this.push(this.root, this.rootParams, opts, () => { - done(true); - }); + this.push(this.root, this.rootParams, opts, done); this._loaded = true; } else { - done(false); + done(true); } } - /** * @private */ - preload(wait: number) { - this._loadTmr = setTimeout(() => { - if (!this._loaded) { - console.debug('Tabs, preload', this.id); - this.load({ - animate: false, - preload: true - }, function(){}); - } - }, wait); - } - - /** - * @private - */ - loadPage(viewCtrl: ViewController, viewport: ViewContainerRef, opts: NavOptions, done: Function) { - let isTabSubPage = (this.parent.subPages && viewCtrl.index > 0); + _viewInsert(viewCtrl: ViewController, componentRef: ComponentRef, viewport: ViewContainerRef) { + const isTabSubPage = (this.parent._subPages && viewCtrl.index > 0); if (isTabSubPage) { viewport = this.parent.portal; } - super.loadPage(viewCtrl, viewport, opts, () => { - if (isTabSubPage) { - // add the .tab-subpage css class to tabs pages that should act like subpages - let pageEleRef = viewCtrl.pageRef(); - if (pageEleRef) { - this._renderer.setElementClass(pageEleRef.nativeElement, 'tab-subpage', true); - } + super._viewInsert(viewCtrl, componentRef, viewport); + + if (isTabSubPage) { + // add the .tab-subpage css class to tabs pages that should act like subpages + const pageEleRef = viewCtrl.pageRef(); + if (pageEleRef) { + this._renderer.setElementClass(pageEleRef.nativeElement, 'tab-subpage', true); } - done(); - }); + } } /** @@ -315,6 +322,9 @@ export class Tab extends NavControllerBase { setSelected(isSelected: boolean) { this.isSelected = isSelected; + this.setElementClass('show-tab', isSelected); + this.setElementAttribute('aria-hidden', (!isSelected).toString()); + if (isSelected) { // this is the selected tab, detect changes this._cd.reattach(); @@ -335,9 +345,18 @@ export class Tab extends NavControllerBase { /** * @private */ - ngOnDestroy() { - clearTimeout(this._loadTmr); - super.ngOnDestroy(); + updateHref(component: any, data: any) { + if (this.btn && this.linker) { + let href = this.linker.createUrl(this, component, data) || '#'; + this.btn.updateHref(href); + } + } + + /** + * @private + */ + destroy() { + this.destroy(); } } diff --git a/src/components/tabs/tabs.ts b/src/components/tabs/tabs.ts index 58dfc8b752..42e823d046 100644 --- a/src/components/tabs/tabs.ts +++ b/src/components/tabs/tabs.ts @@ -1,22 +1,19 @@ -import { Component, ElementRef, EventEmitter, Input, Output, Optional, Renderer, ViewChild, ViewContainerRef, ViewEncapsulation } from '@angular/core'; -import { NgClass, NgFor, NgIf } from '@angular/common'; +import { AfterViewInit, Component, ElementRef, EventEmitter, Input, Output, Optional, Renderer, ViewChild, ViewContainerRef, ViewEncapsulation } from '@angular/core'; import { App } from '../app/app'; -import { Badge } from '../badge/badge'; import { Config } from '../../config/config'; import { Content } from '../content/content'; -import { Icon } from '../icon/icon'; +import { DeepLinker } from '../../navigation/deep-linker'; import { Ion } from '../ion'; -import { isBlank, isPresent, isTrueProperty } from '../../util/util'; +import { isBlank } from '../../util/util'; import { nativeRaf } from '../../util/dom'; -import { NavController } from '../nav/nav-controller'; -import { NavControllerBase } from '../nav/nav-controller-base'; -import { NavOptions, DIRECTION_FORWARD } from '../nav/nav-interfaces'; +import { NavController } from '../../navigation/nav-controller'; +import { NavControllerBase } from '../../navigation/nav-controller-base'; +import { NavOptions, DIRECTION_SWITCH } from '../../navigation/nav-util'; import { Platform } from '../../platform/platform'; import { Tab } from './tab'; -import { TabButton } from './tab-button'; import { TabHighlight } from './tab-highlight'; -import { ViewController } from '../nav/view-controller'; +import { ViewController } from '../../navigation/view-controller'; /** @@ -143,72 +140,61 @@ import { ViewController } from '../nav/view-controller'; */ @Component({ selector: 'ion-tabs', - template: ` - - - - {{t.tabTitle}} - {{t.tabBadge}} - - - - - -
- `, - directives: [Badge, Icon, NgClass, NgFor, NgIf, TabButton, TabHighlight], + template: + '' + + '' + + '
', encapsulation: ViewEncapsulation.None, }) -export class Tabs extends Ion { - private _ids: number = -1; - private _tabs: Tab[] = []; - private _onReady: any = null; - private _sbPadding: boolean; - private _top: number; - private _bottom: number; - - /** - * @private - */ - id: string; - - /** - * @private - */ - selectHistory: string[] = []; - - /** - * @private - */ - subPages: boolean; - +export class Tabs extends Ion implements AfterViewInit { /** @internal */ - _color: string; + _ids: number = -1; + /** @internal */ + _tabs: Tab[] = []; + /** @internal */ + _sbPadding: boolean; + /** @internal */ + _top: number; + /** @internal */ + _bottom: number; + /** @internal */ + id: string; + /** @internal */ + _selectHistory: string[] = []; + /** @internal */ + _subPages: boolean; /** * @input {string} The predefined color to use. For example: `"primary"`, `"secondary"`, `"danger"`. */ @Input() - get color(): string { - return this._color; + set color(value: string) { + this._setColor('tabs', value); } - set color(value: string) { - this._updateColor(value); + /** + * @input {string} The mode to apply to this component. + */ + @Input() + set mode(val: string) { + this._setMode('tabs', val); } /** * @input {number} The default selected tab index when first loaded. If a selected index isn't provided then it will use `0`, the first tab. */ - @Input() selectedIndex: any; + @Input() selectedIndex: number; /** - * @input {boolean} Set whether to preload all the tabs: `true`, `false`. - */ - @Input() preloadTabs: any; - - /** - * @private DEPRECATED. Please use `tabsLayout` instead. + * @internal DEPRECATED. Please use `tabsLayout` instead. */ @Input() private tabbarLayout: string; @@ -218,7 +204,7 @@ export class Tabs extends Ion { @Input() tabsLayout: string; /** - * @private DEPRECATED. Please use `tabsPlacement` instead. + * @internal DEPRECATED. Please use `tabsPlacement` instead. */ @Input() private tabbarPlacement: string; @@ -238,17 +224,17 @@ export class Tabs extends Ion { @Output() ionChange: EventEmitter = new EventEmitter(); /** - * @private + * @internal */ - @ViewChild(TabHighlight) private _highlight: TabHighlight; + @ViewChild(TabHighlight) _highlight: TabHighlight; /** - * @private + * @internal */ - @ViewChild('tabbar') private _tabbar: ElementRef; + @ViewChild('tabbar') _tabbar: ElementRef; /** - * @private + * @internal */ @ViewChild('portal', {read: ViewContainerRef}) portal: ViewContainerRef; @@ -261,29 +247,31 @@ export class Tabs extends Ion { @Optional() parent: NavController, @Optional() public viewCtrl: ViewController, private _app: App, - private _config: Config, - private _elementRef: ElementRef, + config: Config, + elementRef: ElementRef, private _platform: Platform, - private _renderer: Renderer + renderer: Renderer, + private _linker: DeepLinker ) { - super(_elementRef); + super(config, elementRef, renderer); + this.mode = config.get('mode'); this.parent = parent; this.id = 't' + (++tabIds); - this._sbPadding = _config.getBoolean('statusbarPadding'); - this.subPages = _config.getBoolean('tabsHideOnSubPages'); - this.tabsHighlight = _config.getBoolean('tabsHighlight'); + this._sbPadding = config.getBoolean('statusbarPadding'); + this._subPages = config.getBoolean('tabsHideOnSubPages'); + this.tabsHighlight = config.getBoolean('tabsHighlight'); // TODO deprecated 07-07-2016 beta.11 - if (_config.get('tabSubPages') !== null) { + if (config.get('tabSubPages') !== null) { console.warn('Config option "tabSubPages" has been deprecated. Please use "tabsHideOnSubPages" instead.'); - this.subPages = _config.getBoolean('tabSubPages'); + this._subPages = config.getBoolean('tabSubPages'); } // TODO deprecated 07-07-2016 beta.11 - if (_config.get('tabbarHighlight') !== null) { + if (config.get('tabbarHighlight') !== null) { console.warn('Config option "tabbarHighlight" has been deprecated. Please use "tabsHighlight" instead.'); - this.tabsHighlight = _config.getBoolean('tabbarHighlight'); + this.tabsHighlight = config.getBoolean('tabbarHighlight'); } if (this.parent) { @@ -297,24 +285,20 @@ export class Tabs extends Ion { } else if (this._app) { // this is the root navcontroller for the entire app - this._app.setRootNav(this); + this._app._setRootNav(this); } // Tabs may also be an actual ViewController which was navigated to // if Tabs is static and not navigated to within a NavController // then skip this and don't treat it as it's own ViewController if (viewCtrl) { - viewCtrl.setContent(this); - viewCtrl.setContentRef(_elementRef); - - viewCtrl.loaded = (done) => { - this._onReady = done; - }; + viewCtrl._setContent(this); + viewCtrl._setContentRef(elementRef); } } /** - * @private + * @internal */ ngAfterViewInit() { this._setConfig('tabsPlacement', 'bottom'); @@ -328,27 +312,27 @@ export class Tabs extends Ion { // TODO deprecated 07-07-2016 beta.11 if (this.tabbarPlacement !== undefined) { console.warn('Input "tabbarPlacement" has been deprecated. Please use "tabsPlacement" instead.'); - this._renderer.setElementAttribute(this._elementRef.nativeElement, 'tabsPlacement', this.tabbarPlacement); + this.setElementAttribute('tabsPlacement', this.tabbarPlacement); this.tabsPlacement = this.tabbarPlacement; } // TODO deprecated 07-07-2016 beta.11 if (this._config.get('tabbarPlacement') !== null) { console.warn('Config option "tabbarPlacement" has been deprecated. Please use "tabsPlacement" instead.'); - this._renderer.setElementAttribute(this._elementRef.nativeElement, 'tabsPlacement', this._config.get('tabbarPlacement')); + this.setElementAttribute('tabsPlacement', this._config.get('tabbarPlacement')); } // TODO deprecated 07-07-2016 beta.11 if (this.tabbarLayout !== undefined) { console.warn('Input "tabbarLayout" has been deprecated. Please use "tabsLayout" instead.'); - this._renderer.setElementAttribute(this._elementRef.nativeElement, 'tabsLayout', this.tabbarLayout); + this.setElementAttribute('tabsLayout', this.tabbarLayout); this.tabsLayout = this.tabbarLayout; } // TODO deprecated 07-07-2016 beta.11 if (this._config.get('tabbarLayout') !== null) { console.warn('Config option "tabbarLayout" has been deprecated. Please use "tabsLayout" instead.'); - this._renderer.setElementAttribute(this._elementRef.nativeElement, 'tabsLayout', this._config.get('tabsLayout')); + this.setElementAttribute('tabsLayout', this._config.get('tabsLayout')); } if (this.tabsHighlight) { @@ -361,12 +345,19 @@ export class Tabs extends Ion { } /** - * @private + * @internal */ initTabs() { // get the selected index from the input // otherwise default it to use the first index - let selectedIndex = (isBlank(this.selectedIndex) ? 0 : parseInt(this.selectedIndex, 10)); + let selectedIndex = (isBlank(this.selectedIndex) ? 0 : parseInt(this.selectedIndex, 10)); + + // now see if the deep linker can find a tab index + const tabsSegment = this._linker.initNav(this); + if (tabsSegment && isBlank(tabsSegment.component)) { + // we found a segment which probably represents which tab to select + selectedIndex = this._linker.getSelectedTabIndex(this, tabsSegment.name, selectedIndex); + } // get the selectedIndex and ensure it isn't hidden or disabled let selectedTab = this._tabs.find((t, i) => i === selectedIndex && t.enabled && t.show); @@ -378,91 +369,73 @@ export class Tabs extends Ion { if (selectedTab) { // we found a tab to select - this.select(selectedTab); - } - - // check if preloadTab is set as an input @Input - // otherwise check the preloadTabs config - let shouldPreloadTabs = (isBlank(this.preloadTabs) ? this._config.getBoolean('preloadTabs') : isTrueProperty(this.preloadTabs)); - if (shouldPreloadTabs) { - // preload all the tabs which isn't the selected tab - this._tabs.filter((t) => t !== selectedTab).forEach((tab, index) => { - tab.preload(this._config.getNumber('tabsPreloadDelay', 1000) * index); + // get the segment the deep linker says this tab should load with + let pageId: string = null; + if (tabsSegment) { + let selectedTabSegment = this._linker.initNav(selectedTab); + if (selectedTabSegment && selectedTabSegment.component) { + selectedTab.root = selectedTabSegment.component; + selectedTab.rootParams = selectedTabSegment.data; + pageId = selectedTabSegment.id; + } + } + this.select(selectedTab, { + id: pageId }); } + + // set the initial href attribute values for each tab + this._tabs.forEach(t => { + t.updateHref(t.root, t.rootParams); + }); } /** - * @private + * @internal */ - private _setConfig(attrKey: string, fallback: any) { - var val = (this)[attrKey]; + _setConfig(attrKey: string, fallback: any) { + let val = (this)[attrKey]; if (isBlank(val)) { val = this._config.get(attrKey, fallback); } - this._renderer.setElementAttribute(this._elementRef.nativeElement, attrKey, val); - } - - /** - * @internal - */ - _updateColor(newColor: string) { - this._setElementColor(this._color, false); - this._setElementColor(newColor, true); - this._color = newColor; - } - - /** - * @internal - */ - _setElementColor(color: string, isAdd: boolean) { - if (color !== null && color !== '') { - this._renderer.setElementClass(this._elementRef.nativeElement, `tabs-${color}`, isAdd); - } + this.setElementAttribute(attrKey, val); } /** * @private */ add(tab: Tab) { - tab.id = this.id + '-' + (++this._ids); this._tabs.push(tab); + return this.id + '-' + (++this._ids); } /** * @param {number|Tab} tabOrIndex Index, or the Tab instance, of the tab to select. */ - select(tabOrIndex: number | Tab, opts: NavOptions = {}, done?: Function): Promise { - let promise: Promise; - if (!done) { - promise = new Promise(res => { done = res; }); - } - - let selectedTab: Tab = (typeof tabOrIndex === 'number' ? this.getByIndex(tabOrIndex) : tabOrIndex); + select(tabOrIndex: number | Tab, opts: NavOptions = {}) { + const selectedTab: Tab = (typeof tabOrIndex === 'number' ? this.getByIndex(tabOrIndex) : tabOrIndex); if (isBlank(selectedTab)) { - return Promise.resolve(); + return; } - let deselectedTab = this.getSelected(); + const deselectedTab = this.getSelected(); if (selectedTab === deselectedTab) { // no change - this._touchActive(selectedTab); - return Promise.resolve(); + return this._touchActive(selectedTab); } - console.debug(`Tabs, select: ${selectedTab.id}`); let deselectedPage: ViewController; if (deselectedTab) { deselectedPage = deselectedTab.getActive(); - deselectedPage && deselectedPage.fireWillLeave(); + deselectedPage && deselectedPage._willLeave(); } opts.animate = false; - let selectedPage = selectedTab.getActive(); - selectedPage && selectedPage.fireWillEnter(); + const selectedPage = selectedTab.getActive(); + selectedPage && selectedPage._willEnter(); - selectedTab.load(opts, (initialLoad: boolean) => { + selectedTab.load(opts, (alreadyLoaded: boolean) => { selectedTab.ionSelect.emit(selectedTab); this.ionChange.emit(selectedTab); @@ -478,27 +451,26 @@ export class Tabs extends Ion { if (this.tabsHighlight) { this._highlight.select(selectedTab); } + + if (opts.updateUrl !== false) { + this._linker.navChange(DIRECTION_SWITCH); + } } - selectedPage && selectedPage.fireDidEnter(); - deselectedPage && deselectedPage.fireDidLeave(); - - if (this._onReady) { - this._onReady(); - this._onReady = null; - } + selectedPage && selectedPage._didEnter(); + deselectedPage && deselectedPage._didLeave(); // track the order of which tabs have been selected, by their index // do not track if the tab index is the same as the previous - if (this.selectHistory[this.selectHistory.length - 1] !== selectedTab.id) { - this.selectHistory.push(selectedTab.id); + if (this._selectHistory[this._selectHistory.length - 1] !== selectedTab.id) { + this._selectHistory.push(selectedTab.id); } // if this is not the Tab's initial load then we need // to refresh the tabbar and content dimensions to be sure // they're lined up correctly - if (!initialLoad && selectedPage) { - var content = selectedPage.getContent(); + if (alreadyLoaded && selectedPage) { + let content = selectedPage.getContent(); if (content && content instanceof Content) { nativeRaf(() => { content.readDimensions(); @@ -506,11 +478,7 @@ export class Tabs extends Ion { }); } } - - done(); }); - - return promise; } /** @@ -521,12 +489,12 @@ export class Tabs extends Ion { previousTab(trimHistory: boolean = true): Tab { // walk backwards through the tab selection history // and find the first previous tab that is enabled and shown - console.debug('run previousTab', this.selectHistory); - for (var i = this.selectHistory.length - 2; i >= 0; i--) { - var tab = this._tabs.find(t => t.id === this.selectHistory[i]); + console.debug('run previousTab', this._selectHistory); + for (var i = this._selectHistory.length - 2; i >= 0; i--) { + var tab = this._tabs.find(t => t.id === this._selectHistory[i]); if (tab && tab.enabled && tab.show) { if (trimHistory) { - this.selectHistory.splice(i + 1); + this._selectHistory.splice(i + 1); } return tab; } @@ -540,17 +508,14 @@ export class Tabs extends Ion { * @returns {Tab} Returns the tab who's index matches the one passed */ getByIndex(index: number): Tab { - if (index < this._tabs.length && index > -1) { - return this._tabs[index]; - } - return null; + return this._tabs[index]; } /** * @return {Tab} Returns the currently selected tab */ getSelected(): Tab { - for (let i = 0; i < this._tabs.length; i++) { + for (var i = 0; i < this._tabs.length; i++) { if (this._tabs[i].isSelected) { return this._tabs[i]; } @@ -559,68 +524,57 @@ export class Tabs extends Ion { } /** - * @private + * @internal */ getActiveChildNav() { return this.getSelected(); } /** - * @private + * @internal */ getIndex(tab: Tab): number { return this._tabs.indexOf(tab); } /** - * @private + * @internal */ length(): number { return this._tabs.length; } /** - * @private * "Touch" the active tab, going back to the root view of the tab * or optionally letting the tab handle the event */ private _touchActive(tab: Tab) { - let active = tab.getActive(); + const active = tab.getActive(); - if (!active) { - return Promise.resolve(); + if (active) { + if (active._cmp && active._cmp.instance.ionSelected) { + // if they have a custom tab selected handler, call it + active._cmp.instance.ionSelected(); + + } else if (tab.length() > 1) { + // if we're a few pages deep, pop to root + tab.popToRoot(null, null); + + } else if (tab.root !== active.component) { + // Otherwise, if the page we're on is not our real root, reset it to our + // default root type + tab.setRoot(tab.root); + } } - - let instance = active.instance; - - // If they have a custom tab selected handler, call it - if (instance.ionSelected) { - return instance.ionSelected(); - } - - // If we're a few pages deep, pop to root - if (tab.length() > 1) { - // Pop to the root view - return tab.popToRoot(); - } - - // Otherwise, if the page we're on is not our real root, reset it to our - // default root type - if (tab.root !== active.componentType) { - return tab.setRoot(tab.root); - } - - // And failing all of that, we do something safe and secure - return Promise.resolve(); } /** - * @private + * @internal * DOM WRITE */ setTabbarPosition(top: number, bottom: number) { if (this._top !== top || this._bottom !== bottom) { - let tabbarEle = this._tabbar.nativeElement; + const tabbarEle = this._tabbar.nativeElement; tabbarEle.style.top = (top > -1 ? top + 'px' : ''); tabbarEle.style.bottom = (bottom > -1 ? bottom + 'px' : ''); tabbarEle.classList.add('show-tabbar'); diff --git a/src/navigation/nav-controller-base.ts b/src/navigation/nav-controller-base.ts new file mode 100644 index 0000000000..90c542d09b --- /dev/null +++ b/src/navigation/nav-controller-base.ts @@ -0,0 +1,959 @@ +import { ComponentRef, ComponentFactoryResolver, ElementRef, EventEmitter, NgZone, ReflectiveInjector, Renderer, ViewContainerRef } from '@angular/core'; + +import { AnimationOptions } from '../animations/animation'; +import { App } from '../components/app/app'; +import { Config } from '../config/config'; +import { convertToView, convertToViews, NavOptions, DIRECTION_BACK, DIRECTION_FORWARD, INIT_ZINDEX, + TransitionResolveFn, TransitionRejectFn, TransitionInstruction, ViewState } from './nav-util'; +import { setZIndex } from './nav-util'; +import { DeepLinker } from './deep-linker'; +import { GestureController } from '../gestures/gesture-controller'; +import { isBlank, isNumber, isPresent, pascalCaseToDashCase } from '../util/util'; +import { isViewController, ViewController } from './view-controller'; +import { Ion } from '../components/ion'; +import { Keyboard } from '../util/keyboard'; +import { NavController } from './nav-controller'; +import { NavParams } from './nav-params'; +import { SwipeBackGesture } from './swipe-back'; +import { Transition } from '../transitions/transition'; +import { TransitionController } from '../transitions/transition-controller'; + + +/** + * @private + * This class is for internal use only. It is not exported publicly. + */ +export class NavControllerBase extends Ion implements NavController { + _children: any[] = []; + _ids: number = -1; + _init = false; + _isPortal: boolean; + _queue: TransitionInstruction[] = []; + _sbEnabled: boolean; + _sbGesture: SwipeBackGesture; + _sbThreshold: number; + _sbTrns: Transition; + _trnsId: number = null; + _trnsTm: number = 0; + _viewport: ViewContainerRef; + _views: ViewController[] = []; + + viewDidLoad: EventEmitter; + viewWillEnter: EventEmitter; + viewDidEnter: EventEmitter; + viewWillLeave: EventEmitter; + viewDidLeave: EventEmitter; + viewWillUnload: EventEmitter; + + id: string; + parent: any; + config: Config; + + constructor( + parent: any, + public _app: App, + config: Config, + public _keyboard: Keyboard, + elementRef: ElementRef, + public _zone: NgZone, + renderer: Renderer, + public _cfr: ComponentFactoryResolver, + public _gestureCtrl: GestureController, + public _trnsCtrl: TransitionController, + public _linker: DeepLinker + ) { + super(config, elementRef, renderer); + + this.parent = parent; + this.config = config; + + this._sbEnabled = config.getBoolean('swipeBackEnabled'); + this._sbThreshold = config.getNumber('swipeBackThreshold', 40); + + this.id = 'n' + (++ctrlIds); + + this.viewDidLoad = new EventEmitter(); + this.viewWillEnter = new EventEmitter(); + this.viewDidEnter = new EventEmitter(); + this.viewWillLeave = new EventEmitter(); + this.viewDidLeave = new EventEmitter(); + this.viewWillUnload = new EventEmitter(); + } + + push(page: any, params?: any, opts?: NavOptions, done?: Function): Promise { + return this._queueTrns({ + insertStart: -1, + insertViews: [convertToView(this._linker, page, params)], + opts: opts, + }, done); + } + + insert(insertIndex: number, page: any, params?: any, opts?: NavOptions, done?: Function): Promise { + return this._queueTrns({ + insertStart: insertIndex, + insertViews: [convertToView(this._linker, page, params)], + opts: opts, + }, done); + } + + insertPages(insertIndex: number, insertPages: any[], opts?: NavOptions, done?: Function): Promise { + return this._queueTrns({ + insertStart: insertIndex, + insertViews: convertToViews(this._linker, insertPages), + opts: opts, + }, done); + } + + pop(opts?: NavOptions, done?: Function): Promise { + return this._queueTrns({ + removeStart: -1, + removeCount: 1, + opts: opts, + }, done); + } + + popTo(indexOrViewCtrl: any, opts?: NavOptions, done?: Function): Promise { + const startIndex = isViewController(indexOrViewCtrl) ? this.indexOf(indexOrViewCtrl) : isNumber(indexOrViewCtrl) ? indexOrViewCtrl : -1; + return this._queueTrns({ + removeStart: startIndex + 1, + removeCount: -1, + opts: opts, + }, done); + } + + popToRoot(opts?: NavOptions, done?: Function): Promise { + return this._queueTrns({ + removeStart: 1, + removeCount: -1, + opts: opts, + }, done); + } + + popAll() { + let promises: any[] = []; + for (var i = this._views.length - 1; i >= 0; i--) { + promises.push(this.pop(null)); + } + return Promise.all(promises); + } + + remove(startIndex: number, removeCount: number = 1, opts?: NavOptions, done?: Function): Promise { + return this._queueTrns({ + removeStart: startIndex, + removeCount: removeCount, + opts: opts, + }, done); + } + + setRoot(pageOrViewCtrl: any, params?: any, opts?: NavOptions, done?: Function): Promise { + return this._queueTrns({ + insertStart: 0, + insertViews: [convertToView(this._linker, pageOrViewCtrl, params)], + removeStart: 0, + removeCount: -1, + opts: opts + }, done); + } + + setPages(pages: any[], opts?: NavOptions, done?: Function): Promise { + return this._queueTrns({ + insertStart: 0, + insertViews: convertToViews(this._linker, pages), + removeStart: 0, + removeCount: -1, + opts: opts + }, done); + } + + _queueTrns(ti: TransitionInstruction, done: Function): Promise { + let promise: Promise; + let resolve: Function = done; + let reject: Function = done; + + if (done === undefined) { + // only create a promise if a done callback wasn't provided + // done can be a null, which avoids any functions + promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + } + + ti.resolve = (hasCompleted: boolean, isAsync: boolean, enteringName: string, leavingName: string, direction: string) => { + // transition has successfully resolved + this._trnsId = null; + resolve && resolve(hasCompleted, isAsync, enteringName, leavingName, direction); + this._sbCheck(); + + // let's see if there's another to kick off + this.setTransitioning(false); + this._nextTrns(); + }; + + ti.reject = (rejectReason: any, trns: Transition) => { + // rut row raggy, something rejected this transition + this._trnsId = null; + this._queue.length = 0; + + while (trns) { + if (trns.enteringView && (trns.enteringView._state !== ViewState.LOADED)) { + // destroy the entering views and all of their hopes and dreams + trns.enteringView._destroy(this._renderer); + } + if (!trns.parent) break; + } + + if (trns) { + this._trnsCtrl.destroy(trns.trnsId); + } + + this._sbCheck(); + + reject && reject(false, false, rejectReason); + + this.setTransitioning(false); + this._nextTrns(); + }; + + if (ti.insertViews) { + // ensure we've got good views to insert + ti.insertViews = ti.insertViews.filter(v => v !== null); + if (!ti.insertViews.length) { + ti.reject('invalid views to insert'); + return; + } + + } else if (isPresent(ti.removeStart) && !this._views.length && !this._isPortal) { + ti.reject('no views in the stack to be removed'); + return; + } + + this._queue.push(ti); + + // if there isn't a transitoin already happening + // then this will kick off this transition + this._nextTrns(); + + // promise is undefined if a done callbacks was provided + return promise; + } + + _nextTrns(): boolean { + // this is the framework's bread 'n butta function + // only one transition is allowed at any given time + if (this.isTransitioning()) { + return false; + } + + // there is no transition happening right now + // get the next instruction + const ti = this._queue.shift(); + if (!ti) { + this.setTransitioning(false); + return false; + } + + this.setTransitioning(true, ACTIVE_TRANSITION_MAX_TIME); + const viewsLength = this._views.length; + const activeView = this.getActive(); + + let enteringView: ViewController; + let leavingView: ViewController = activeView; + const destroyQueue: ViewController[] = []; + + const opts = ti.opts || {}; + const resolve = ti.resolve; + const reject = ti.reject; + let insertViews = ti.insertViews; + ti.resolve = ti.reject = ti.opts = ti.insertViews = null; + + let enteringRequiresTransition = false; + let leavingRequiresTransition = false; + + if (isPresent(ti.removeStart)) { + if (ti.removeStart < 0) { + ti.removeStart = (viewsLength - 1); + } + if (ti.removeCount < 0) { + ti.removeCount = (viewsLength - ti.removeStart); + } + + leavingRequiresTransition = (ti.removeStart + ti.removeCount === viewsLength); + + for (var i = ti.removeStart; i <= ti.removeCount; i++) { + destroyQueue.push(this._views[i]); + } + + for (var i = viewsLength - 1; i >= 0; i--) { + var view = this._views[i]; + if (destroyQueue.indexOf(view) < 0 && view !== leavingView) { + enteringView = view; + break; + } + } + + // default the direction to "back" + opts.direction = opts.direction || DIRECTION_BACK; + } + + if (insertViews) { + // 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 (ti.insertStart < 0 || ti.insertStart > viewsLength) { + ti.insertStart = viewsLength; + } + + // only requires a transition if it's going at the end + enteringRequiresTransition = (ti.insertStart === viewsLength); + + // grab the very last view of the views to be inserted + // and initialize it as the new entering view + enteringView = insertViews[insertViews.length - 1]; + + // manually set the new view's id if an id was passed in the options + if (isPresent(opts.id)) { + enteringView.id = opts.id; + } + + // add the views to the + for (var i = 0; i < insertViews.length; i++) { + var view = insertViews[i]; + + var existingIndex = this._views.indexOf(view); + if (existingIndex > -1) { + // this view is already in the stack!! + // move it to its new location + this._views.splice(ti.insertStart + i, 0, this._views.splice(existingIndex, 1)[0]); + + } else { + // this is a new view to add to the stack + // create the new entering view + view._setNav(this); + + // 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(ti.insertStart + i, 0, view); + } + } + + if (enteringRequiresTransition) { + // default to forward if not already set + opts.direction = opts.direction || DIRECTION_FORWARD; + } + } + + // if the views to be removed are in the beginning or middle + // and there is not a view that needs to visually transition out + // then just destroy them and don't transition anything + for (var i = 0; i < destroyQueue.length; i++) { + // batch all of lifecycles together + var view = destroyQueue[i]; + if (view && view !== enteringView && view !== leavingView) { + view._willLeave(); + this.viewWillLeave.emit(view); + this._app.viewWillLeave.emit(view); + + view._didLeave(); + this.viewDidLeave.emit(view); + this._app.viewDidLeave.emit(view); + + view._willUnload(); + this.viewWillUnload.emit(view); + this._app.viewWillUnload.emit(view); + } + } + for (var i = 0; i < destroyQueue.length; i++) { + // batch all of the destroys together + var view = destroyQueue[i]; + if (view && view !== enteringView && view !== leavingView) { + view._destroy(this._renderer); + } + } + destroyQueue.length = 0; + + if (enteringRequiresTransition || leavingRequiresTransition && enteringView !== leavingView) { + // set which animation it should use if it wasn't set yet + if (!opts.animation) { + if (isPresent(ti.removeStart)) { + opts.animation = (leavingView || enteringView).getTransitionName(opts.direction); + } else { + opts.animation = (enteringView || leavingView).getTransitionName(opts.direction); + } + } + + // huzzah! let us transition these views + this._transition(enteringView, leavingView, opts, resolve, reject); + + } else { + // they're inserting/removing the views somewhere in the middle or + // beginning, so visually nothing needs to animate/transition + // resolve immediately because there's no animation that's happening + resolve(true, false); + } + + return true; + } + + _transition(enteringView: ViewController, leavingView: ViewController, opts: NavOptions, resolve: TransitionResolveFn, reject: TransitionRejectFn): void { + // figure out if this transition is the root one or a + // child of a parent nav that has the root transition + this._trnsId = this._trnsCtrl.getRootTrnsId(this); + if (this._trnsId === null) { + // this is the root transition, meaning all child navs and their views + // should be added as a child transition to this one + this._trnsId = this._trnsCtrl.nextId(); + } + + // create the transition options + const animationOpts: AnimationOptions = { + animation: opts.animation, + direction: opts.direction, + duration: (opts.animate === false ? 0 : opts.duration), + easing: opts.easing, + isRTL: this.config.platform.isRTL(), + ev: opts.ev, + }; + + // create the transition animation from the TransitionController + // this will either create the root transition, or add it as a child transition + const trns = this._trnsCtrl.get(this._trnsId, enteringView, leavingView, animationOpts); + + // ensure any swipeback transitions are cleared out + this._sbTrns && this._sbTrns.destroy(); + + if (trns.parent) { + // this is important for later to know if there + // are any more child tests to check for + trns.parent.hasChildTrns = true; + + } else { + // this is the root transition + if (opts.progressAnimation) { + this._sbTrns = trns; + } + } + + trns.registerStart(() => { + this._trnsStart(trns, enteringView, leavingView, opts, resolve); + if (trns.parent) { + trns.parent.start(); + } + }); + + if (enteringView && isBlank(enteringView._state)) { + // render the entering view, and all child navs and views + // ******** DOM WRITE **************** + this._viewInit(trns, enteringView, opts); + } + + // views have been initialized, now let's test + // to see if the transition is even allowed or not + const shouldContinue = this._viewTest(trns, enteringView, leavingView, opts, resolve, reject); + if (shouldContinue) { + // synchronous and all tests passed! let's continue + this._postViewInit(trns, enteringView, leavingView, opts, resolve); + } + } + + /** + * DOM WRITE + */ + _viewInit(trns: Transition, enteringView: ViewController, opts: NavOptions) { + // entering view has not been initialized yet + const componentProviders = ReflectiveInjector.resolve([ + { provide: NavController, useValue: this }, + { provide: ViewController, useValue: enteringView }, + { provide: NavParams, useValue: enteringView.getNavParams() } + ]); + const componentFactory = this._cfr.resolveComponentFactory(enteringView.component); + const childInjector = ReflectiveInjector.fromResolvedProviders(componentProviders, this._viewport.parentInjector); + + // create ComponentRef and set it to the entering view + enteringView.init(componentFactory.create(childInjector, [])); + enteringView._state = ViewState.INITIALIZED; + } + + _viewTest(trns: Transition, enteringView: ViewController, leavingView: ViewController, opts: NavOptions, resolve: TransitionResolveFn, reject: TransitionRejectFn): boolean { + const promises: Promise[] = []; + + if (leavingView) { + const leavingTestResult = leavingView._lifecycleTest('Leave'); + + if (isPresent(leavingTestResult) && leavingTestResult !== true) { + if (leavingTestResult instanceof Promise) { + // async promise + promises.push(leavingTestResult); + + } else { + // synchronous reject + reject((leavingTestResult !== false ? leavingTestResult : `ionViewCanLeave rejected`), trns); + return false; + } + } + } + + if (enteringView) { + const enteringTestResult = enteringView._lifecycleTest('Enter'); + + if (isPresent(enteringTestResult) && enteringTestResult !== true) { + if (enteringTestResult instanceof Promise) { + // async promise + promises.push(enteringTestResult); + + } else { + // synchronous reject + reject((enteringTestResult !== false ? enteringTestResult : `ionViewCanEnter rejected`), trns); + return false; + } + } + } + + if (promises.length) { + // darn, async promises, gotta wait for them to resolve + Promise.all(promises).then(() => { + // all promises resolved! let's continue + this._postViewInit(trns, enteringView, leavingView, opts, resolve); + + }, (rejectReason: any) => { + // darn, one of the promises was rejected!! + reject(rejectReason, trns); + + }).catch((rejectReason) => { + // idk, who knows + reject(rejectReason, trns); + }); + + return false; + } + + // synchronous and all tests passed! let's move on already + return true; + } + + _postViewInit(trns: Transition, enteringView: ViewController, leavingView: ViewController, opts: NavOptions, resolve: TransitionResolveFn): void { + // passed both the enter and leave tests + if (enteringView && enteringView._state === ViewState.INITIALIZED) { + // render the entering component in the DOM + // this would also render new child navs/views + // which may have their very own async canEnter/Leave tests + // ******** DOM WRITE **************** + this._viewInsert(enteringView, enteringView._cmp, this._viewport); + } + + if (!trns.hasChildTrns) { + // lowest level transition, so kick it off and let it bubble up to start all of them + trns.start(); + } + } + + _viewInsert(view: ViewController, componentRef: ComponentRef, viewport: ViewContainerRef) { + // successfully finished loading the entering view + // fire off the "loaded" lifecycle events + view._didLoad(); + this.viewDidLoad.emit(view); + this._app.viewDidLoad.emit(view); + + // render the component ref instance to the DOM + // ******** DOM WRITE **************** + viewport.insert(componentRef.hostView, viewport.length); + view._state = ViewState.PRE_RENDERED; + + // the ElementRef of the actual ion-page created + const pageElement = componentRef.location.nativeElement; + + if (view._cssClass) { + // ******** DOM WRITE **************** + this._renderer.setElementClass(pageElement, view._cssClass, true); + } + + // auto-add page css className created from component JS class name + // ******** DOM WRITE **************** + const cssClassName = pascalCaseToDashCase(view.component.name); + this._renderer.setElementClass(pageElement, cssClassName, true); + + componentRef.changeDetectorRef.detectChanges(); + } + + _trnsStart(trns: Transition, enteringView: ViewController, leavingView: ViewController, opts: NavOptions, resolve: TransitionResolveFn) { + this._trnsId = null; + + // set the correct zIndex for the entering and leaving views + // ******** DOM WRITE **************** + setZIndex(this, enteringView, leavingView, opts.direction, this._renderer); + + // always ensure the entering view is viewable + // ******** DOM WRITE **************** + enteringView && enteringView._domShow(true, this._renderer); + + if (leavingView) { + // always ensure the leaving view is viewable + // ******** DOM WRITE **************** + leavingView._domShow(true, this._renderer); + } + + // initialize the transition + trns.init(); + + if ((!this._init && this._views.length === 1 && !this._isPortal) || this.config.get('animate') === false) { + // the initial load shouldn't animate, unless it's a portal + opts.animate = false; + } + if (opts.animate === false) { + // if it was somehow set to not animation, then make the duration zero + trns.duration(0); + } + + // create a callback that needs to run within zone + // that will fire off the willEnter/Leave lifecycle events at the right time + trns.beforeAddRead(() => { + this._zone.run(this._viewsWillLifecycles.bind(this, enteringView, leavingView)); + }); + + // create a callback for when the animation is done + trns.onFinish(() => { + // transition animation has ended + this._zone.run(this._trnsFinish.bind(this, trns, opts, resolve)); + }); + + // get the set duration of this transition + const duration = trns.getDuration(); + + // set that this nav is actively transitioning + this.setTransitioning(true, duration); + + if (!trns.parent) { + // this is the top most, or only active transition, so disable the app + // add XXms to the duration the app is disabled when the keyboard is open + + if (duration > DISABLE_APP_MINIMUM_DURATION) { + // if this transition has a duration and this is the root transition + // then set that the app is actively disabled + this._app.setEnabled(false, duration); + } + + // 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 + trns.progressStart(); + + } else { + // only the top level transition should actually start "play" + // kick it off and let it play through + // ******** DOM WRITE **************** + trns.play(); + } + } + } + + _viewsWillLifecycles(enteringView: ViewController, leavingView: ViewController) { + // call each view's lifecycle events + if (enteringView) { + enteringView._willEnter(); + this.viewWillEnter.emit(enteringView); + this._app.viewWillEnter.emit(enteringView); + } + + if (leavingView) { + leavingView._willLeave(); + this.viewWillLeave.emit(leavingView); + this._app.viewWillLeave.emit(leavingView); + } + } + + _trnsFinish(trns: Transition, opts: NavOptions, resolve: TransitionResolveFn) { + const hasCompleted = trns.hasCompleted; + + // mainly for testing + let enteringName: string; + let leavingName: string; + + if (hasCompleted) { + // transition has completed (went from 0 to 1) + if (trns.enteringView) { + enteringName = trns.enteringView.name; + trns.enteringView._didEnter(); + this.viewDidEnter.emit(trns.enteringView); + this._app.viewDidEnter.emit(trns.enteringView); + } + + if (trns.leavingView) { + leavingName = trns.leavingView.name; + trns.leavingView._didLeave(); + this.viewDidLeave.emit(trns.leavingView); + this._app.viewDidLeave.emit(trns.leavingView); + } + + this._cleanup(trns.enteringView); + } + + if (!trns.parent) { + // this is the root transition + // it's save to destroy this transition + this._trnsCtrl.destroy(trns.trnsId); + + // it's safe to enable the app again + this._app.setEnabled(true); + + if (opts.updateUrl !== false) { + // notify deep linker of the nav change + // if a direction was provided and should update url + this._linker.navChange(opts.direction); + } + + if (opts.keyboardClose !== false && this._keyboard.isOpen()) { + // the keyboard is still open! + // no problem, let's just close for them + this._keyboard.close(); + } + } + + // congrats, we did it! + resolve(hasCompleted, true, enteringName, leavingName, opts.direction); + } + + /** + * DOM WRITE + */ + _cleanup(activeView: ViewController) { + // ok, cleanup time!! Destroy all of the views that are + // INACTIVE and come after the active view + const activeViewIndex = this.indexOf(activeView); + + let reorderZIndexes = false; + for (var i = this._views.length - 1; i >= 0; i--) { + var view = this._views[i]; + if (i > activeViewIndex) { + // this view comes after the active view + // let's unload it + view._willUnload(); + this.viewWillUnload.emit(view); + this._app.viewWillUnload.emit(view); + view._destroy(this._renderer); + + } else if (i < activeViewIndex && !this._isPortal) { + // this view comes before the active view + // and it is not a portal then ensure it is hidden + view._domShow(false, this._renderer); + } + if (view._zIndex <= 0) { + reorderZIndexes = true; + } + } + + if (!this._isPortal) { + if (reorderZIndexes) { + this._views.forEach(view => { + // ******** DOM WRITE **************** + view._setZIndex(view._zIndex + INIT_ZINDEX + 1, this._renderer); + }); + } + } + } + + getActiveChildNav(): any { + return this._children[this._children.length - 1]; + } + + registerChildNav(nav: any) { + this._children.push(nav); + } + + unregisterChildNav(nav: any) { + const index = this._children.indexOf(nav); + if (index > -1) { + this._children.splice(index, 1); + } + } + + destroy() { + for (var i = this._views.length - 1; i >= 0; i--) { + this._views[i]._willUnload(); + this._views[i]._destroy(this._renderer); + } + this._views.length = 0; + + this._sbGesture && this._sbGesture.destroy(); + this._sbTrns && this._sbTrns.destroy(); + this._sbGesture = this._sbTrns = null; + + if (this.parent && this.parent.unregisterChildNav) { + this.parent.unregisterChildNav(this); + } + } + + swipeBackStart() { + if (this.isTransitioning() || this._queue.length > 0) return; + + // default the direction to "back"; + const opts: NavOptions = { + direction: DIRECTION_BACK, + progressAnimation: true + }; + + this._queueTrns({ + removeStart: -1, + removeCount: 1, + opts: opts, + }, null); + + } + + swipeBackProgress(stepValue: number) { + if (this._sbTrns && this._sbGesture) { + // continue to disable the app while actively dragging + this._app.setEnabled(false, ACTIVE_TRANSITION_MAX_TIME); + this.setTransitioning(true, ACTIVE_TRANSITION_MAX_TIME); + + // set the transition animation's progress + this._sbTrns.progressStep(stepValue); + } + } + + swipeBackEnd(shouldComplete: boolean, currentStepValue: number) { + if (this._sbTrns && this._sbGesture) { + // the swipe back gesture has ended + this._sbTrns.progressEnd(shouldComplete, currentStepValue); + } + } + + _sbCheck() { + if (this._sbEnabled && !this._isPortal) { + // this nav controller can have swipe to go back + + if (!this._sbGesture) { + // create the swipe back gesture if we haven't already + const 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(); + } + } + } + + canSwipeBack(): boolean { + return (this._sbEnabled && + !this._children.length && + !this.isTransitioning() && + this._app.isEnabled() && + this.canGoBack()); + } + + canGoBack(): boolean { + const activeView = this.getActive(); + return !!(activeView && activeView.enableBack()) || false; + } + + isTransitioning(): boolean { + // using a timestamp instead of boolean incase something goes wrong + return (this._trnsTm > Date.now()); + } + + setTransitioning(isTransitioning: boolean, durationPadding: number = 2000) { + this._trnsTm = (isTransitioning ? Date.now() + durationPadding : 0); + } + + getActive() { + return this._views[this._views.length - 1]; + } + + isActive(view: ViewController) { + return (view === this.getActive()); + } + + getByIndex(index: number): ViewController { + return this._views[index]; + } + + getPrevious(view?: ViewController): ViewController { + // returns the view controller which is before the given view controller. + if (!view) { + view = this.getActive(); + } + return this._views[this.indexOf(view) - 1]; + } + + first(): ViewController { + // returns the first view controller in this nav controller's stack. + return this._views[0]; + } + + last(): ViewController { + // returns the last page in this nav controller's stack. + return this._views[this._views.length - 1]; + } + + indexOf(view: ViewController): number { + // returns the index number of the given view controller. + return this._views.indexOf(view); + } + + length(): number { + return this._views.length; + } + + isSwipeBackEnabled(): boolean { + return this._sbEnabled; + } + + dismissPageChangeViews() { + for (var i = 0; i < this._views.length; i++) { + var view = this._views[i]; + if (view.data && view.data.dismissOnPageChange) { + view.dismiss(); + } + } + } + + setViewport(val: ViewContainerRef) { + this._viewport = val; + } + + + /** + * @private + * DEPRECATED: Please use app.getRootNav() instead + */ + get rootNav(): NavController { + // deprecated 07-14-2016 beta.11 + console.warn('nav.rootNav() has been deprecated, please use app.getRootNav() instead'); + return this._app.getRootNav(); + } + + /** + * @private + * DEPRECATED: Please use inject the overlays controller and use the present method on the instance instead. + */ + present(): Promise { + // 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(); + } + +} + +let ctrlIds = -1; + +const DISABLE_APP_MINIMUM_DURATION = 64; +const ACTIVE_TRANSITION_MAX_TIME = 5000; diff --git a/src/components/nav/nav-controller.ts b/src/navigation/nav-controller.ts similarity index 90% rename from src/components/nav/nav-controller.ts rename to src/navigation/nav-controller.ts index 8b309cd77f..08fb80a111 100644 --- a/src/components/nav/nav-controller.ts +++ b/src/navigation/nav-controller.ts @@ -1,11 +1,7 @@ import { EventEmitter } from '@angular/core'; -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 { Config } from '../config/config'; +import { NavOptions } from './nav-util'; import { ViewController } from './view-controller'; @@ -96,7 +92,7 @@ import { ViewController } from './view-controller'; * template: '' * }) * export class MyApp { - * @ViewChild('myNav') nav : NavController + * @ViewChild('myNav') nav: NavController * private rootPage = TabsPage; * * // Wait for the components in MyApp's template to be initialized @@ -199,11 +195,11 @@ import { ViewController } from './view-controller'; * I'm the other page!` * }) * class OtherPage { - * constructor(private navController: NavController ){ + * constructor(private navCtrl: NavController ){ * } * * popView(){ - * this.navController.pop(); + * this.navCtrl.pop(); * } * } * ``` @@ -219,7 +215,7 @@ import { ViewController } from './view-controller'; * template: 'Hello World' * }) * class HelloWorld { - * ionViewLoaded() { + * ionViewDidLoad() { * console.log("I'm alive!"); * } * ionViewWillLeave() { @@ -230,13 +226,12 @@ import { ViewController } from './view-controller'; * * | 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. | + * | `ionViewDidLoad` | Runs when the page has loaded. This event only happens once per page being created. If a page leaves but is cached, then this event will not fire again on a subsequent viewing. The `ionViewDidLoad` 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. + * | `ionViewWillUnload` | Runs when the page is about to be destroyed and have its elements removed. * * * ## Asynchronous Nav Transitions @@ -254,14 +249,14 @@ import { ViewController } from './view-controller'; * operations in order. Navigation actions can be chained together very easily using promises. * * ```typescript - * let navTransitionPromise = this.navController.push(Page2); - * navTransitionPromise.then( () => { + * let navTransitionPromise = this.navCtrl.push(Page2); + * navTransitionPromise.then(() => { * // the transition has completed, so I can push another page now - * return this.navController.push(Page3); - * }).then( () => { + * return this.navCtrl.push(Page3); + * }).then(() => { * // the second transition has completed, so I can push yet another page - return this.navController.push(Page4); - * }).then( () => { + return this.navCtrl.push(Page4); + * }).then(() => { * console.log('The transitions are complete!'); * }) * ``` @@ -322,12 +317,6 @@ export abstract class NavController { */ viewWillUnload: EventEmitter; - /** - * Observable to be subscribed to when a component has fully been unloaded and destroyed. - * @returns {Observable} Returns an observable - */ - viewDidUnload: EventEmitter; - /** * @private */ @@ -345,27 +334,6 @@ export abstract class NavController { */ config: Config; - /** - * 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. - */ - abstract setRoot(page: any, params?: any, opts?: NavOptions, done?: Function): Promise; - - /** - * 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} 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. - */ - abstract setPages(pages: Array<{page: any, params?: any}>, opts?: NavOptions, done?: Function): 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 @@ -421,13 +389,20 @@ export abstract class NavController { /** * @private - * Pop to a specific view in the history stack. + * Pop to a specific view in the history stack. If an already created + * instance of the page is not found in the stack, then it'll `setRoot` + * to the nav stack by removing all current pages and pushing on a + * new instance of the given page. Note that any params passed to + * this method are not used when an existing page instance has already + * been found in the stack. Nav params are only used by this method + * when a new instance needs to be created. * - * @param {ViewController} view to pop to + * @param {any} page A page can be a ViewController instance or string. + * @param {object} [params={}] Any nav-params to be used when a new view instance is created at the root. * @param {object} [opts={}] Nav options to go with this transition. * @returns {Promise} Returns a promise which is resolved when the transition has completed. */ - abstract popTo(view: ViewController, opts?: NavOptions, done?: Function): Promise; + abstract popTo(page: any, params?: any, opts?: NavOptions, done?: Function): Promise; /** * Removes a page from the nav stack at the specified index. @@ -439,6 +414,27 @@ export abstract class NavController { */ abstract remove(startIndex: number, removeCount?: number, opts?: NavOptions, done?: Function): Promise; + /** + * Set the root for the current navigation stack. + * @param {Page|ViewController} 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. + */ + abstract setRoot(pageOrViewCtrl: any, params?: any, opts?: NavOptions, done?: Function): Promise; + + /** + * 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} 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. + */ + abstract setPages(pages: any[], opts?: NavOptions, done?: Function): Promise; + /** * @param {number} index The index of the page to get. * @returns {ViewController} Returns the view controller that matches the given index. @@ -448,7 +444,7 @@ export abstract class NavController { /** * @returns {ViewController} Returns the active page's view controller. */ - abstract getActive(): ViewController; + abstract getActive(includeEntering?: boolean): ViewController; /** * Returns if the given view is the active view or not. @@ -459,10 +455,11 @@ export abstract class NavController { /** * Returns the view controller which is before the given view controller. + * If no view controller is passed in, then it'll default to the active view. * @param {ViewController} view * @returns {viewController} */ - abstract getPrevious(view: ViewController): ViewController; + abstract getPrevious(view?: ViewController): ViewController; /** * Returns the first view controller in this nav controller's stack. diff --git a/src/components/nav/nav-params.ts b/src/navigation/nav-params.ts similarity index 100% rename from src/components/nav/nav-params.ts rename to src/navigation/nav-params.ts diff --git a/src/navigation/nav-util.ts b/src/navigation/nav-util.ts new file mode 100644 index 0000000000..71884f4c53 --- /dev/null +++ b/src/navigation/nav-util.ts @@ -0,0 +1,176 @@ +import { Renderer, TypeDecorator } from '@angular/core'; + +import { DeepLinker } from './deep-linker'; +import { isPresent } from '../util/util'; +import { isViewController, ViewController } from './view-controller'; +import { NavControllerBase } from './nav-controller-base'; +import { Transition } from '../transitions/transition'; + + +export function convertToView(linker: DeepLinker, nameOrPageOrView: any, params: any): ViewController { + if (nameOrPageOrView) { + if (isViewController(nameOrPageOrView)) { + // is already a ViewController + return nameOrPageOrView; + } + if (typeof nameOrPageOrView === 'function') { + // is a page component, now turn it into a ViewController + return new ViewController(nameOrPageOrView, params); + } + if (typeof nameOrPageOrView === 'string') { + // is a string, see if it matches a + const component = linker.getComponentFromName(nameOrPageOrView); + if (component) { + // found a page component in the link config by name + return new ViewController(component, params); + } + } + } + console.error(`invalid page component: ${nameOrPageOrView}`); + return null; +} + +export function convertToViews(linker: DeepLinker, pages: any[]): ViewController[] { + const views: ViewController[] = []; + if (pages) { + for (var i = 0; i < pages.length; i++) { + var page = pages[i]; + if (page) { + if (isViewController(page)) { + views.push(page); + + } else { + views.push(convertToView(linker, page.page, page.params)); + } + } + } + } + return views; +} + +export function setZIndex(nav: NavControllerBase, enteringView: ViewController, leavingView: ViewController, direction: string, renderer: Renderer) { + if (enteringView) { + + leavingView = leavingView || nav.getPrevious(enteringView); + + if (leavingView && isPresent(leavingView._zIndex)) { + if (direction === DIRECTION_BACK) { + enteringView._setZIndex(leavingView._zIndex - 1, renderer); + + } else { + enteringView._setZIndex(leavingView._zIndex + 1, renderer); + } + + } else { + enteringView._setZIndex(nav._isPortal ? PORTAL_ZINDEX : INIT_ZINDEX, renderer); + } + } +} + +export function isTabs(nav: any) { + // Tabs (ion-tabs) + return !!nav && !!nav.getSelected; +} + +export function isTab(nav: any) { + // Tab (ion-tab) + return !!nav && isPresent(nav._tabId); +} + +export function isNav(nav: any) { + // Nav (ion-nav), Tab (ion-tab), Portal (ion-portal) + return !!nav && !!nav.push; +} + +// public link interface +export interface DeepLinkMetadataType { + name: string; + segment?: string; + defaultHistory?: any[]; +} + +export class DeepLinkMetadata implements DeepLinkMetadataType { + component: any; + name: string; + segment?: string; + defaultHistory?: any[]; +} + +export interface DeepLinkDecorator extends TypeDecorator {} + +export interface DeepLinkMetadataFactory { + (obj: DeepLinkMetadataType): DeepLinkDecorator; + new (obj: DeepLinkMetadataType): DeepLinkMetadata; +} + +export var DeepLink: DeepLinkMetadataFactory; + +export interface DeepLinkConfig { + links: DeepLinkMetadata[]; +} + +// internal link interface, not exposed publicly +export interface NavLink { + component: any; + name?: string; + segment?: string; + parts?: string[]; + partsLen?: number; + staticLen?: number; + dataLen?: number; + defaultHistory?: any[]; +} + +export interface NavSegment { + id: string; + name: string; + component: any; + data: any; + navId?: string; + defaultHistory?: NavSegment[]; +} + +export interface NavOptions { + animate?: boolean; + animation?: string; + direction?: string; + duration?: number; + easing?: string; + id?: string; + keyboardClose?: boolean; + progressAnimation?: boolean; + ev?: any; + updateUrl?: boolean; + isNavRoot?: boolean; +} + +export interface TransitionResolveFn { + (hasCompleted: boolean, requiresTransition: boolean, enteringName?: string, leavingName?: string, direction?: string): void; +} + +export interface TransitionRejectFn { + (rejectReason: any, transition?: Transition): void; +} + +export interface TransitionInstruction { + opts: NavOptions; + insertStart?: number; + insertViews?: ViewController[]; + removeStart?: number; + removeCount?: number; + resolve?: TransitionResolveFn; + reject?: TransitionRejectFn; +} + +export enum ViewState { + INITIALIZED, + PRE_RENDERED, + LOADED, +} + +export const INIT_ZINDEX = 100; +export const PORTAL_ZINDEX = 9999; + +export const DIRECTION_BACK = 'back'; +export const DIRECTION_FORWARD = 'forward'; +export const DIRECTION_SWITCH = 'switch'; diff --git a/src/components/nav/swipe-back.ts b/src/navigation/swipe-back.ts similarity index 82% rename from src/components/nav/swipe-back.ts rename to src/navigation/swipe-back.ts index ebd34cfda0..ffd4f0a27d 100644 --- a/src/components/nav/swipe-back.ts +++ b/src/navigation/swipe-back.ts @@ -1,9 +1,8 @@ -import { assign } from '../../util/util'; -import { GestureController, GestureDelegate, GesturePriority } from '../../gestures/gesture-controller'; -import { MenuController } from '../menu/menu-controller'; +import { assign } from '../util/util'; +import { GestureController, GesturePriority } from '../gestures/gesture-controller'; import { NavControllerBase } from './nav-controller-base'; -import { SlideData } from '../../gestures/slide-gesture'; -import { SlideEdgeGesture } from '../../gestures/slide-edge-gesture'; +import { SlideData } from '../gestures/slide-gesture'; +import { SlideEdgeGesture } from '../gestures/slide-edge-gesture'; export class SwipeBackGesture extends SlideEdgeGesture { diff --git a/src/navigation/test/nav-controller.spec.ts b/src/navigation/test/nav-controller.spec.ts new file mode 100644 index 0000000000..dc7b2722af --- /dev/null +++ b/src/navigation/test/nav-controller.spec.ts @@ -0,0 +1,994 @@ +import { mockNavController, mockView, mockViews, + MockView1, MockView2, MockView3, MockView4, MockView5 } from '../../util/mock-providers'; +import { NavControllerBase } from '../nav-controller-base'; +import { NavOptions, DIRECTION_FORWARD, DIRECTION_BACK } from '../nav-util'; +import { ViewController } from '../view-controller'; + + +describe('NavController', () => { + + describe('push and pop', () => { + + it('should push multiple times and pop multiple times', () => { + let push1Done = jasmine.createSpy('PushDone'); + let push2Done = jasmine.createSpy('PushDone'); + let push3Done = jasmine.createSpy('PushDone'); + let push4Done = jasmine.createSpy('PushDone'); + let pop1Done = jasmine.createSpy('PopDone'); + let pop2Done = jasmine.createSpy('PopDone'); + let pop3Done = jasmine.createSpy('PopDone'); + + // Push 1 + nav.push(MockView1, null, { animate: false }, push1Done); + + let hasCompleted = true; + let requiresTransition = true; + expect(push1Done).toHaveBeenCalledWith( + hasCompleted, requiresTransition, 'MockView1', undefined, DIRECTION_FORWARD + ); + expect(nav.length()).toEqual(1); + expect(nav.getByIndex(0).component).toEqual(MockView1); + + // Push 2 + nav.push(MockView2, null, { animate: false }, push2Done); + + expect(push2Done).toHaveBeenCalledWith( + hasCompleted, requiresTransition, 'MockView2', 'MockView1', DIRECTION_FORWARD + ); + expect(nav.length()).toEqual(2); + expect(nav.getByIndex(0).component).toEqual(MockView1); + expect(nav.getByIndex(1).component).toEqual(MockView2); + + // Push 3 + nav.push(MockView3, null, { animate: false }, push3Done); + + expect(push3Done).toHaveBeenCalledWith( + hasCompleted, requiresTransition, 'MockView3', 'MockView2', DIRECTION_FORWARD + ); + expect(nav.length()).toEqual(3); + expect(nav.getByIndex(0).component).toEqual(MockView1); + expect(nav.getByIndex(1).component).toEqual(MockView2); + expect(nav.getByIndex(2).component).toEqual(MockView3); + + // Push 4 + nav.push(MockView4, null, { animate: false }, push4Done); + + expect(push4Done).toHaveBeenCalledWith( + hasCompleted, requiresTransition, 'MockView4', 'MockView3', DIRECTION_FORWARD + ); + expect(nav.length()).toEqual(4); + expect(nav.getByIndex(0).component).toEqual(MockView1); + expect(nav.getByIndex(1).component).toEqual(MockView2); + expect(nav.getByIndex(2).component).toEqual(MockView3); + expect(nav.getByIndex(3).component).toEqual(MockView4); + + // Pop 1 + nav.pop({ animate: false }, pop1Done); + + expect(pop1Done).toHaveBeenCalledWith( + hasCompleted, requiresTransition, 'MockView3', 'MockView4', DIRECTION_BACK + ); + expect(nav.length()).toEqual(3); + expect(nav.getByIndex(0).component).toEqual(MockView1); + expect(nav.getByIndex(1).component).toEqual(MockView2); + expect(nav.getByIndex(2).component).toEqual(MockView3); + + // Pop 2 + nav.pop({ animate: false }, pop2Done); + + expect(pop2Done).toHaveBeenCalledWith( + hasCompleted, requiresTransition, 'MockView2', 'MockView3', DIRECTION_BACK + ); + expect(nav.length()).toEqual(2); + expect(nav.getByIndex(0).component).toEqual(MockView1); + expect(nav.getByIndex(1).component).toEqual(MockView2); + + // Pop 3 + nav.pop({ animate: false }, pop3Done); + + expect(pop3Done).toHaveBeenCalledWith( + hasCompleted, requiresTransition, 'MockView1', 'MockView2', DIRECTION_BACK + ); + expect(nav.length()).toEqual(1); + expect(nav.getByIndex(0).component).toEqual(MockView1); + }); + + }); + + describe('push', () => { + + it('should push a component as the first view', () => { + nav.push(MockView1, null, null, trnsDone); + + let hasCompleted = true; + let requiresTransition = true; + expect(trnsDone).toHaveBeenCalledWith( + hasCompleted, requiresTransition, 'MockView1', undefined, DIRECTION_FORWARD + ); + expect(nav.length()).toEqual(1); + expect(nav.getByIndex(0).component).toEqual(MockView1); + expect(nav.isTransitioning()).toEqual(false); + }); + + it('should push a component as the second view at the end', () => { + mockViews(nav, [mockView(MockView1)]); + nav.push(MockView2, null, null, trnsDone); + + let hasCompleted = true; + let requiresTransition = true; + expect(trnsDone).toHaveBeenCalledWith( + hasCompleted, requiresTransition, 'MockView2', 'MockView1', DIRECTION_FORWARD + ); + expect(nav.length()).toEqual(2); + expect(nav.getByIndex(0).component).toEqual(MockView1); + expect(nav.getByIndex(1).component).toEqual(MockView2); + expect(nav.isTransitioning()).toEqual(false); + }); + + it('should push a ViewController as the second view and fire lifecycles', () => { + let view1 = mockView(); + let view2 = mockView(); + + let instance1 = spyOnLifecycles(view1); + let instance2 = spyOnLifecycles(view2); + + mockViews(nav, [view1]); + nav.push(view2, null, null, trnsDone); + + expect(instance1.ionViewDidLoad).not.toHaveBeenCalled(); + expect(instance1.ionViewCanEnter).not.toHaveBeenCalled(); + expect(instance1.ionViewWillEnter).not.toHaveBeenCalled(); + expect(instance1.ionViewDidEnter).not.toHaveBeenCalled(); + expect(instance1.ionViewCanLeave).toHaveBeenCalled(); + expect(instance1.ionViewWillLeave).toHaveBeenCalled(); + expect(instance1.ionViewDidLeave).toHaveBeenCalled(); + expect(instance1.ionViewWillUnload).not.toHaveBeenCalled(); + + expect(instance2.ionViewDidLoad).toHaveBeenCalled(); + expect(instance2.ionViewCanEnter).toHaveBeenCalled(); + expect(instance2.ionViewWillEnter).toHaveBeenCalled(); + expect(instance2.ionViewDidEnter).toHaveBeenCalled(); + expect(instance2.ionViewCanLeave).not.toHaveBeenCalled(); + expect(instance2.ionViewWillLeave).not.toHaveBeenCalled(); + expect(instance2.ionViewDidLeave).not.toHaveBeenCalled(); + expect(instance2.ionViewWillUnload).not.toHaveBeenCalled(); + + let hasCompleted = true; + let requiresTransition = true; + expect(trnsDone).toHaveBeenCalledWith( + hasCompleted, requiresTransition, 'MockView', 'MockView', DIRECTION_FORWARD + ); + expect(nav.length()).toEqual(2); + }); + + }); + + describe('insert', () => { + + it('should insert at the begining with no async transition', () => { + let view4 = mockView(MockView4); + let instance4 = spyOnLifecycles(view4); + let opts: NavOptions = {}; + + mockViews(nav, [mockView(MockView1), mockView(MockView2), mockView(MockView3)]); + nav.insert(0, view4, null, opts, trnsDone); + + expect(instance4.ionViewDidLoad).not.toHaveBeenCalled(); + expect(instance4.ionViewCanEnter).not.toHaveBeenCalled(); + expect(instance4.ionViewWillEnter).not.toHaveBeenCalled(); + expect(instance4.ionViewDidEnter).not.toHaveBeenCalled(); + expect(instance4.ionViewCanLeave).not.toHaveBeenCalled(); + expect(instance4.ionViewWillLeave).not.toHaveBeenCalled(); + expect(instance4.ionViewDidLeave).not.toHaveBeenCalled(); + expect(instance4.ionViewWillUnload).not.toHaveBeenCalled(); + + let hasCompleted = true; + let requiresTransition = false; + expect(trnsDone).toHaveBeenCalledWith( + hasCompleted, requiresTransition, undefined, undefined, undefined + ); + expect(nav.length()).toEqual(4); + expect(nav.first().component).toEqual(MockView4); + expect(nav.last().component).toEqual(MockView3); + }); + + it('should insert at the end when given -1', () => { + let opts: NavOptions = {}; + mockViews(nav, [mockView(MockView1)]); + nav.insert(-1, MockView2, null, opts, trnsDone); + + let hasCompleted = true; + let requiresTransition = true; + expect(trnsDone).toHaveBeenCalledWith( + hasCompleted, requiresTransition, 'MockView2', 'MockView1', DIRECTION_FORWARD + ); + expect(nav.length()).toEqual(2); + expect(nav.last().component).toEqual(MockView2); + }); + + it('should insert at the end when given a number greater than actual length', () => { + mockViews(nav, [mockView(MockView1)]); + nav.insert(9999, MockView2, null, null, trnsDone); + + let hasCompleted = true; + let requiresTransition = true; + expect(trnsDone).toHaveBeenCalledWith( + hasCompleted, requiresTransition, 'MockView2', 'MockView1', DIRECTION_FORWARD + ); + expect(nav.length()).toEqual(2); + expect(nav.last().component).toEqual(MockView2); + }); + + it('should not insert if null view', () => { + mockViews(nav, [mockView(MockView1)]); + nav.insert(-1, null, null, null, trnsDone); + + let hasCompleted = false; + let requiresTransition = false; + let rejectReason = 'invalid views to insert'; + expect(trnsDone).toHaveBeenCalledWith(hasCompleted, requiresTransition, rejectReason); + expect(nav.length()).toEqual(1); + expect(nav.last().component).toEqual(MockView1); + }); + + }); + + describe('insertPages', () => { + + it('should insert all pages in the middle', () => { + let view4 = mockView(MockView4); + let instance4 = spyOnLifecycles(view4); + mockViews(nav, [mockView(MockView1), mockView(MockView2), mockView(MockView3)]); + nav.insertPages(1, [view4, mockView(MockView5)], null, trnsDone); + + expect(instance4.ionViewDidLoad).not.toHaveBeenCalled(); + expect(instance4.ionViewCanEnter).not.toHaveBeenCalled(); + expect(instance4.ionViewWillEnter).not.toHaveBeenCalled(); + expect(instance4.ionViewDidEnter).not.toHaveBeenCalled(); + expect(instance4.ionViewCanLeave).not.toHaveBeenCalled(); + expect(instance4.ionViewWillLeave).not.toHaveBeenCalled(); + expect(instance4.ionViewDidLeave).not.toHaveBeenCalled(); + expect(instance4.ionViewWillUnload).not.toHaveBeenCalled(); + + let hasCompleted = true; + let requiresTransition = false; + expect(trnsDone).toHaveBeenCalledWith( + hasCompleted, requiresTransition, undefined, undefined, undefined + ); + expect(nav.length()).toEqual(5); + expect(nav.getByIndex(0).component).toEqual(MockView1); + expect(nav.getByIndex(1).component).toEqual(MockView4); + expect(nav.getByIndex(2).component).toEqual(MockView5); + expect(nav.getByIndex(3).component).toEqual(MockView2); + expect(nav.getByIndex(4).component).toEqual(MockView3); + + expect(nav.getByIndex(1)._nav).toEqual(nav); + expect(nav.getByIndex(2)._nav).toEqual(nav); + }); + + }); + + describe('pop', () => { + + it('should not pop when no views in the stack', () => { + nav.pop(null, trnsDone); + + let hasCompleted = false; + let requiresTransition = false; + expect(trnsDone).toHaveBeenCalledWith( + hasCompleted, requiresTransition, 'no views in the stack to be removed' + ); + expect(nav.length()).toEqual(0); + expect(nav.isTransitioning()).toEqual(false); + }); + + it('should remove the last view and fire lifecycles', () => { + let view1 = mockView(MockView1); + let view2 = mockView(MockView2); + mockViews(nav, [view1, view2]); + let instance1 = spyOnLifecycles(view1); + let instance2 = spyOnLifecycles(view2); + + nav.pop(null, trnsDone); + + expect(instance1.ionViewDidLoad).toHaveBeenCalled(); + expect(instance1.ionViewCanEnter).toHaveBeenCalled(); + expect(instance1.ionViewWillEnter).toHaveBeenCalled(); + expect(instance1.ionViewDidEnter).toHaveBeenCalled(); + expect(instance1.ionViewCanLeave).not.toHaveBeenCalled(); + expect(instance1.ionViewWillLeave).not.toHaveBeenCalled(); + expect(instance1.ionViewDidLeave).not.toHaveBeenCalled(); + expect(instance1.ionViewWillUnload).not.toHaveBeenCalled(); + + expect(instance2.ionViewDidLoad).not.toHaveBeenCalled(); + expect(instance2.ionViewCanEnter).not.toHaveBeenCalled(); + expect(instance2.ionViewWillEnter).not.toHaveBeenCalled(); + expect(instance2.ionViewDidEnter).not.toHaveBeenCalled(); + expect(instance2.ionViewCanLeave).toHaveBeenCalled(); + expect(instance2.ionViewWillLeave).toHaveBeenCalled(); + expect(instance2.ionViewDidLeave).toHaveBeenCalled(); + expect(instance2.ionViewWillUnload).toHaveBeenCalled(); + + let hasCompleted = true; + let requiresTransition = true; + expect(trnsDone).toHaveBeenCalledWith( + hasCompleted, requiresTransition, 'MockView1', 'MockView2', DIRECTION_BACK + ); + expect(nav.length()).toEqual(1); + expect(nav.getByIndex(0).component).toEqual(MockView1); + expect(nav.isTransitioning()).toEqual(false); + }); + + }); + + describe('popTo', () => { + + it('should pop to a view', () => { + let view1 = mockView(MockView1); + let view2 = mockView(MockView2); + let view3 = mockView(MockView3); + mockViews(nav, [view1, view2, view3]); + + nav.popTo(view2, null, trnsDone); + + let hasCompleted = true; + let requiresTransition = true; + expect(trnsDone).toHaveBeenCalledWith( + hasCompleted, requiresTransition, 'MockView2', 'MockView3', DIRECTION_BACK + ); + expect(nav.length()).toEqual(2); + expect(nav.getByIndex(0).component).toEqual(MockView1); + expect(nav.getByIndex(1).component).toEqual(MockView2); + }); + + it('should pop to using an index number', () => { + let view1 = mockView(MockView1); + let view2 = mockView(MockView2); + let view3 = mockView(MockView3); + let view4 = mockView(MockView4); + mockViews(nav, [view1, view2, view3, view4]); + + nav.popTo(1, null, trnsDone); + + let hasCompleted = true; + let requiresTransition = true; + expect(trnsDone).toHaveBeenCalledWith( + hasCompleted, requiresTransition, 'MockView2', 'MockView4', DIRECTION_BACK + ); + expect(nav.length()).toEqual(2); + expect(nav.getByIndex(0).component).toEqual(MockView1); + expect(nav.getByIndex(1).component).toEqual(MockView2); + }); + + it('should pop to first using an index number', () => { + let view1 = mockView(MockView1); + let view2 = mockView(MockView2); + let view3 = mockView(MockView3); + let view4 = mockView(MockView4); + mockViews(nav, [view1, view2, view3, view4]); + + let instance1 = spyOnLifecycles(view1); + let instance2 = spyOnLifecycles(view2); + let instance3 = spyOnLifecycles(view3); + let instance4 = spyOnLifecycles(view4); + + nav.popTo(0, null, trnsDone); + + expect(instance1.ionViewDidLoad).toHaveBeenCalled(); + expect(instance1.ionViewCanEnter).toHaveBeenCalled(); + expect(instance1.ionViewWillEnter).toHaveBeenCalled(); + expect(instance1.ionViewDidEnter).toHaveBeenCalled(); + expect(instance1.ionViewCanLeave).not.toHaveBeenCalled(); + expect(instance1.ionViewWillLeave).not.toHaveBeenCalled(); + expect(instance1.ionViewDidLeave).not.toHaveBeenCalled(); + expect(instance1.ionViewWillUnload).not.toHaveBeenCalled(); + + expect(instance2.ionViewDidLoad).not.toHaveBeenCalled(); + expect(instance2.ionViewCanEnter).not.toHaveBeenCalled(); + expect(instance2.ionViewWillEnter).not.toHaveBeenCalled(); + expect(instance2.ionViewDidEnter).not.toHaveBeenCalled(); + expect(instance2.ionViewCanLeave).not.toHaveBeenCalled(); + expect(instance2.ionViewWillLeave).toHaveBeenCalled(); + expect(instance2.ionViewDidLeave).toHaveBeenCalled(); + expect(instance2.ionViewWillUnload).toHaveBeenCalled(); + + expect(instance3.ionViewDidLoad).not.toHaveBeenCalled(); + expect(instance3.ionViewCanEnter).not.toHaveBeenCalled(); + expect(instance3.ionViewWillEnter).not.toHaveBeenCalled(); + expect(instance3.ionViewDidEnter).not.toHaveBeenCalled(); + expect(instance3.ionViewCanLeave).not.toHaveBeenCalled(); + expect(instance3.ionViewWillLeave).toHaveBeenCalled(); + expect(instance3.ionViewDidLeave).toHaveBeenCalled(); + expect(instance3.ionViewWillUnload).toHaveBeenCalled(); + + expect(instance4.ionViewDidLoad).not.toHaveBeenCalled(); + expect(instance4.ionViewCanEnter).not.toHaveBeenCalled(); + expect(instance4.ionViewWillEnter).not.toHaveBeenCalled(); + expect(instance4.ionViewDidEnter).not.toHaveBeenCalled(); + expect(instance4.ionViewCanLeave).toHaveBeenCalled(); + expect(instance4.ionViewWillLeave).toHaveBeenCalled(); + expect(instance4.ionViewDidLeave).toHaveBeenCalled(); + expect(instance4.ionViewWillUnload).toHaveBeenCalled(); + + let hasCompleted = true; + let requiresTransition = true; + expect(trnsDone).toHaveBeenCalledWith( + hasCompleted, requiresTransition, 'MockView1', 'MockView4', DIRECTION_BACK + ); + expect(nav.length()).toEqual(1); + expect(nav.getByIndex(0).component).toEqual(MockView1); + }); + + }); + + describe('popToRoot', () => { + + it('should pop to the first view', () => { + let view1 = mockView(MockView1); + let view2 = mockView(MockView2); + let view3 = mockView(MockView3); + let view4 = mockView(MockView4); + mockViews(nav, [view1, view2, view3, view4]); + + let instance1 = spyOnLifecycles(view1); + let instance2 = spyOnLifecycles(view2); + let instance3 = spyOnLifecycles(view3); + let instance4 = spyOnLifecycles(view4); + + nav.popToRoot(null, trnsDone); + + expect(instance1.ionViewDidLoad).toHaveBeenCalled(); + expect(instance1.ionViewCanEnter).toHaveBeenCalled(); + expect(instance1.ionViewWillEnter).toHaveBeenCalled(); + expect(instance1.ionViewDidEnter).toHaveBeenCalled(); + expect(instance1.ionViewCanLeave).not.toHaveBeenCalled(); + expect(instance1.ionViewWillLeave).not.toHaveBeenCalled(); + expect(instance1.ionViewDidLeave).not.toHaveBeenCalled(); + expect(instance1.ionViewWillUnload).not.toHaveBeenCalled(); + + expect(instance2.ionViewDidLoad).not.toHaveBeenCalled(); + expect(instance2.ionViewCanEnter).not.toHaveBeenCalled(); + expect(instance2.ionViewWillEnter).not.toHaveBeenCalled(); + expect(instance2.ionViewDidEnter).not.toHaveBeenCalled(); + expect(instance2.ionViewCanLeave).not.toHaveBeenCalled(); + expect(instance2.ionViewWillLeave).toHaveBeenCalled(); + expect(instance2.ionViewDidLeave).toHaveBeenCalled(); + expect(instance2.ionViewWillUnload).toHaveBeenCalled(); + + expect(instance3.ionViewDidLoad).not.toHaveBeenCalled(); + expect(instance3.ionViewCanEnter).not.toHaveBeenCalled(); + expect(instance3.ionViewWillEnter).not.toHaveBeenCalled(); + expect(instance3.ionViewDidEnter).not.toHaveBeenCalled(); + expect(instance3.ionViewCanLeave).not.toHaveBeenCalled(); + expect(instance3.ionViewWillLeave).toHaveBeenCalled(); + expect(instance3.ionViewDidLeave).toHaveBeenCalled(); + expect(instance3.ionViewWillUnload).toHaveBeenCalled(); + + expect(instance4.ionViewDidLoad).not.toHaveBeenCalled(); + expect(instance4.ionViewCanEnter).not.toHaveBeenCalled(); + expect(instance4.ionViewWillEnter).not.toHaveBeenCalled(); + expect(instance4.ionViewDidEnter).not.toHaveBeenCalled(); + expect(instance4.ionViewCanLeave).toHaveBeenCalled(); + expect(instance4.ionViewWillLeave).toHaveBeenCalled(); + expect(instance4.ionViewDidLeave).toHaveBeenCalled(); + expect(instance4.ionViewWillUnload).toHaveBeenCalled(); + + let hasCompleted = true; + let requiresTransition = true; + expect(trnsDone).toHaveBeenCalledWith( + hasCompleted, requiresTransition, 'MockView1', 'MockView4', DIRECTION_BACK + ); + expect(nav.length()).toEqual(1); + expect(nav.getByIndex(0).component).toEqual(MockView1); + }); + + }); + + describe('remove', () => { + + it('should remove the first three views in the beginning, no last view transition', () => { + let view1 = mockView(MockView1); + let view2 = mockView(MockView2); + let view3 = mockView(MockView3); + let view4 = mockView(MockView4); + mockViews(nav, [view1, view2, view3, view4]); + + let instance1 = spyOnLifecycles(view1); + let instance2 = spyOnLifecycles(view2); + let instance3 = spyOnLifecycles(view3); + let instance4 = spyOnLifecycles(view4); + + nav.remove(0, 3, null, trnsDone); + + expect(instance1.ionViewDidLoad).not.toHaveBeenCalled(); + expect(instance1.ionViewCanEnter).not.toHaveBeenCalled(); + expect(instance1.ionViewWillEnter).not.toHaveBeenCalled(); + expect(instance1.ionViewDidEnter).not.toHaveBeenCalled(); + expect(instance1.ionViewCanLeave).not.toHaveBeenCalled(); + expect(instance1.ionViewWillLeave).toHaveBeenCalled(); + expect(instance1.ionViewDidLeave).toHaveBeenCalled(); + expect(instance1.ionViewWillUnload).toHaveBeenCalled(); + + expect(instance2.ionViewDidLoad).not.toHaveBeenCalled(); + expect(instance2.ionViewCanEnter).not.toHaveBeenCalled(); + expect(instance2.ionViewWillEnter).not.toHaveBeenCalled(); + expect(instance2.ionViewDidEnter).not.toHaveBeenCalled(); + expect(instance2.ionViewCanLeave).not.toHaveBeenCalled(); + expect(instance2.ionViewWillLeave).toHaveBeenCalled(); + expect(instance2.ionViewDidLeave).toHaveBeenCalled(); + expect(instance2.ionViewWillUnload).toHaveBeenCalled(); + + expect(instance3.ionViewDidLoad).not.toHaveBeenCalled(); + expect(instance3.ionViewCanEnter).not.toHaveBeenCalled(); + expect(instance3.ionViewWillEnter).not.toHaveBeenCalled(); + expect(instance3.ionViewDidEnter).not.toHaveBeenCalled(); + expect(instance3.ionViewCanLeave).not.toHaveBeenCalled(); + expect(instance3.ionViewWillLeave).toHaveBeenCalled(); + expect(instance3.ionViewDidLeave).toHaveBeenCalled(); + expect(instance3.ionViewWillUnload).toHaveBeenCalled(); + + expect(instance4.ionViewDidLoad).not.toHaveBeenCalled(); + expect(instance4.ionViewCanEnter).not.toHaveBeenCalled(); + expect(instance4.ionViewWillEnter).not.toHaveBeenCalled(); + expect(instance4.ionViewDidEnter).not.toHaveBeenCalled(); + expect(instance4.ionViewCanLeave).not.toHaveBeenCalled(); + expect(instance4.ionViewWillLeave).not.toHaveBeenCalled(); + expect(instance4.ionViewDidLeave).not.toHaveBeenCalled(); + expect(instance4.ionViewWillUnload).not.toHaveBeenCalled(); + + let hasCompleted = true; + let requiresTransition = false; + expect(trnsDone).toHaveBeenCalledWith( + hasCompleted, requiresTransition, undefined, undefined, undefined + ); + expect(nav.length()).toEqual(1); + expect(nav.getByIndex(0).component).toEqual(MockView4); + }); + + it('should remove two views in the middle', () => { + let view1 = mockView(MockView1); + let view2 = mockView(MockView2); + let view3 = mockView(MockView3); + let view4 = mockView(MockView4); + mockViews(nav, [view1, view2, view3, view4]); + + let instance1 = spyOnLifecycles(view1); + let instance2 = spyOnLifecycles(view2); + let instance3 = spyOnLifecycles(view3); + let instance4 = spyOnLifecycles(view4); + + nav.remove(1, 2, null, trnsDone); + + expect(instance1.ionViewDidLoad).not.toHaveBeenCalled(); + expect(instance1.ionViewCanEnter).not.toHaveBeenCalled(); + expect(instance1.ionViewWillEnter).not.toHaveBeenCalled(); + expect(instance1.ionViewDidEnter).not.toHaveBeenCalled(); + expect(instance1.ionViewCanLeave).not.toHaveBeenCalled(); + expect(instance1.ionViewWillLeave).not.toHaveBeenCalled(); + expect(instance1.ionViewDidLeave).not.toHaveBeenCalled(); + expect(instance1.ionViewWillUnload).not.toHaveBeenCalled(); + + expect(instance2.ionViewDidLoad).not.toHaveBeenCalled(); + expect(instance2.ionViewCanEnter).not.toHaveBeenCalled(); + expect(instance2.ionViewWillEnter).not.toHaveBeenCalled(); + expect(instance2.ionViewDidEnter).not.toHaveBeenCalled(); + expect(instance2.ionViewCanLeave).not.toHaveBeenCalled(); + expect(instance2.ionViewWillLeave).toHaveBeenCalled(); + expect(instance2.ionViewDidLeave).toHaveBeenCalled(); + expect(instance2.ionViewWillUnload).toHaveBeenCalled(); + + expect(instance3.ionViewDidLoad).not.toHaveBeenCalled(); + expect(instance3.ionViewCanEnter).not.toHaveBeenCalled(); + expect(instance3.ionViewWillEnter).not.toHaveBeenCalled(); + expect(instance3.ionViewDidEnter).not.toHaveBeenCalled(); + expect(instance3.ionViewCanLeave).not.toHaveBeenCalled(); + expect(instance3.ionViewWillLeave).toHaveBeenCalled(); + expect(instance3.ionViewDidLeave).toHaveBeenCalled(); + expect(instance3.ionViewWillUnload).toHaveBeenCalled(); + + expect(instance4.ionViewDidLoad).not.toHaveBeenCalled(); + expect(instance4.ionViewCanEnter).not.toHaveBeenCalled(); + expect(instance4.ionViewWillEnter).not.toHaveBeenCalled(); + expect(instance4.ionViewDidEnter).not.toHaveBeenCalled(); + expect(instance4.ionViewCanLeave).not.toHaveBeenCalled(); + expect(instance4.ionViewWillLeave).not.toHaveBeenCalled(); + expect(instance4.ionViewDidLeave).not.toHaveBeenCalled(); + expect(instance4.ionViewWillUnload).not.toHaveBeenCalled(); + + let hasCompleted = true; + let requiresTransition = false; + expect(trnsDone).toHaveBeenCalledWith( + hasCompleted, requiresTransition, undefined, undefined, undefined + ); + expect(nav.length()).toEqual(2); + expect(nav.getByIndex(0).component).toEqual(MockView1); + expect(nav.getByIndex(1).component).toEqual(MockView4); + }); + + it('should remove the last two views at the end', () => { + let view1 = mockView(MockView1); + let view2 = mockView(MockView2); + let view3 = mockView(MockView3); + let view4 = mockView(MockView4); + mockViews(nav, [view1, view2, view3, view4]); + + let instance1 = spyOnLifecycles(view1); + let instance2 = spyOnLifecycles(view2); + let instance3 = spyOnLifecycles(view3); + let instance4 = spyOnLifecycles(view4); + + nav.remove(2, 2, null, trnsDone); + + expect(instance1.ionViewDidLoad).not.toHaveBeenCalled(); + expect(instance1.ionViewCanEnter).not.toHaveBeenCalled(); + expect(instance1.ionViewWillEnter).not.toHaveBeenCalled(); + expect(instance1.ionViewDidEnter).not.toHaveBeenCalled(); + expect(instance1.ionViewCanLeave).not.toHaveBeenCalled(); + expect(instance1.ionViewWillLeave).not.toHaveBeenCalled(); + expect(instance1.ionViewDidLeave).not.toHaveBeenCalled(); + expect(instance1.ionViewWillUnload).not.toHaveBeenCalled(); + + expect(instance2.ionViewDidLoad).toHaveBeenCalled(); + expect(instance2.ionViewCanEnter).toHaveBeenCalled(); + expect(instance2.ionViewWillEnter).toHaveBeenCalled(); + expect(instance2.ionViewDidEnter).toHaveBeenCalled(); + expect(instance2.ionViewCanLeave).not.toHaveBeenCalled(); + expect(instance2.ionViewWillLeave).not.toHaveBeenCalled(); + expect(instance2.ionViewDidLeave).not.toHaveBeenCalled(); + expect(instance2.ionViewWillUnload).not.toHaveBeenCalled(); + + expect(instance3.ionViewDidLoad).not.toHaveBeenCalled(); + expect(instance3.ionViewCanEnter).not.toHaveBeenCalled(); + expect(instance3.ionViewWillEnter).not.toHaveBeenCalled(); + expect(instance3.ionViewDidEnter).not.toHaveBeenCalled(); + expect(instance3.ionViewCanLeave).not.toHaveBeenCalled(); + expect(instance3.ionViewWillLeave).toHaveBeenCalled(); + expect(instance3.ionViewDidLeave).toHaveBeenCalled(); + expect(instance3.ionViewWillUnload).toHaveBeenCalled(); + + expect(instance4.ionViewDidLoad).not.toHaveBeenCalled(); + expect(instance4.ionViewCanEnter).not.toHaveBeenCalled(); + expect(instance4.ionViewWillEnter).not.toHaveBeenCalled(); + expect(instance4.ionViewDidEnter).not.toHaveBeenCalled(); + expect(instance4.ionViewCanLeave).toHaveBeenCalled(); + expect(instance4.ionViewWillLeave).toHaveBeenCalled(); + expect(instance4.ionViewDidLeave).toHaveBeenCalled(); + expect(instance4.ionViewWillUnload).toHaveBeenCalled(); + + let hasCompleted = true; + let requiresTransition = true; + expect(trnsDone).toHaveBeenCalledWith( + hasCompleted, requiresTransition, 'MockView2', 'MockView4', DIRECTION_BACK + ); + expect(nav.length()).toEqual(2); + expect(nav.getByIndex(0).component).toEqual(MockView1); + expect(nav.getByIndex(1).component).toEqual(MockView2); + }); + + }); + + describe('setRoot', () => { + + it('should set a ViewController as the root when its the last view, no transition', () => { + let view1 = mockView(MockView1); + let view2 = mockView(MockView2); + let view3 = mockView(MockView3); + mockViews(nav, [view1, view2, view3]); + + let instance1 = spyOnLifecycles(view1); + let instance2 = spyOnLifecycles(view2); + let instance3 = spyOnLifecycles(view3); + + nav.setRoot(view3, null, null, trnsDone); + + expect(instance1.ionViewDidLoad).not.toHaveBeenCalled(); + expect(instance1.ionViewCanEnter).not.toHaveBeenCalled(); + expect(instance1.ionViewWillEnter).not.toHaveBeenCalled(); + expect(instance1.ionViewDidEnter).not.toHaveBeenCalled(); + expect(instance1.ionViewCanLeave).not.toHaveBeenCalled(); + expect(instance1.ionViewWillLeave).toHaveBeenCalled(); + expect(instance1.ionViewDidLeave).toHaveBeenCalled(); + expect(instance1.ionViewWillUnload).toHaveBeenCalled(); + + expect(instance2.ionViewDidLoad).not.toHaveBeenCalled(); + expect(instance2.ionViewCanEnter).not.toHaveBeenCalled(); + expect(instance2.ionViewWillEnter).not.toHaveBeenCalled(); + expect(instance2.ionViewDidEnter).not.toHaveBeenCalled(); + expect(instance2.ionViewCanLeave).not.toHaveBeenCalled(); + expect(instance2.ionViewWillLeave).toHaveBeenCalled(); + expect(instance2.ionViewDidLeave).toHaveBeenCalled(); + expect(instance2.ionViewWillUnload).toHaveBeenCalled(); + + expect(instance3.ionViewDidLoad).not.toHaveBeenCalled(); + expect(instance3.ionViewCanEnter).not.toHaveBeenCalled(); + expect(instance3.ionViewWillEnter).not.toHaveBeenCalled(); + expect(instance3.ionViewDidEnter).not.toHaveBeenCalled(); + expect(instance3.ionViewCanLeave).not.toHaveBeenCalled(); + expect(instance3.ionViewWillLeave).not.toHaveBeenCalled(); + expect(instance3.ionViewDidLeave).not.toHaveBeenCalled(); + expect(instance3.ionViewWillUnload).not.toHaveBeenCalled(); + + let hasCompleted = true; + let requiresTransition = false; + expect(trnsDone).toHaveBeenCalledWith( + hasCompleted, requiresTransition, undefined, undefined, undefined + ); + expect(nav.length()).toEqual(1); + expect(nav.getByIndex(0).component).toEqual(MockView3); + }); + + it('should set a ViewController as the root when its the middle view, with transition', () => { + let view1 = mockView(MockView1); + let view2 = mockView(MockView2); + let view3 = mockView(MockView3); + mockViews(nav, [view1, view2, view3]); + + let instance1 = spyOnLifecycles(view1); + let instance2 = spyOnLifecycles(view2); + let instance3 = spyOnLifecycles(view3); + + nav.setRoot(view2, null, null, trnsDone); + + expect(instance1.ionViewDidLoad).not.toHaveBeenCalled(); + expect(instance1.ionViewCanEnter).not.toHaveBeenCalled(); + expect(instance1.ionViewWillEnter).not.toHaveBeenCalled(); + expect(instance1.ionViewDidEnter).not.toHaveBeenCalled(); + expect(instance1.ionViewCanLeave).not.toHaveBeenCalled(); + expect(instance1.ionViewWillLeave).toHaveBeenCalled(); + expect(instance1.ionViewDidLeave).toHaveBeenCalled(); + expect(instance1.ionViewWillUnload).toHaveBeenCalled(); + + expect(instance2.ionViewDidLoad).toHaveBeenCalled(); + expect(instance2.ionViewCanEnter).toHaveBeenCalled(); + expect(instance2.ionViewWillEnter).toHaveBeenCalled(); + expect(instance2.ionViewDidEnter).toHaveBeenCalled(); + expect(instance2.ionViewCanLeave).not.toHaveBeenCalled(); + expect(instance2.ionViewWillLeave).not.toHaveBeenCalled(); + expect(instance2.ionViewDidLeave).not.toHaveBeenCalled(); + expect(instance2.ionViewWillUnload).not.toHaveBeenCalled(); + + expect(instance3.ionViewDidLoad).not.toHaveBeenCalled(); + expect(instance3.ionViewCanEnter).not.toHaveBeenCalled(); + expect(instance3.ionViewWillEnter).not.toHaveBeenCalled(); + expect(instance3.ionViewDidEnter).not.toHaveBeenCalled(); + expect(instance3.ionViewCanLeave).toHaveBeenCalled(); + expect(instance3.ionViewWillLeave).toHaveBeenCalled(); + expect(instance3.ionViewDidLeave).toHaveBeenCalled(); + expect(instance3.ionViewWillUnload).toHaveBeenCalled(); + + let hasCompleted = true; + let requiresTransition = true; + expect(trnsDone).toHaveBeenCalledWith( + hasCompleted, requiresTransition, 'MockView2', 'MockView3', DIRECTION_BACK + ); + expect(nav.length()).toEqual(1); + expect(nav.getByIndex(0).component).toEqual(MockView2); + }); + + it('should set a ViewController as the root when its the first view, with transition', () => { + let view1 = mockView(MockView1); + let view2 = mockView(MockView2); + let view3 = mockView(MockView3); + mockViews(nav, [view1, view2, view3]); + + let instance1 = spyOnLifecycles(view1); + let instance2 = spyOnLifecycles(view2); + let instance3 = spyOnLifecycles(view3); + + nav.setRoot(view1, null, null, trnsDone); + + expect(instance1.ionViewDidLoad).toHaveBeenCalled(); + expect(instance1.ionViewCanEnter).toHaveBeenCalled(); + expect(instance1.ionViewWillEnter).toHaveBeenCalled(); + expect(instance1.ionViewDidEnter).toHaveBeenCalled(); + expect(instance1.ionViewCanLeave).not.toHaveBeenCalled(); + expect(instance1.ionViewWillLeave).not.toHaveBeenCalled(); + expect(instance1.ionViewDidLeave).not.toHaveBeenCalled(); + expect(instance1.ionViewWillUnload).not.toHaveBeenCalled(); + + expect(instance2.ionViewDidLoad).not.toHaveBeenCalled(); + expect(instance2.ionViewCanEnter).not.toHaveBeenCalled(); + expect(instance2.ionViewWillEnter).not.toHaveBeenCalled(); + expect(instance2.ionViewDidEnter).not.toHaveBeenCalled(); + expect(instance2.ionViewCanLeave).not.toHaveBeenCalled(); + expect(instance2.ionViewWillLeave).toHaveBeenCalled(); + expect(instance2.ionViewDidLeave).toHaveBeenCalled(); + expect(instance2.ionViewWillUnload).toHaveBeenCalled(); + + expect(instance3.ionViewDidLoad).not.toHaveBeenCalled(); + expect(instance3.ionViewCanEnter).not.toHaveBeenCalled(); + expect(instance3.ionViewWillEnter).not.toHaveBeenCalled(); + expect(instance3.ionViewDidEnter).not.toHaveBeenCalled(); + expect(instance3.ionViewCanLeave).toHaveBeenCalled(); + expect(instance3.ionViewWillLeave).toHaveBeenCalled(); + expect(instance3.ionViewDidLeave).toHaveBeenCalled(); + expect(instance3.ionViewWillUnload).toHaveBeenCalled(); + + let hasCompleted = true; + let requiresTransition = true; + expect(trnsDone).toHaveBeenCalledWith( + hasCompleted, requiresTransition, 'MockView1', 'MockView3', DIRECTION_BACK + ); + expect(nav.length()).toEqual(1); + expect(nav.getByIndex(0).component).toEqual(MockView1); + }); + + it('should set a page component as the root, with transition', () => { + let view1 = mockView(MockView1); + let view2 = mockView(MockView2); + let view3 = mockView(MockView3); + mockViews(nav, [view1, view2, view3]); + + let instance1 = spyOnLifecycles(view1); + let instance2 = spyOnLifecycles(view2); + let instance3 = spyOnLifecycles(view3); + + nav.setRoot(MockView4, null, null, trnsDone); + + expect(instance1.ionViewWillUnload).toHaveBeenCalled(); + expect(instance2.ionViewWillUnload).toHaveBeenCalled(); + expect(instance3.ionViewWillUnload).toHaveBeenCalled(); + + let hasCompleted = true; + let requiresTransition = true; + expect(trnsDone).toHaveBeenCalledWith( + hasCompleted, requiresTransition, 'MockView4', 'MockView3', DIRECTION_BACK + ); + expect(nav.length()).toEqual(1); + expect(nav.getByIndex(0).component).toEqual(MockView4); + }); + + }); + + describe('setPages', () => { + + it('should set the pages from an array, starting at the root, with transition', () => { + let view1 = mockView(MockView1); + let view2 = mockView(MockView2); + mockViews(nav, [view1, view2]); + + let instance1 = spyOnLifecycles(view1); + let instance2 = spyOnLifecycles(view2); + + nav.setPages([{page: MockView4}, {page: MockView5}], null, trnsDone); + + expect(instance1.ionViewWillUnload).toHaveBeenCalled(); + expect(instance2.ionViewWillUnload).toHaveBeenCalled(); + + let hasCompleted = true; + let requiresTransition = true; + expect(trnsDone).toHaveBeenCalledWith( + hasCompleted, requiresTransition, 'MockView5', 'MockView2', DIRECTION_BACK + ); + expect(nav.length()).toEqual(2); + expect(nav.getByIndex(0).component).toEqual(MockView4); + expect(nav.getByIndex(1).component).toEqual(MockView5); + }); + + }); + + describe('_nextTrns', () => { + + it('should not start next transition when already transitioning', () => { + nav.setTransitioning(true); + expect(nav._nextTrns()).toEqual(false); + }); + + it('should not start next transition nothing in the queue', () => { + expect(nav._nextTrns()).toEqual(false); + }); + + }); + + // describe('_cleanup', () => { + // it('should destroy views that are inactive after the active view', () => { + // let view1 = new ViewController(Page1); + // view1.state = STATE_INACTIVE; + // let view2 = new ViewController(Page2); + // view2.state = STATE_ACTIVE; + // let view3 = new ViewController(Page3); + // view3.state = STATE_INACTIVE; + // let view4 = new ViewController(Page4); + // view4.state = STATE_TRANS_ENTER; + // let view5 = new ViewController(Page5); + // view5.state = STATE_INACTIVE; + // nav._views = [view1, view2, view3, view4, view5]; + // nav._cleanup(); + + // expect(nav.length()).toBe(3); + // expect(nav.getByIndex(0).state).toBe(STATE_INACTIVE); + // expect(nav.getByIndex(0).component).toBe(Page1); + // expect(nav.getByIndex(1).state).toBe(STATE_ACTIVE); + // expect(nav.getByIndex(1).component).toBe(Page2); + // expect(nav.getByIndex(2).state).toBe(STATE_TRANS_ENTER); + // expect(nav.getByIndex(2).component).toBe(Page4); + // }); + + // it('should not destroy any views since the last is active', () => { + // let view1 = new ViewController(Page1); + // view1.state = STATE_INACTIVE; + // let view2 = new ViewController(Page2); + // view2.state = STATE_ACTIVE; + // nav._views = [view1, view2]; + // nav._cleanup(); + // expect(nav.length()).toBe(2); + // }); + + // it('should call destroy for each view to be destroyed', () => { + // let view1 = new ViewController(Page1); + // view1.state = STATE_ACTIVE; + // let view2 = new ViewController(Page2); + // view2.state = STATE_INACTIVE; + // let view3 = new ViewController(Page3); + // view3.state = STATE_INACTIVE; + // nav._views = [view1, view2, view3]; + + // spyOn(view1, 'destroy'); + // spyOn(view2, 'destroy'); + // spyOn(view3, 'destroy'); + + // nav._cleanup(); + + // expect(nav.length()).toBe(1); + // expect(view1._willUnload).not.toHaveBeenCalled(); + // expect(view2._willUnload).toHaveBeenCalled(); + // expect(view3._willUnload).toHaveBeenCalled(); + // }); + + // it('should reset zIndexes if their is a negative zindex', () => { + // let view1 = new ViewController(Page1); + // view1._setPageElementRef( mockElementRef() ); + // view1.state = STATE_INACTIVE; + // view1._zIndex = -1; + + // let view2 = new ViewController(Page2); + // view2._setPageElementRef( mockElementRef() ); + // view2.state = STATE_INACTIVE; + // view2._zIndex = 0; + + // let view3 = new ViewController(Page3); + // view3._setPageElementRef( mockElementRef() ); + // view3.state = STATE_ACTIVE; + // view3._zIndex = 1; + + // nav._views = [view1, view2, view3]; + // nav._cleanup(); + + // expect(view1._zIndex).toEqual(100); + // expect(view2._zIndex).toEqual(101); + // expect(view3._zIndex).toEqual(102); + // }); + // }); + + let nav: NavControllerBase; + let trnsDone: jasmine.Spy; + + function spyOnLifecycles(view: ViewController) { + let instance = view.instance = { + ionViewDidLoad: () => {}, + ionViewCanEnter: () => { return true; }, + ionViewWillEnter: () => {}, + ionViewDidEnter: () => {}, + ionViewCanLeave: () => {}, + ionViewWillLeave: () => { return true; }, + ionViewDidLeave: () => {}, + ionViewWillUnload: () => {}, + }; + spyOn(instance, 'ionViewDidLoad'); + spyOn(instance, 'ionViewCanEnter'); + spyOn(instance, 'ionViewWillEnter'); + spyOn(instance, 'ionViewDidEnter'); + spyOn(instance, 'ionViewCanLeave'); + spyOn(instance, 'ionViewWillLeave'); + spyOn(instance, 'ionViewDidLeave'); + spyOn(instance, 'ionViewWillUnload'); + + return instance; + } + + beforeEach(() => { + trnsDone = jasmine.createSpy('TransitionDone'); + nav = mockNavController(); + }); + +}); diff --git a/src/navigation/test/nav-util.spec.ts b/src/navigation/test/nav-util.spec.ts new file mode 100644 index 0000000000..171bf1daf6 --- /dev/null +++ b/src/navigation/test/nav-util.spec.ts @@ -0,0 +1,129 @@ +import { convertToView, convertToViews, DIRECTION_BACK, DIRECTION_FORWARD, setZIndex } from '../nav-util'; +import { mockDeepLinker, mockNavController, MockView, mockRenderer, mockView, mockViews } from '../../util/mock-providers'; +import { ViewController } from '../view-controller'; + + +describe('NavUtil', () => { + + describe('convertToViews', () => { + + it('should convert all page components', () => { + let linker = mockDeepLinker(); + let pages = [{ page: MockView }, { page: MockView }, { page: MockView }]; + let views = convertToViews(linker, pages); + expect(views.length).toEqual(3); + expect(views[0].component).toEqual(MockView); + expect(views[1].component).toEqual(MockView); + expect(views[2].component).toEqual(MockView); + }); + + it('should convert all string names', () => { + let linker = mockDeepLinker({ + links: [{ component: MockView, name: 'someName' }] + }); + let pages = [{ page: 'someName' }, { page: 'someName' }, { page: 'someName' }]; + let views = convertToViews(linker, pages); + expect(views.length).toEqual(3); + expect(views[0].component).toEqual(MockView); + expect(views[1].component).toEqual(MockView); + expect(views[2].component).toEqual(MockView); + }); + + it('should convert all ViewControllers', () => { + let pages = [mockView(MockView), mockView(MockView), mockView(MockView)]; + let linker = mockDeepLinker(); + let views = convertToViews(linker, pages); + expect(views.length).toEqual(3); + expect(views[0].component).toEqual(MockView); + expect(views[1].component).toEqual(MockView); + expect(views[2].component).toEqual(MockView); + }); + + }); + + describe('convertToView', () => { + + it('should return new ViewController instance from page component link config name', () => { + let linker = mockDeepLinker({ + links: [{ component: MockView, name: 'someName' }] + }); + let outputView = convertToView(linker, 'someName', null); + expect(outputView.component).toEqual(MockView); + }); + + it('should return new ViewController instance from page component', () => { + let linker = mockDeepLinker(); + let outputView = convertToView(linker, MockView, null); + expect(outputView.component).toEqual(MockView); + }); + + it('should return existing ViewController instance', () => { + let linker = mockDeepLinker(); + let inputView = new ViewController(MockView); + let outputView = convertToView(linker, inputView, null); + expect(outputView).toEqual(inputView); + }); + + it('should return null for null/undefined/number', () => { + let linker = mockDeepLinker(); + let outputView = convertToView(linker, null, null); + expect(outputView).toEqual(null); + + outputView = convertToView(linker, undefined, undefined); + expect(outputView).toEqual(null); + + outputView = convertToView(linker, 8675309, null); + expect(outputView).toEqual(null); + }); + + }); + + describe('setZIndex', () => { + + it('should set zIndex 100 when leaving view doesnt have a zIndex', () => { + let leavingView = mockView(); + let enteringView = mockView(); + + let nav = mockNavController(); + mockViews(nav, [leavingView, enteringView]); + + setZIndex(nav, enteringView, leavingView, DIRECTION_FORWARD, mockRenderer()); + expect(enteringView._zIndex).toEqual(100); + }); + + it('should set zIndex 100 on first entering view', () => { + let enteringView = mockView(); + let nav = mockNavController(); + setZIndex(nav, enteringView, null, DIRECTION_FORWARD, mockRenderer()); + expect(enteringView._zIndex).toEqual(100); + }); + + it('should set zIndex 101 on second entering view', () => { + let leavingView = mockView(); + leavingView._zIndex = 100; + let enteringView = mockView(); + let nav = mockNavController(); + setZIndex(nav, enteringView, leavingView, DIRECTION_FORWARD, mockRenderer()); + expect(enteringView._zIndex).toEqual(101); + }); + + it('should set zIndex 100 on entering view going back', () => { + let leavingView = mockView(); + leavingView._zIndex = 101; + let enteringView = mockView(); + let nav = mockNavController(); + setZIndex(nav, enteringView, leavingView, DIRECTION_BACK, mockRenderer()); + expect(enteringView._zIndex).toEqual(100); + }); + + it('should set zIndex 9999 on first entering portal view', () => { + let enteringView = mockView(); + let nav = mockNavController(); + nav._isPortal = true; + setZIndex(nav, enteringView, null, DIRECTION_FORWARD, mockRenderer()); + expect(enteringView._zIndex).toEqual(9999); + }); + + }); + +}); diff --git a/src/navigation/test/view-controller.spec.ts b/src/navigation/test/view-controller.spec.ts new file mode 100644 index 0000000000..fcc782cc7f --- /dev/null +++ b/src/navigation/test/view-controller.spec.ts @@ -0,0 +1,98 @@ +import { mockView } from '../../util/mock-providers'; + + +describe('ViewController', () => { + + describe('willEnter', () => { + it('should emit LifeCycleEvent when called with component data', (done) => { + // arrange + let viewController = mockView(); + subscription = viewController.willEnter.subscribe((event) => { + // assert + expect(event).toEqual(null); + done(); + }, (err: any) => { + done(); + }); + + // act + viewController._willEnter(); + }, 10000); + }); + + describe('didEnter', () => { + it('should emit LifeCycleEvent when called with component data', (done) => { + // arrange + let viewController = mockView(); + subscription = viewController.didEnter.subscribe((event) => { + // assert + expect(event).toEqual(null); + done(); + }, (err: any) => { + done(); + }); + + // act + viewController._didEnter(); + }, 10000); + }); + + describe('willLeave', () => { + it('should emit LifeCycleEvent when called with component data', (done) => { + // arrange + let viewController = mockView(); + subscription = viewController.willLeave.subscribe((event) => { + // assert + expect(event).toEqual(null); + done(); + }, (err: any) => { + done(); + }); + + // act + viewController._willLeave(); + }, 10000); + }); + + describe('didLeave', () => { + it('should emit LifeCycleEvent when called with component data', (done) => { + // arrange + let viewController = mockView(); + subscription = viewController.didLeave.subscribe((event) => { + // assert + expect(event).toEqual(null); + done(); + }, (err: any) => { + done(); + }); + + // act + viewController._didLeave(); + }, 10000); + }); + + describe('willUnload', () => { + it('should emit LifeCycleEvent when called with component data', (done) => { + // arrange + let viewController = mockView(); + subscription = viewController.willUnload.subscribe((event) => { + expect(event).toEqual(null); + done(); + }, (err: any) => { + done(); + }); + + // act + viewController._willUnload(); + }, 10000); + }); + + afterEach(() => { + if (subscription) { + subscription.unsubscribe(); + } + }); + + let subscription: any = null; + +}); diff --git a/src/components/nav/view-controller.ts b/src/navigation/view-controller.ts similarity index 58% rename from src/components/nav/view-controller.ts rename to src/navigation/view-controller.ts index 781c6e2556..0058dbd18d 100644 --- a/src/components/nav/view-controller.ts +++ b/src/navigation/view-controller.ts @@ -1,10 +1,10 @@ -import { ChangeDetectorRef, ElementRef, EventEmitter, Output, Renderer } from '@angular/core'; +import { ComponentRef, ElementRef, EventEmitter, Output, Renderer } from '@angular/core'; -import { Footer, Header } from '../toolbar/toolbar'; -import { isPresent, merge } from '../../util/util'; -import { Navbar } from '../navbar/navbar'; -import { NavController } from './nav-controller'; -import { NavOptions } from './nav-interfaces'; +import { Footer, Header } from '../components/toolbar/toolbar'; +import { isPresent, merge } from '../util/util'; +import { Navbar } from '../components/navbar/navbar'; +import { NavControllerBase } from './nav-controller-base'; +import { NavOptions, ViewState } from './nav-util'; import { NavParams } from './nav-params'; @@ -28,19 +28,14 @@ import { NavParams } from './nav-params'; export class ViewController { private _cntDir: any; private _cntRef: ElementRef; - private _tbRefs: ElementRef[] = []; private _hdrDir: Header; private _ftrDir: Footer; - private _destroyFn: Function; - private _hdAttr: string = null; - private _leavingOpts: NavOptions = null; - private _loaded: boolean = false; - private _nbDir: Navbar; - private _onDidDismiss: Function = null; - private _onWillDismiss: Function = null; - private _pgRef: ElementRef; - private _cd: ChangeDetectorRef; - protected _nav: NavController; + private _hidden: string; + private _leavingOpts: NavOptions; + private _nb: Navbar; + private _onDidDismiss: Function; + private _onWillDismiss: Function; + private _detached: boolean; /** * Observable to be subscribed to when the current component will become active @@ -67,69 +62,75 @@ export class ViewController { didLeave: EventEmitter; /** - * Observable to be subscribed to when the current component will be destroyed + * Observable to be subscribed to when the current component has been destroyed * @returns {Observable} Returns an observable */ willUnload: EventEmitter; - /** - * Observable to be subscribed to when the current component has been destroyed - * @returns {Observable} Returns an observable - */ - didUnload: EventEmitter; - - /** - * @internal - */ + /** @private */ data: any; - /** - * @private - */ + /** @private */ + instance: any; + + /** @private */ id: string; - /** - * @private - */ - instance: any = {}; - - /** - * @private - */ - state: number = 0; - - /** - * @private - * If this is currently the active view, then set to false - * if it does not want the other views to fire their own lifecycles. - */ - fireOtherLifecycles: boolean = true; - - /** - * @private - */ + /** @private */ isOverlay: boolean = false; - /** - * @private - */ - zIndex: number; + /** @private */ + _cmp: ComponentRef; - /** - * @private - */ + /** @private */ + _nav: NavControllerBase; + + /** @private */ + _zIndex: number; + + /** @private */ + _state: ViewState; + + /** @private */ + _cssClass: string; + + /** @private */ @Output() private _emitter: EventEmitter = new EventEmitter(); - constructor(public componentType?: any, data?: any) { + constructor(public component?: any, data?: any, rootCssClass: string = DEFAULT_CSS_CLASS) { // passed in data could be NavParams, but all we care about is its data object this.data = (data instanceof NavParams ? data.data : (isPresent(data) ? data : {})); + this._cssClass = rootCssClass; + this.willEnter = new EventEmitter(); this.didEnter = new EventEmitter(); this.willLeave = new EventEmitter(); this.didLeave = new EventEmitter(); this.willUnload = new EventEmitter(); - this.didUnload = new EventEmitter(); + } + + /** + * @private + */ + init(componentRef: ComponentRef) { + this._cmp = componentRef; + this.instance = this.instance || componentRef.instance; + this._detached = false; + } + + /** + * @private + */ + _setNav(navCtrl: NavControllerBase) { + this._nav = navCtrl; + } + + /** + * @private + */ + _setInstance(instance: any) { + this.instance = instance; } /** @@ -150,28 +151,28 @@ export class ViewController { * @private * onDismiss(..) has been deprecated. Please use onDidDismiss(..) instead */ - private onDismiss(callback: Function) { + onDismiss(callback: Function) { // deprecated warning: added beta.11 2016-06-30 console.warn('onDismiss(..) has been deprecated. Please use onDidDismiss(..) instead'); this.onDidDismiss(callback); } /** - * @private + * onDidDismiss */ onDidDismiss(callback: Function) { this._onDidDismiss = callback; } /** - * @private + * onWillDismiss */ onWillDismiss(callback: Function) { this._onWillDismiss = callback; } /** - * @private + * dismiss */ dismiss(data?: any, role?: any, navOptions: NavOptions = {}) { let options = merge({}, this._leavingOpts, navOptions); @@ -182,13 +183,6 @@ export class ViewController { }); } - /** - * @private - */ - setNav(navCtrl: NavController) { - this._nav = navCtrl; - } - /** * @private */ @@ -233,25 +227,11 @@ export class ViewController { return false; } - /** - * @private - */ - setChangeDetector(cd: ChangeDetectorRef) { - this._cd = cd; - } - - /** - * @private - */ - setInstance(instance: any) { - this.instance = instance; - } - /** * @private */ get name(): string { - return this.componentType ? this.componentType['name'] : ''; + return this.component ? this.component.name : ''; } /** @@ -278,57 +258,68 @@ export class ViewController { /** * @private + * DOM WRITE */ - domShow(shouldShow: boolean, renderer: Renderer) { + _domShow(shouldShow: boolean, renderer: Renderer) { // using hidden element attribute to display:none and not render views - // renderAttr of '' means the hidden attribute will be added - // renderAttr of null means the hidden attribute will be removed - // doing checks to make sure we only make an update to the element when needed - if (this._pgRef && - (shouldShow && this._hdAttr === '' || - !shouldShow && this._hdAttr !== '')) { - - this._hdAttr = (shouldShow ? null : ''); - - renderer.setElementAttribute(this._pgRef.nativeElement, 'hidden', this._hdAttr); + // _hidden value of '' means the hidden attribute will be added + // _hidden value of null means the hidden attribute will be removed + // doing checks to make sure we only update the DOM when actually needed + if (this._cmp) { + // if it should render, then the hidden attribute should not be on the element + if (shouldShow && this._hidden === '' || !shouldShow && this._hidden !== '') { + this._hidden = (shouldShow ? null : ''); + // ******** DOM WRITE **************** + renderer.setElementAttribute(this.pageRef().nativeElement, 'hidden', this._hidden); + } } } /** * @private + * DOM WRITE */ - setZIndex(zIndex: number, renderer: Renderer) { - if (this._pgRef && zIndex !== this.zIndex) { - this.zIndex = zIndex; - renderer.setElementStyle(this._pgRef.nativeElement, 'z-index', zIndex.toString()); + _setZIndex(zIndex: number, renderer: Renderer) { + if (zIndex !== this._zIndex) { + this._zIndex = zIndex; + const pageRef = this.pageRef(); + if (pageRef) { + // ******** DOM WRITE **************** + renderer.setElementStyle(pageRef.nativeElement, 'z-index', (zIndex)); + } } } /** - * @private - */ - setPageRef(elementRef: ElementRef) { - this._pgRef = elementRef; - } - - /** - * @private - * @returns {elementRef} Returns the Page's ElementRef + * @returns {ElementRef} Returns the Page's ElementRef. */ pageRef(): ElementRef { - return this._pgRef; + return this._cmp && this._cmp.location; } /** * @private */ - setContentRef(elementRef: ElementRef) { + _setContent(directive: any) { + this._cntDir = directive; + } + + /** + * @returns {component} Returns the Page's Content component reference. + */ + getContent() { + return this._cntDir; + } + + /** + * @private + */ + _setContentRef(elementRef: ElementRef) { this._cntRef = elementRef; } /** - * @private - * @returns {elementRef} Returns the Page's Content ElementRef + * @returns {ElementRef} Returns the Content's ElementRef. */ contentRef(): ElementRef { return this._cntRef; @@ -337,28 +328,7 @@ export class ViewController { /** * @private */ - setContent(directive: any) { - this._cntDir = directive; - } - - /** - * @private - */ - setToolbarRef(elementRef: ElementRef) { - this._tbRefs.push(elementRef); - } - - /** - * @private - */ - toolbarRefs(): ElementRef[] { - return this._tbRefs; - } - - /** - * @private - */ - setHeader(directive: Header) { + _setHeader(directive: Header) { this._hdrDir = directive; } @@ -372,7 +342,7 @@ export class ViewController { /** * @private */ - setFooter(directive: Footer) { + _setFooter(directive: Footer) { this._ftrDir = directive; } @@ -385,83 +355,26 @@ export class ViewController { /** * @private - * @returns {component} Returns the Page's Content component reference. */ - getContent() { - return this._cntDir; + _setNavbar(directive: Navbar) { + this._nb = directive; } /** * @private */ - setNavbar(directive: Navbar) { - this._nbDir = directive; + getNavbar(): Navbar { + return this._nb; } /** - * @private - */ - getNavbar() { - return this._nbDir; - } - - /** - * * Find out if the current component has a NavBar or not. Be sure * to wrap this in an `ionViewWillEnter` method in order to make sure * the view has rendered fully. * @returns {boolean} Returns a boolean if this Page has a navbar or not. */ hasNavbar(): boolean { - return !!this.getNavbar(); - } - - /** - * @private - */ - navbarRef(): ElementRef { - let navbar = this.getNavbar(); - return navbar && navbar.getElementRef(); - } - - /** - * @private - */ - titleRef(): ElementRef { - let navbar = this.getNavbar(); - return navbar && navbar.getTitleRef(); - } - - /** - * @private - */ - navbarItemRefs(): Array { - let navbar = this.getNavbar(); - return navbar && navbar.getItemRefs(); - } - - /** - * @private - */ - backBtnRef(): ElementRef { - let navbar = this.getNavbar(); - return navbar && navbar.getBackButtonRef(); - } - - /** - * @private - */ - backBtnTextRef(): ElementRef { - let navbar = this.getNavbar(); - return navbar && navbar.getBackButtonTextRef(); - } - - /** - * @private - */ - navbarBgRef(): ElementRef { - let navbar = this.getNavbar(); - return navbar && navbar.getBackgroundRef(); + return !!this._nb; } /** @@ -470,10 +383,7 @@ export class ViewController { * @param {string} backButtonText Set the back button text. */ setBackButtonText(val: string) { - let navbar = this.getNavbar(); - if (navbar) { - navbar.setBackButtonText(val); - } + this._nb && this._nb.setBackButtonText(val); } /** @@ -482,31 +392,11 @@ export class ViewController { * @param {boolean} Set if this Page's back button should show or not. */ showBackButton(shouldShow: boolean) { - let navbar = this.getNavbar(); - if (navbar) { - navbar.hideBackButton = !shouldShow; + if (this._nb) { + this._nb.hideBackButton = !shouldShow; } } - /** - * @private - */ - isLoaded(): boolean { - return this._loaded; - } - - /** - * The loaded method is used to load any dynamic content/components - * into the dom before proceeding with the transition. If a component - * needs dynamic component loading, extending ViewController and - * overriding this method is a good option - * @param {function} done is a callback that must be called when async - * loading/actions are completed - */ - loaded(done: (() => any)) { - done(); - } - /** * @private * The view has loaded. This event only happens once per view being @@ -515,23 +405,31 @@ export class ViewController { * to put your setup code for the view; however, it is not the * recommended method to use when a view becomes active. */ - fireLoaded() { - this._loaded = true; - ctrlFn(this, 'Loaded'); + _didLoad() { + // deprecated warning: added 2016-08-14, beta.12 + if (this.instance && this.instance.ionViewLoaded) { + try { + console.warn('ionViewLoaded() has been deprecated. Please rename to ionViewDidLoad()'); + this.instance.ionViewLoaded(); + } catch (e) { + console.error(this.name + ' iionViewLoaded: ' + e.message); + } + } + + ctrlFn(this, 'DidLoad'); } /** * @private * The view is about to enter and become the active view. */ - fireWillEnter() { - if (this._cd) { + _willEnter() { + if (this._detached && this._cmp) { // ensure this has been re-attached to the change detector - this._cd.reattach(); - - // detect changes before we run any user code - this._cd.detectChanges(); + this._cmp.changeDetectorRef.reattach(); + this._detached = false; } + this.willEnter.emit(null); ctrlFn(this, 'WillEnter'); } @@ -541,9 +439,8 @@ export class ViewController { * The view has fully entered and is now the active view. This * will fire, whether it was the first load or loaded from the cache. */ - fireDidEnter() { - let navbar = this.getNavbar(); - navbar && navbar.didEnter(); + _didEnter() { + this._nb && this._nb.didEnter(); this.didEnter.emit(null); ctrlFn(this, 'DidEnter'); } @@ -552,7 +449,7 @@ export class ViewController { * @private * The view has is about to leave and no longer be the active view. */ - fireWillLeave() { + _willLeave() { this.willLeave.emit(null); ctrlFn(this, 'WillLeave'); } @@ -562,60 +459,87 @@ export class ViewController { * The view has finished leaving and is no longer the active view. This * will fire, whether it is cached or unloaded. */ - fireDidLeave() { + _didLeave() { this.didLeave.emit(null); ctrlFn(this, 'DidLeave'); // when this is not the active page // we no longer need to detect changes - this._cd && this._cd.detach(); + if (!this._detached && this._cmp) { + this._cmp.changeDetectorRef.detach(); + this._detached = true; + } } /** * @private - * The view is about to be destroyed and have its elements removed. */ - fireWillUnload() { + _willUnload() { this.willUnload.emit(null); ctrlFn(this, 'WillUnload'); - } - /** - * @private - */ - onDestroy(destroyFn: Function) { - this._destroyFn = destroyFn; - } - - /** - * @private - */ - destroy() { - this.didUnload.emit(null); - ctrlFn(this, 'DidUnload'); - - this._destroyFn && this._destroyFn(); - this._destroyFn = null; - } - -} - -export interface LifeCycleEvent { - componentType?: any; -} - -function ctrlFn(viewCtrl: ViewController, fnName: string) { - if (viewCtrl.instance) { - // deprecated warning: added 2016-06-01, beta.8 - if (viewCtrl.instance['onPage' + fnName]) { + // deprecated warning: added 2016-08-14, beta.12 + if (this.instance && this.instance.ionViewDidUnload) { + console.warn('ionViewDidUnload() has been deprecated. Please use ionViewWillUnload() instead'); try { - console.warn('onPage' + fnName + '() has been deprecated. Please rename to ionView' + fnName + '()'); - viewCtrl.instance['onPage' + fnName](); + this.instance.ionViewDidUnload(); } catch (e) { - console.error(viewCtrl.name + ' onPage' + fnName + ': ' + e.message); + console.error(this.name + ' ionViewDidUnload: ' + e.message); + } + } + } + + /** + * @private + * DOM WRITE + */ + _destroy(renderer: Renderer) { + if (this._cmp) { + if (renderer) { + // ensure the element is cleaned up for when the view pool reuses this element + // ******** DOM WRITE **************** + renderer.setElementAttribute(this._cmp.location.nativeElement, 'class', null); + renderer.setElementAttribute(this._cmp.location.nativeElement, 'style', null); + } + + // completely destroy this component. boom. + this._cmp.destroy(); + } + + if (this._nav) { + // remove it from the nav + const index = this._nav.indexOf(this); + if (index > -1) { + this._nav._views.splice(index, 1); } } + this._nav = this._cmp = this.instance = this._cntDir = this._cntRef = this._hdrDir = this._ftrDir = this._nb = this._onDidDismiss = this._onWillDismiss = null; + } + + /** + * @private + */ + _lifecycleTest(lifecycle: string): boolean | string | Promise { + let result: any = true; + + if (this.instance && this.instance['ionViewCan' + lifecycle]) { + try { + result = this.instance['ionViewCan' + lifecycle](); + + } catch (e) { + console.error(`${this.name} ionViewCan${lifecycle} error: ${e}`); + result = false; + } + } + return result; + } + +} + + +function ctrlFn(viewCtrl: ViewController, fnName: string) { + if (viewCtrl.instance) { // fire off ionView lifecycle instance method if (viewCtrl.instance['ionView' + fnName]) { try { @@ -626,3 +550,10 @@ function ctrlFn(viewCtrl: ViewController, fnName: string) { } } } + + +export function isViewController(viewCtrl: any) { + return !!(viewCtrl && (viewCtrl)._didLoad && (viewCtrl)._willUnload); +} + +const DEFAULT_CSS_CLASS = 'ion-page';