UNPKG

ionic-angular

Version:

[![Circle CI](https://circleci.com/gh/driftyco/ionic.svg?style=svg)](https://circleci.com/gh/driftyco/ionic)

894 lines (775 loc) 29.5 kB
/** * @ngdoc service * @name $ionicHistory * @module ionic * @description * $ionicHistory keeps track of views as the user navigates through an app. Similar to the way a * browser behaves, an Ionic app is able to keep track of the previous view, the current view, and * the forward view (if there is one). However, a typical web browser only keeps track of one * history stack in a linear fashion. * * Unlike a traditional browser environment, apps and webapps have parallel independent histories, * such as with tabs. Should a user navigate few pages deep on one tab, and then switch to a new * tab and back, the back button relates not to the previous tab, but to the previous pages * visited within _that_ tab. * * `$ionicHistory` facilitates this parallel history architecture. */ IonicModule .factory('$ionicHistory', [ '$rootScope', '$state', '$location', '$window', '$timeout', '$ionicViewSwitcher', '$ionicNavViewDelegate', function($rootScope, $state, $location, $window, $timeout, $ionicViewSwitcher, $ionicNavViewDelegate) { // history actions while navigating views var ACTION_INITIAL_VIEW = 'initialView'; var ACTION_NEW_VIEW = 'newView'; var ACTION_MOVE_BACK = 'moveBack'; var ACTION_MOVE_FORWARD = 'moveForward'; // direction of navigation var DIRECTION_BACK = 'back'; var DIRECTION_FORWARD = 'forward'; var DIRECTION_ENTER = 'enter'; var DIRECTION_EXIT = 'exit'; var DIRECTION_SWAP = 'swap'; var DIRECTION_NONE = 'none'; var stateChangeCounter = 0; var lastStateId, nextViewOptions, deregisterStateChangeListener, nextViewExpireTimer, forcedNav; var viewHistory = { histories: { root: { historyId: 'root', parentHistoryId: null, stack: [], cursor: -1 } }, views: {}, backView: null, forwardView: null, currentView: null }; var View = function() {}; View.prototype.initialize = function(data) { if (data) { for (var name in data) this[name] = data[name]; return this; } return null; }; View.prototype.go = function() { if (this.stateName) { return $state.go(this.stateName, this.stateParams); } if (this.url && this.url !== $location.url()) { if (viewHistory.backView === this) { return $window.history.go(-1); } else if (viewHistory.forwardView === this) { return $window.history.go(1); } $location.url(this.url); } return null; }; View.prototype.destroy = function() { if (this.scope) { this.scope.$destroy && this.scope.$destroy(); this.scope = null; } }; function getViewById(viewId) { return (viewId ? viewHistory.views[ viewId ] : null); } function getBackView(view) { return (view ? getViewById(view.backViewId) : null); } function getForwardView(view) { return (view ? getViewById(view.forwardViewId) : null); } function getHistoryById(historyId) { return (historyId ? viewHistory.histories[ historyId ] : null); } function getHistory(scope) { var histObj = getParentHistoryObj(scope); if (!viewHistory.histories[ histObj.historyId ]) { // this history object exists in parent scope, but doesn't // exist in the history data yet viewHistory.histories[ histObj.historyId ] = { historyId: histObj.historyId, parentHistoryId: getParentHistoryObj(histObj.scope.$parent).historyId, stack: [], cursor: -1 }; } return getHistoryById(histObj.historyId); } function getParentHistoryObj(scope) { var parentScope = scope; while (parentScope) { if (parentScope.hasOwnProperty('$historyId')) { // this parent scope has a historyId return { historyId: parentScope.$historyId, scope: parentScope }; } // nothing found keep climbing up parentScope = parentScope.$parent; } // no history for the parent, use the root return { historyId: 'root', scope: $rootScope }; } function setNavViews(viewId) { viewHistory.currentView = getViewById(viewId); viewHistory.backView = getBackView(viewHistory.currentView); viewHistory.forwardView = getForwardView(viewHistory.currentView); } function getCurrentStateId() { var id; if ($state && $state.current && $state.current.name) { id = $state.current.name; if ($state.params) { for (var key in $state.params) { if ($state.params.hasOwnProperty(key) && $state.params[key]) { id += "_" + key + "=" + $state.params[key]; } } } return id; } // if something goes wrong make sure its got a unique stateId return ionic.Utils.nextUid(); } function getCurrentStateParams() { var rtn; if ($state && $state.params) { for (var key in $state.params) { if ($state.params.hasOwnProperty(key)) { rtn = rtn || {}; rtn[key] = $state.params[key]; } } } return rtn; } return { register: function(parentScope, viewLocals) { var currentStateId = getCurrentStateId(), hist = getHistory(parentScope), currentView = viewHistory.currentView, backView = viewHistory.backView, forwardView = viewHistory.forwardView, viewId = null, action = null, direction = DIRECTION_NONE, historyId = hist.historyId, url = $location.url(), tmp, x, ele; if (lastStateId !== currentStateId) { lastStateId = currentStateId; stateChangeCounter++; } if (forcedNav) { // we've previously set exactly what to do viewId = forcedNav.viewId; action = forcedNav.action; direction = forcedNav.direction; forcedNav = null; } else if (backView && backView.stateId === currentStateId) { // they went back one, set the old current view as a forward view viewId = backView.viewId; historyId = backView.historyId; action = ACTION_MOVE_BACK; if (backView.historyId === currentView.historyId) { // went back in the same history direction = DIRECTION_BACK; } else if (currentView) { direction = DIRECTION_EXIT; tmp = getHistoryById(backView.historyId); if (tmp && tmp.parentHistoryId === currentView.historyId) { direction = DIRECTION_ENTER; } else { tmp = getHistoryById(currentView.historyId); if (tmp && tmp.parentHistoryId === hist.parentHistoryId) { direction = DIRECTION_SWAP; } } } } else if (forwardView && forwardView.stateId === currentStateId) { // they went to the forward one, set the forward view to no longer a forward view viewId = forwardView.viewId; historyId = forwardView.historyId; action = ACTION_MOVE_FORWARD; if (forwardView.historyId === currentView.historyId) { direction = DIRECTION_FORWARD; } else if (currentView) { direction = DIRECTION_EXIT; if (currentView.historyId === hist.parentHistoryId) { direction = DIRECTION_ENTER; } else { tmp = getHistoryById(currentView.historyId); if (tmp && tmp.parentHistoryId === hist.parentHistoryId) { direction = DIRECTION_SWAP; } } } tmp = getParentHistoryObj(parentScope); if (forwardView.historyId && tmp.scope) { // if a history has already been created by the forward view then make sure it stays the same tmp.scope.$historyId = forwardView.historyId; historyId = forwardView.historyId; } } else if (currentView && currentView.historyId !== historyId && hist.cursor > -1 && hist.stack.length > 0 && hist.cursor < hist.stack.length && hist.stack[hist.cursor].stateId === currentStateId) { // they just changed to a different history and the history already has views in it var switchToView = hist.stack[hist.cursor]; viewId = switchToView.viewId; historyId = switchToView.historyId; action = ACTION_MOVE_BACK; direction = DIRECTION_SWAP; tmp = getHistoryById(currentView.historyId); if (tmp && tmp.parentHistoryId === historyId) { direction = DIRECTION_EXIT; } else { tmp = getHistoryById(historyId); if (tmp && tmp.parentHistoryId === currentView.historyId) { direction = DIRECTION_ENTER; } } // if switching to a different history, and the history of the view we're switching // to has an existing back view from a different history than itself, then // it's back view would be better represented using the current view as its back view tmp = getViewById(switchToView.backViewId); if (tmp && switchToView.historyId !== tmp.historyId) { // the new view is being removed from it's old position in the history and being placed at the top, // so we need to update any views that reference it as a backview, otherwise there will be infinitely loops var viewIds = Object.keys(viewHistory.views); viewIds.forEach(function(viewId) { var view = viewHistory.views[viewId]; if ( view.backViewId === switchToView.viewId ) { view.backViewId = null; } }); hist.stack[hist.cursor].backViewId = currentView.viewId; } } else { // create an element from the viewLocals template ele = $ionicViewSwitcher.createViewEle(viewLocals); if (this.isAbstractEle(ele, viewLocals)) { return { action: 'abstractView', direction: DIRECTION_NONE, ele: ele }; } // set a new unique viewId viewId = ionic.Utils.nextUid(); if (currentView) { // set the forward view if there is a current view (ie: if its not the first view) currentView.forwardViewId = viewId; action = ACTION_NEW_VIEW; // check if there is a new forward view within the same history if (forwardView && currentView.stateId !== forwardView.stateId && currentView.historyId === forwardView.historyId) { // they navigated to a new view but the stack already has a forward view // since its a new view remove any forwards that existed tmp = getHistoryById(forwardView.historyId); if (tmp) { // the forward has a history for (x = tmp.stack.length - 1; x >= forwardView.index; x--) { // starting from the end destroy all forwards in this history from this point var stackItem = tmp.stack[x]; stackItem && stackItem.destroy && stackItem.destroy(); tmp.stack.splice(x); } historyId = forwardView.historyId; } } // its only moving forward if its in the same history if (hist.historyId === currentView.historyId) { direction = DIRECTION_FORWARD; } else if (currentView.historyId !== hist.historyId) { // DB: this is a new view in a different tab direction = DIRECTION_ENTER; tmp = getHistoryById(currentView.historyId); if (tmp && tmp.parentHistoryId === hist.parentHistoryId) { direction = DIRECTION_SWAP; } else { tmp = getHistoryById(tmp.parentHistoryId); if (tmp && tmp.historyId === hist.historyId) { direction = DIRECTION_EXIT; } } } } else { // there's no current view, so this must be the initial view action = ACTION_INITIAL_VIEW; } if (stateChangeCounter < 2) { // views that were spun up on the first load should not animate direction = DIRECTION_NONE; } // add the new view viewHistory.views[viewId] = this.createView({ viewId: viewId, index: hist.stack.length, historyId: hist.historyId, backViewId: (currentView && currentView.viewId ? currentView.viewId : null), forwardViewId: null, stateId: currentStateId, stateName: this.currentStateName(), stateParams: getCurrentStateParams(), url: url, canSwipeBack: canSwipeBack(ele, viewLocals) }); // add the new view to this history's stack hist.stack.push(viewHistory.views[viewId]); } deregisterStateChangeListener && deregisterStateChangeListener(); $timeout.cancel(nextViewExpireTimer); if (nextViewOptions) { if (nextViewOptions.disableAnimate) direction = DIRECTION_NONE; if (nextViewOptions.disableBack) viewHistory.views[viewId].backViewId = null; if (nextViewOptions.historyRoot) { for (x = 0; x < hist.stack.length; x++) { if (hist.stack[x].viewId === viewId) { hist.stack[x].index = 0; hist.stack[x].backViewId = hist.stack[x].forwardViewId = null; } else { delete viewHistory.views[hist.stack[x].viewId]; } } hist.stack = [viewHistory.views[viewId]]; } nextViewOptions = null; } setNavViews(viewId); if (viewHistory.backView && historyId == viewHistory.backView.historyId && currentStateId == viewHistory.backView.stateId && url == viewHistory.backView.url) { for (x = 0; x < hist.stack.length; x++) { if (hist.stack[x].viewId == viewId) { action = 'dupNav'; direction = DIRECTION_NONE; if (x > 0) { hist.stack[x - 1].forwardViewId = null; } viewHistory.forwardView = null; viewHistory.currentView.index = viewHistory.backView.index; viewHistory.currentView.backViewId = viewHistory.backView.backViewId; viewHistory.backView = getBackView(viewHistory.backView); hist.stack.splice(x, 1); break; } } } hist.cursor = viewHistory.currentView.index; return { viewId: viewId, action: action, direction: direction, historyId: historyId, enableBack: this.enabledBack(viewHistory.currentView), isHistoryRoot: (viewHistory.currentView.index === 0), ele: ele }; }, registerHistory: function(scope) { scope.$historyId = ionic.Utils.nextUid(); }, createView: function(data) { var newView = new View(); return newView.initialize(data); }, getViewById: getViewById, /** * @ngdoc method * @name $ionicHistory#viewHistory * @description The app's view history data, such as all the views and histories, along * with how they are ordered and linked together within the navigation stack. * @returns {object} Returns an object containing the apps view history data. */ viewHistory: function() { return viewHistory; }, /** * @ngdoc method * @name $ionicHistory#currentView * @description The app's current view. * @returns {object} Returns the current view. */ currentView: function(view) { if (arguments.length) { viewHistory.currentView = view; } return viewHistory.currentView; }, /** * @ngdoc method * @name $ionicHistory#currentHistoryId * @description The ID of the history stack which is the parent container of the current view. * @returns {string} Returns the current history ID. */ currentHistoryId: function() { return viewHistory.currentView ? viewHistory.currentView.historyId : null; }, /** * @ngdoc method * @name $ionicHistory#currentTitle * @description Gets and sets the current view's title. * @param {string=} val The title to update the current view with. * @returns {string} Returns the current view's title. */ currentTitle: function(val) { if (viewHistory.currentView) { if (arguments.length) { viewHistory.currentView.title = val; } return viewHistory.currentView.title; } }, /** * @ngdoc method * @name $ionicHistory#backView * @description Returns the view that was before the current view in the history stack. * If the user navigated from View A to View B, then View A would be the back view, and * View B would be the current view. * @returns {object} Returns the back view. */ backView: function(view) { if (arguments.length) { viewHistory.backView = view; } return viewHistory.backView; }, /** * @ngdoc method * @name $ionicHistory#backTitle * @description Gets the back view's title. * @returns {string} Returns the back view's title. */ backTitle: function(view) { var backView = (view && getViewById(view.backViewId)) || viewHistory.backView; return backView && backView.title; }, /** * @ngdoc method * @name $ionicHistory#forwardView * @description Returns the view that was in front of the current view in the history stack. * A forward view would exist if the user navigated from View A to View B, then * navigated back to View A. At this point then View B would be the forward view, and View * A would be the current view. * @returns {object} Returns the forward view. */ forwardView: function(view) { if (arguments.length) { viewHistory.forwardView = view; } return viewHistory.forwardView; }, /** * @ngdoc method * @name $ionicHistory#currentStateName * @description Returns the current state name. * @returns {string} */ currentStateName: function() { return ($state && $state.current ? $state.current.name : null); }, isCurrentStateNavView: function(navView) { return !!($state && $state.current && $state.current.views && $state.current.views[navView]); }, goToHistoryRoot: function(historyId) { if (historyId) { var hist = getHistoryById(historyId); if (hist && hist.stack.length) { if (viewHistory.currentView && viewHistory.currentView.viewId === hist.stack[0].viewId) { return; } forcedNav = { viewId: hist.stack[0].viewId, action: ACTION_MOVE_BACK, direction: DIRECTION_BACK }; hist.stack[0].go(); } } }, /** * @ngdoc method * @name $ionicHistory#goBack * @param {number=} backCount Optional negative integer setting how many views to go * back. By default it'll go back one view by using the value `-1`. To go back two * views you would use `-2`. If the number goes farther back than the number of views * in the current history's stack then it'll go to the first view in the current history's * stack. If the number is zero or greater then it'll do nothing. It also does not * cross history stacks, meaning it can only go as far back as the current history. * @description Navigates the app to the back view, if a back view exists. */ goBack: function(backCount) { if (isDefined(backCount) && backCount !== -1) { if (backCount > -1) return; var currentHistory = viewHistory.histories[this.currentHistoryId()]; var newCursor = currentHistory.cursor + backCount + 1; if (newCursor < 1) { newCursor = 1; } currentHistory.cursor = newCursor; setNavViews(currentHistory.stack[newCursor].viewId); var cursor = newCursor - 1; var clearStateIds = []; var fwdView = getViewById(currentHistory.stack[cursor].forwardViewId); while (fwdView) { clearStateIds.push(fwdView.stateId || fwdView.viewId); cursor++; if (cursor >= currentHistory.stack.length) break; fwdView = getViewById(currentHistory.stack[cursor].forwardViewId); } var self = this; if (clearStateIds.length) { $timeout(function() { self.clearCache(clearStateIds); }, 300); } } viewHistory.backView && viewHistory.backView.go(); }, /** * @ngdoc method * @name $ionicHistory#removeBackView * @description Remove the previous view from the history completely, including the * cached element and scope (if they exist). */ removeBackView: function() { var self = this; var currentHistory = viewHistory.histories[this.currentHistoryId()]; var currentCursor = currentHistory.cursor; var currentView = currentHistory.stack[currentCursor]; var backView = currentHistory.stack[currentCursor - 1]; var replacementView = currentHistory.stack[currentCursor - 2]; // fail if we dont have enough views in the history if (!backView || !replacementView) { return; } // remove the old backView and the cached element/scope currentHistory.stack.splice(currentCursor - 1, 1); self.clearCache([backView.viewId]); // make the replacementView and currentView point to each other (bypass the old backView) currentView.backViewId = replacementView.viewId; currentView.index = currentView.index - 1; replacementView.forwardViewId = currentView.viewId; // update the cursor and set new backView viewHistory.backView = replacementView; currentHistory.currentCursor += -1; }, enabledBack: function(view) { var backView = getBackView(view); return !!(backView && backView.historyId === view.historyId); }, /** * @ngdoc method * @name $ionicHistory#clearHistory * @description Clears out the app's entire history, except for the current view. */ clearHistory: function() { var histories = viewHistory.histories, currentView = viewHistory.currentView; if (histories) { for (var historyId in histories) { if (histories[historyId].stack) { histories[historyId].stack = []; histories[historyId].cursor = -1; } if (currentView && currentView.historyId === historyId) { currentView.backViewId = currentView.forwardViewId = null; histories[historyId].stack.push(currentView); } else if (histories[historyId].destroy) { histories[historyId].destroy(); } } } for (var viewId in viewHistory.views) { if (viewId !== currentView.viewId) { delete viewHistory.views[viewId]; } } if (currentView) { setNavViews(currentView.viewId); } }, /** * @ngdoc method * @name $ionicHistory#clearCache * @return promise * @description Removes all cached views within every {@link ionic.directive:ionNavView}. * This both removes the view element from the DOM, and destroy it's scope. */ clearCache: function(stateIds) { return $timeout(function() { $ionicNavViewDelegate._instances.forEach(function(instance) { instance.clearCache(stateIds); }); }); }, /** * @ngdoc method * @name $ionicHistory#nextViewOptions * @description Sets options for the next view. This method can be useful to override * certain view/transition defaults right before a view transition happens. For example, * the {@link ionic.directive:menuClose} directive uses this method internally to ensure * an animated view transition does not happen when a side menu is open, and also sets * the next view as the root of its history stack. After the transition these options * are set back to null. * * Available options: * * * `disableAnimate`: Do not animate the next transition. * * `disableBack`: The next view should forget its back view, and set it to null. * * `historyRoot`: The next view should become the root view in its history stack. * * ```js * $ionicHistory.nextViewOptions({ * disableAnimate: true, * disableBack: true * }); * ``` */ nextViewOptions: function(opts) { deregisterStateChangeListener && deregisterStateChangeListener(); if (arguments.length) { $timeout.cancel(nextViewExpireTimer); if (opts === null) { nextViewOptions = opts; } else { nextViewOptions = nextViewOptions || {}; extend(nextViewOptions, opts); if (nextViewOptions.expire) { deregisterStateChangeListener = $rootScope.$on('$stateChangeSuccess', function() { nextViewExpireTimer = $timeout(function() { nextViewOptions = null; }, nextViewOptions.expire); }); } } } return nextViewOptions; }, isAbstractEle: function(ele, viewLocals) { if (viewLocals && viewLocals.$$state && viewLocals.$$state.self['abstract']) { return true; } return !!(ele && (isAbstractTag(ele) || isAbstractTag(ele.children()))); }, isActiveScope: function(scope) { if (!scope) return false; var climbScope = scope; var currentHistoryId = this.currentHistoryId(); var foundHistoryId; while (climbScope) { if (climbScope.$$disconnected) { return false; } if (!foundHistoryId && climbScope.hasOwnProperty('$historyId')) { foundHistoryId = true; } if (currentHistoryId) { if (climbScope.hasOwnProperty('$historyId') && currentHistoryId == climbScope.$historyId) { return true; } if (climbScope.hasOwnProperty('$activeHistoryId')) { if (currentHistoryId == climbScope.$activeHistoryId) { if (climbScope.hasOwnProperty('$historyId')) { return true; } if (!foundHistoryId) { return true; } } } } if (foundHistoryId && climbScope.hasOwnProperty('$activeHistoryId')) { foundHistoryId = false; } climbScope = climbScope.$parent; } return currentHistoryId ? currentHistoryId == 'root' : true; } }; function isAbstractTag(ele) { return ele && ele.length && /ion-side-menus|ion-tabs/i.test(ele[0].tagName); } function canSwipeBack(ele, viewLocals) { if (viewLocals && viewLocals.$$state && viewLocals.$$state.self.canSwipeBack === false) { return false; } if (ele && ele.attr('can-swipe-back') === 'false') { return false; } var eleChild = ele.find('ion-view'); if (eleChild && eleChild.attr('can-swipe-back') === 'false') { return false; } return true; } }]) .run([ '$rootScope', '$state', '$location', '$document', '$ionicPlatform', '$ionicHistory', 'IONIC_BACK_PRIORITY', function($rootScope, $state, $location, $document, $ionicPlatform, $ionicHistory, IONIC_BACK_PRIORITY) { // always reset the keyboard state when change stage $rootScope.$on('$ionicView.beforeEnter', function() { ionic.keyboard && ionic.keyboard.hide && ionic.keyboard.hide(); }); $rootScope.$on('$ionicHistory.change', function(e, data) { if (!data) return null; var viewHistory = $ionicHistory.viewHistory(); var hist = (data.historyId ? viewHistory.histories[ data.historyId ] : null); if (hist && hist.cursor > -1 && hist.cursor < hist.stack.length) { // the history they're going to already exists // go to it's last view in its stack var view = hist.stack[ hist.cursor ]; return view.go(data); } // this history does not have a URL, but it does have a uiSref // figure out its URL from the uiSref if (!data.url && data.uiSref) { data.url = $state.href(data.uiSref); } if (data.url) { // don't let it start with a #, messes with $location.url() if (data.url.indexOf('#') === 0) { data.url = data.url.replace('#', ''); } if (data.url !== $location.url()) { // we've got a good URL, ready GO! $location.url(data.url); } } }); $rootScope.$ionicGoBack = function(backCount) { $ionicHistory.goBack(backCount); }; // Set the document title when a new view is shown $rootScope.$on('$ionicView.afterEnter', function(ev, data) { if (data && data.title) { $document[0].title = data.title; } }); // Triggered when devices with a hardware back button (Android) is clicked by the user // This is a Cordova/Phonegap platform specifc method function onHardwareBackButton(e) { var backView = $ionicHistory.backView(); if (backView) { // there is a back view, go to it backView.go(); } else { // there is no back view, so close the app instead ionic.Platform.exitApp(); } e.preventDefault(); return false; } $ionicPlatform.registerBackButtonAction( onHardwareBackButton, IONIC_BACK_PRIORITY.view ); }]);