Reputation: 671
I've got a weird bug that I can't quite figure out. I recently refactored my angular app to be a bit more 'angular-ish'. Part of that was moving a window resize event handler out of a directive and putting it into a .run block where it can broadcast Angular events to the whole application.
That part seems to work fine. It watches the window and events propagate properly. However, when I call my model manipulation code from the $on listener, the databindings don't seem to fire.
.controller('channelListController', function ($scope, $rootScope, $http, $filter){
$http.get('api/v1/channels').success(function(data){
$scope.baseChannels = data;
filterChannelData($scope.filter);
//call manually since initial window resize occurs before async return
splitChannelData($rootScope.windowAttr.columns);
});
//*************
//this part doesn't trigger databinding update
//*************
$scope.$on('columnChange', function(){
//window resize requires changing column number
console.log('change detected');
splitChannelData($rootScope.windowAttr.columns);
});
//*************
//but this does
//*************
$scope.$watch('filter', function(newVal){
//the filters are changed
filterChannelData(newVal);
//re-run split for new data
splitChannelData($rootScope.windowAttr.columns);
}, true);
function filterChannelData(filter){
if(filter){
$scope.filteredChannels = $filter('looseCreatorComparator')($scope.baseChannels, filter.creators);
$scope.filteredChannels = $filter('looseTagComparator')($scope.filteredChannels, filter.tags);
} else {
$scope.filteredChannels = $scope.baseChannels;
}
}
function splitChannelData(columns){
if(columns){
$scope.splitChannels = [];
for(var rep=0;rep<columns;rep++){
$scope.splitChannels.push([]);
}
_.forEach($scope.filteredChannels, function(channel, index){
$scope.splitChannels[index % columns].push(channel);
});
}
}
});
So, when I alter the contents of my filter input fields, the $watch catches that, runs my filters and then fires splitChannelData. The latter refactors my dataset into a set of separate arrays for display. The view is databound to $scope.splitChannels. This works fine.
However, when the broadcast 'columnChange' event is caught by $on, the splitChannelData function fires, properly refactors $scope.splitChannels but the view does not refresh to reflect the change.
Also, if I then subsequently make another change to the filter input fields and trigger the $watch handler, it properly updates the view, including the dataset changes that the $on made but that didn't update to the view earlier.
WUT.
So, can anyone tell me why calling splitDataChannel from $scope.$watch makes the databinding work properly but the same call triggered by $scope.$on doesn't?
EDIT: I just solved the bug but still don't really understand why this solution is necessary. I would still love to get feedback.
I added a $apply call to the $on handler:
$scope.$on('columnChange', function(){
//window resize requires changing column number
console.log('change detected');
splitChannelData($rootScope.windowAttr.columns);
//added this
$scope.$apply();
});
Why does $watch trigger the databinding update while $on does not and the latte requires a manual $apply to be called?
Upvotes: 3
Views: 747
Reputation: 38490
"The boundaries of Angular's world" is not as complex as it sounds.
A bit simplified, Angular's dirty checking works by storing watchers in an array. A watcher has a watchExpression and a listenerFunction. When the digest cycle is triggered the watchers are iterated. If the returned value of a watchExpression has changed since last time, the associated listenerFunction is executed.
For example a watchExpression returns the string hello
, while last time it returned hell
. The listenerFunction is executed to update a specific DOM element's text value with the returned value hello
.
If you change a variable that is watched but don't trigger the digest cycle, the update will be not reflected in the UI.
If the action triggers the digest cycle - You are inside the boundaries.
If the action doesn't trigger the digest cycle - You are outside the boundaries.
Most of the commonly used functionalities in Angular automatically trigger the digest loop internally for you. For example the directive ngClick
and the $http
service.
$broadcast
, $emit
and $on
does not trigger the digest cycle. The problem is not with your handler attached to $on
, but most likely with where $broadcast
is used.
My guess is that it looks something like this:
angular.element($window).on('resize', function () {
$rootScope.$broadcast('columnChange');
});
The on
function is a jqLite/jQuery function. It does not trigger the digest cycle, which means it lives outside the boundaries of Angular's world.
You should wrap it in a call to $apply
:
angular.element($window).on('resize', function() {
$rootScope.$apply(function () {
$rootScope.$broadcast('columnChange');
});
});
The reason not to call $apply
in the handler attached to $on
is because if something both calls $broadcast('columnChange')
and triggers the digest loop you will get the $digest already in progress
error.
Upvotes: 3