ionic-angular
Version:
[](https://circleci.com/gh/driftyco/ionic)
894 lines (775 loc) • 29.5 kB
JavaScript
/**
* @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
);
}]);