I'm a new user to AngularJS and ui-router, and I'm trying to turn my head around on how the scope is managed. I was expecting the scope of an active controller would be destroyed when it becomes inactive on state change, however, it doesn't appear to be the case.
I've modified the example from UI-Router's website to illustrate the situation (see plunker below). Every time when the state route1.list/route2.list is triggered, they will emit an event on $rootScope. On receiving the event, a debug statement will be printed to console.
By toggling between the two states a few times, it is observed that all the controllers initialized previously responded the the event. So it appears that the scopes created by them have never been destroyed. Is this behavior expected? If so, what should I do such that only active controllers will respond to an event?
Plunker
Debug message printed on Console:
Code:
var myapp = angular.module('myapp', ["ui.router"])
myapp.config(function($stateProvider, $urlRouterProvider){
// For any unmatched url, send to /route1
$urlRouterProvider.otherwise("/route1")
here is the route1
$stateProvider
.state('route1', {
url: "/route1",
templateUrl: "route1.html"
})
.state('route1.list', {
url: "/list",
templateUrl: "route1.list.html",
controller: function($rootScope, $scope){
$rootScope.$emit("eventCT1");
$rootScope.$on("eventCT2", fn);
function fn () {
console.log("Controller 1 receives an event emitted by Controller 2");
}
$scope.items = ["A", "List", "Of", "Items"];
}
})
and here is route 2
.state('route2', {
url: "/route2",
templateUrl: "route2.html"
})
.state('route2.list', {
url: "/list",
templateUrl: "route2.list.html",
controller: function($rootScope, $scope){
$rootScope.$emit("eventCT2");
$rootScope.$on("eventCT1", fn);
function fn () {
console.log("Controller 2 receives an event emitted by Controller 1");
}
$scope.things = ["A", "Set", "Of", "Things"];
}
})
...
If we want to do something with
1) $rootScope inside of the controller (which has very limited lifetime),
2) we must destroy that, when controller (its $scope in fact) is being destroyed
So, this is the way how to hook and unhook
// get remove function
var removeMe = $rootScope.$on("eventCT2", ...);
// call that function
$scope.$on("$destroy", removeMe)
But, in the case above, we should not even try to
1) create some controller action for one state...
2) and expect it will be called in another controller from different state
These will never live together
If you are using Ionic with Angular, you could use the life cycle events like so:
$scope.$on("$ionicView.beforeEnter", function(){
//Do something every time this controller is the active scope.
})
You could play around with the other events provided in the above link as well. And it's probably best practise to minimize the use of $emit, which will lead to more predictable code and fewer state mutations.
Related
I'm using Angular's UI-Router as shown below to do URL-routing in my web-app as shown below:
$stateProvider.state('myState1', {
url: '/state1',
templateUrl: 'state1.html',
controller: 'MyState1Ctrl'
});
$stateProvider.state('myState2', {
url: '/state2',
templateUrl: 'state2.html',
controller: 'MyState2Ctrl'
});
Within each of the two template files state1.html, state2.html, I have my navigation bar directive: <myapp-navigation-bar></myapp-navigation-bar>. Here is the navigation bar's controller:
raptorApp.controller('NavigationCtrl', function($scope){
var self = this;
$scope.$watch(function() {return "something";}, function (objVal) {
console.log('Hello from NavigationCtrl!');
},true);
});
But I want the navigation bar to behave differently based on weather it is in myState1 or myState2. How can I detect from within NavigationCtrl which state it is in?
we can always place anything into $scope and watch it. Even the $state service and its current.name. Something like (just a draft)
raptorApp.controller('NavigationCtrl', function($scope, $state){
var self = this;
$scope.state = $state;
$scope.$watch("state.current.name", function (objVal) {
console.log('current state name changed');
},true);
});
NOTE: never forget to remove such watch on destroy
But with UI-Router.. there are better ways. Much better ways:
named views
multiple views
So we can have a parent state, with a view called 'myapp-navigation-bar' and that could be filled by each state... with a different implementation. See this for detailed how to and example
Nested states or views for layout with leftbar in ui-router?
How do I load UI-router ui-view templates that are nested 3 states deep?
Given the following state in ui-router:
.state('some.state', {
url: '/some/:viewType',
templateUrl: 'myTemplate.html',
controller: 'SomeStateController',
controllerAs: 'vm',
data: {
authorizedFor: [SOME_ROLE]
}
}
I'm trying to use the "data" object for a state to help control access to authorized states. Separately, I handle the $stateChangeStart event to look at data.authorizedFor and act accordingly.
The problem, though, is that the list of authorized roles might change based on the value of :viewType. I thought I could let data:{} be a function, inject $stateParams, and handle the logic there...but that won't do.
So, I tried using the params object instead, but at the $stateChangeStart time, the :viewType is not yet accessible from $state.params or $stateParams.
Stepping through in dev tools, I noticed that $state.transitionTo.arguments is populated, but it seems awfully hacky to go that route.
params: {
authorizedFor: function($state) {
console.log($state.transitionTo.arguments[1].viewType); // has value I need
}
}
Any suggestions?
My suggestion is to use resolve to provide your controller with content or data that is custom to the state. resolve is an optional map of dependencies which should be injected into the controller.
If any of these dependencies are promises, they will be resolved and converted to a value before the controller is instantiated and the $stateChangeSuccess event is fired.
for example:
$stateProvider
.state('profile', {
url: '/profile',
templateUrl: 'profile.html',
resolve:{
'ProfileService': function(ProfileService){
return ProfileService.promise_skillRecommendation_mock;
}
}
})
The profileService code:
var app = angular.module('app').service("ProfileService", function($http){
var myData = null;
var promise_skillRecommendation_mock =
$http.get('Mock/skillRecommendation-mock.json')
.success(function(data){
myData = data;
});
return{
promise_skillRecommendation_mock: promise_skillRecommendation_mock,
get_skillRecommendation: function(){
return myData;
}
};
});
and the controller code which will use this service is:
angular.module('app').controller('ProfileController', function($scope, $http, ProfileService){
$scope.skillRecommendation = ProfileService.get_skillRecommendation();
The object in resolve below must be resolved (via deferred.resolve() if they are a promise) before the controller is instantiated. Notice how each resolve object is injected as a parameter into the controller.
by using this code, the page will be displayed only after that the promise will be resolved.
for more info please view this page: https://github.com/angular-ui/ui-router/wiki
I have a controller that has an event called changeSafe. I want other controllers to listen when this event gets fired
secure.controller('wrapperCtrl', function ($scope, $rootScope, storeFactory, safeFactory) {
$scope.changeSafe = function (safeId) {
$scope.loading = true;
safeFactory.setSafe(safeId).then(function (safeData) {
$scope.safe = safeData;
$scope.loading = false;
$rootScope.$broadcast('changeSafe', safeData);
});
}
});
The first page that loads is called dashboard when I add what is below the page re-draws with $scope.safe as I would expect it to.
secure.controller('dashboardCtrl', function ($scope, $rootScope, storeFactory, safeFactory) {
$scope.$on('changeSafe', function (event, arg) {
bindSafe(arg.safeId);
});
});
I have pretty much the exact same thing on my history Controller
secure.controller('historyCtrl', function($scope, $rootScope, storeFactory, safeFactory) {
$scope.$on('changeSafe', function (event, arg) {
bindHistory(arg.safeId);
});
});
Here is I have in the config section
secure.config(['$routeProvider', function ($routeProvider) {
$routeProvider
.when('/history', {
templateUrl: '/Angular/History/history.html',
controller: 'historyCtrl'
})
.otherwise({
templateUrl: '/Angular/Dashboard/dashboard.html',
controller: 'dashboardCtrl'
});
}]);
Whenever I click the button that is held within wrapperCtrl only the $scope.$on fires in the dashboardCtrl. Can anyone see why the $scope.$on is not being fired from the historyCtrl controller? I'm also unclear why it gets called from dashboardCtrl when I'm not on that view anymore.
When I step through the code, I'm actually seeing the $rootScope.$on('changeSafe') getting called multiple times both on history page and dashboard page. I can't figure out why it is changing views back to the dashboard though
I'm not sure if I have a full grasp of your problem, but my best guess is you are having a load order issue and the event is being broadcasted before the subscription has been initialized.
First off, to save yourself a lot of headaches, it is important to have a solid understanding of Event Handling in AnguarJS:
know the difference between subscribing on $rootScope.$on vs
$scope.$on
know the difference of publishing with $broadcast vs $emit on
$rootScope vs $scope
From the comments it sounds like you might have been using $rootScope.$on, which isn't cleaned up when your controllers are destroyed (same as directives):
var changeSafeListnerUnbindFunction = $rootScope.$on('changeSafe', funciton() { //... });
$scope.$on('$destroy', changeSafeListnerUnbindFunction);
Given your use case, registering listeners on the child scopes will pick up events published from the $rootScope.$broadcast (which communicates top level down to each child scope).
You probably have a load order issue?
I'm making an app using the angular ui router.
When navigating to a particular state, which always has parameters passed to it, I need to be able to change the url and update the parameters without reloading the view.
In my state config I'm using:
reloadOnSearch: false
This works and allows me to update the parameters in the URL without page reload.
However, I need to be notified of these changes on the $stateParams so I can run various methods that will do things with the new data.
Any suggestions?
Thanks, James
This is relevant to me so I did a bit of work.
Here is my state that uses reloadOnSearch but fires off a function each time the URL is updated.
.state('route2.list', {
url: "/list/:listId",
templateUrl: "route2.list.html",
reloadOnSearch: false,
controller: function($scope, $stateParams){
console.log($stateParams);
$scope.listId = $stateParams.listId;
$scope.things = ["A", "Set", "Of", "Things"];
//This will fire off every time you update the URL.
$scope.$on('$locationChangeSuccess', function(event) {
$scope.listId = 22;
console.log("Update");
console.log($location.url());
console.log($stateParams);
});
}
}
Returns in console:
Update
/route2/list/7 (index):70
Object {listId: "8"} (index):71
$locationChangeStart and $locationChangeSuccess should get you where you want to go. Keep in mind that your location functions will always fire off, even on the first load.
Inspiration taken from this SO post and the $location documentation
EDIT 2
According to the Angular docs for ngRoute, $routeParams aren't updated until after the success of the changes. Chances are that ui-route has a similar restriction. Since we're inturrupting the resolution of the state, $stateParams is never updated. What you can do is just emulate how $stateParams are passed in the first place using $urlMatcherFactory.
Like so:
.state('route2.list', {
url: "/list/:listId",
templateUrl: "route2.list.html",
reloadOnSearch: false,
controller: function($scope, $stateParams, $state, $urlMatcherFactory){
console.log($stateParams.listId + " :: " + $location.url());
$scope.listId = $stateParams.listId;
$scope.things = ["A", "Set", "Of", "Things"];
$scope.$on('$locationChangeSuccess', function(event) {
$scope.listId = 22;
//For when you want to dynamically assign arguments for .compile
console.log($state.get($state.current).url); //returns /list/:listId
//plain text for the argument for clarity
var urlMatcher = $urlMatcherFactory.compile("/route2/list/:listId");
//below returns Object {listId: "11" when url is "/list/11"}
console.log(urlMatcher.exec($location.url()));
var matched = urlMatcher.exec($location.url());
//this line will be wrong unless we set $stateParams = matched
console.log("Update " + $stateParams.listId);
console.log($location.url());
//properly updates scope.
$scope.listId = matched.listId;
});
}
})
The scope actually updates, so that's a good thing. The thing to remember is that you're stoppng the normal resolution of everything but setting reloadOnSearch to false so you'll need to handle pretty much everything on your own.
Following on from Romans code, i built a service that returns the state params of of the current state
/**
* Get parameters from URL and return them as an object like in ui-router
*/
eventaApp.service( 'getStateParams', ['$urlMatcherFactory', '$location', '$state', function( $urlMatcherFactory, $location, $state ){
return function() {
var urlMatcher = $urlMatcherFactory.compile($state.current.url);
return urlMatcher.exec($location.url());
}
}]);
I'm not sure if the way I am doing it is correct, any advice would be appreciated.
I have a Restaurant Selector, which the user can select a restaurant from. Then all other child states load up content specific to the restaurant selected. But I need to have a child state selected by default, including a restaurant, which will be either selected based on the users closest location or on cookie data if they have previously selected one. But, i'm not sure how I can redirect to a child state by default without knowing the restaurant id already?
A bastardised version of my code below to illustrate.
app.config(function ($stateProvider, $urlRouterProvider) {
$urlRouterProvider.otherwise("/restaurant/[some id based on cookie info]/our-food"); // if no id is set, then I want to get it from a cookie, but then how do i do the redirect?
$stateProvider
.state('restaurant', {
url: '/restaurant/:restaurantId',
template: "<ui-view/>",
controller: function($state, $stateParams, Data, RestaurantService, $cookieStore) {
if(typeof $stateParams.restaurantId !== 'undefined') {
// get the restaurantID from the cookie
}
}
})
.state('restaurant.our-food', {
url: '/our-food',
templateUrl: function($stateParams) {
return 'templates/food-food.html?restaurantId=' + $stateParams.restaurantId;
},
controller: 'SubNavCtrl'
})
.state('restaurant.glutenfree-vegetarian', {
url: '/glutenfree-vegetarian',
templateUrl: function($stateParams) {
return 'templates/food-vegetarian.html?restaurantId=' + $stateParams.restaurantId;
},
controller: 'SubNavCtrl'
})
An image below to illustrate what is happening on the front end:
www.merrywidowswine.com/ss.jpg
I would create an event that is fired every time you open that specific state.
Check out their doc: https://github.com/angular-ui/ui-router/wiki#onenter-and-onexit-callbacks
So my guess is either something like this
1. onEnter callback to restaurant state (recommended)
$stateProvider.state("contacts", {
template: '<ui-view>',
resolve: ...,
controller: function($scope, title){
},
onEnter: function(){
if(paramNotSet){ $state.go(...)}
}
});
I've never used an event as such myself so you might have to do some gymnastics with resolve, but I believe this is the cleanest, easiest to understand and most maintainable solution.
2 Global onStateChangeStart event
A global event (although this would get fired for every state change)
$rootScope.$on('$stateChangeStart',
function(event, toState, toParams, fromState, fromParams){ ... //check cookie, change state etc ...})
3 In the controller
Alternatively If you want to use the controller like you started doing.
controller: ['$state', '$stateParams', 'Data', 'RestaurantService', '$cookieStore',
function($state, $stateParams, Data, RestaurantService, $cookieStore) {
if(typeof $stateParams.restaurantId !== 'undefined') {
sate.go('restaurant', $cookieStore['restaurant'])
}
}]
This is probably the fastest solution in terms of development but I believe using events is cleaner and makes for more understandable code.
Note: I haven't actually run any of this code so it might not work, it's just pseudo-code to give you an idea of where to go. Let me know if you run into issues.
EDIT: Acutally I'm not sure if stateParams are passed on to the controller. You might have to use resolve to access them.
EDIT: to access stateParams in onEnter callback or the controller, I believe you have to use resolve as such:
resolve: {
checkParam: ['$state','$stateParams', '$cookieStore', function($state, $stateParams, $cookieStore) {
//logic in here, what it returns can be accessed in callback or controller.
}]
see the ui-router doc on resolve for more examples/better explanation
I needed to get this working, so to add details to the answer from #NicolasMoise:
.state('restaurant', {
url: '/restaurant/:restaurantId',
template: "<ui-view/>",
onEnter: function ($state, $stateParams, $cookies) {
console.log($stateParams.restaurantId);
if (!$stateParams.restaurantId) {
$stateParams.restaurantId = $cookies.restaurantId;
$state.go('restaurant.restaurantID');
}
},
})
I didn't test the cookies portion of this, but the conditional and state change worked.