54

I have a directive with a template like

<div>
    <div ng-repeat="item in items" ng-click="updateModel(item)">
<div>

My directive is declared as:

return {
    templateUrl: '...',
    restrict: 'E',
    require: '^ngModel',
    scope: {
        items: '=',
        ngModel: '=',
        ngChange: '&'
    },
    link: function postLink(scope, element, attrs) 
    {
        scope.updateModel = function(item)
        {
             scope.ngModel = item;
             scope.ngChange();
        }
    }
}

I would like to have ng-change called when an item is clicked and the value of foo has been changed already.

That is, if my directive is implemented as:

<my-directive items=items ng-model="foo" ng-change="bar(foo)"></my-directive>

I would expect to call bar when the value of foo has been updated.

With code given above, ngChange is successfully called, but it is called with the old value of foo instead of the new updated value.

One way to solve the problem is to call ngChange inside a timeout to execute it at some point in the future, when the value of foo has been already changed. But this solution make me loose control over the order in which things are supposed to be executed and I assume that there should be a more elegant solution.

I could also use a watcher over foo in the parent scope, but this solution doesn't really give an ngChange method to be implmented and I have been told that watchers are great memory consumers.

Is there a way to make ngChange be executed synchronously without a timeout or a watcher?

Example: http://plnkr.co/edit/8H6QDO8OYiOyOx8efhyJ?p=preview

simeg
  • 1,889
  • 2
  • 26
  • 34
htellez
  • 1,269
  • 2
  • 13
  • 25

5 Answers5

61

If you require ngModel you can just call $setViewValue on the ngModelController, which implicitly evaluates ng-change. The fourth parameter to the linking function should be the ngModelCtrl. The following code will make ng-change work for your directive.

link : function(scope, element, attrs, ngModelCtrl){
    scope.updateModel = function(item) {
        ngModelCtrl.$setViewValue(item);
    }
}

In order for your solution to work, please remove ngChange and ngModel from isolate scope of myDirective.

Here's a plunk: http://plnkr.co/edit/UefUzOo88MwOMkpgeX07?p=preview

Samuli Ulmanen
  • 2,841
  • 1
  • 16
  • 10
  • 2
    Is there anyway to do this without removing the ngModel from the isolated scope? Trying to implement two-way binding with ngChange and it has become convoluted/inefficient https://stackoverflow.com/questions/30575973/angularjs-directive-with-two-way-binding-and-ng-change – JonoCoetzee Jun 02 '15 at 08:37
  • Despite solving the problem, this answer covers up the real issue. The custom directive in the question will not work with many of the common ng-* directives because it doesn't register that the view value has been updated. See @lucienBertin's answer for more detail. – Ed_ Mar 11 '16 at 14:02
  • You're right Ed & @lucienBertin. Indeed the viewChangListeners are not needed. Setting the view value through the ngModelCtrl implicitly evaluates the ng-change expression. Plunk cleaned out a bit. Nice one. – Samuli Ulmanen Mar 17 '16 at 19:43
  • This works but relies on your item being a value type. If you use objects it will only work the first time the reference is set. You will need to clone a ref object before invoking setViewValue in order for ngChange to trigger in these cases (see my answer below). – Arkiliknam Jul 04 '16 at 14:00
15

tl;dr

In my experience you just need to inherit from the ngModelCtrl. the ng-change expression will be automatically evaluated when you use the method ngModelCtrl.$setViewValue

angular.module("myApp").directive("myDirective", function(){
  return {
    require:"^ngModel", // this is important, 
    scope:{
      ... // put the variables you need here but DO NOT have a variable named ngModel or ngChange 
    }, 
    link: function(scope, elt, attrs, ctrl){ // ctrl here is the ngModelCtrl
      scope.setValue = function(value){
        ctrl.$setViewValue(value); // this line will automatically eval your ng-change
      };
    }
  };
});

More precisely

ng-change is evaluated during the ngModelCtrl.$commitViewValue() IF the object reference of your ngModel has changed. the method $commitViewValue() is called automatically by $setViewValue(value, trigger) if you do not use the trigger argument or have not precised any ngModelOptions.

I specified that the ng-change would be automatically triggered if the reference of the $viewValue changed. When your ngModel is a string or an int, you don't have to worry about it. If your ngModel is an object and your just changing some of its properties, then $setViewValue will not eval ngChange.

If we take the code example from the start of the post

scope.setValue = function(value){
    ctrl.$setViewValue(value); // this line will automatically evalyour ng-change
};
scope.updateValue = function(prop1Value){
    var vv = ctrl.$viewValue;
    vv.prop1 = prop1Value;
    ctrl.$setViewValue(vv); // this line won't eval the ng-change expression
};
Antoine
  • 3,880
  • 2
  • 26
  • 44
lucienBertin
  • 159
  • 1
  • 3
  • 2
    Great insight. Reading your description, I'd think I could manually call $commitViewValue() to force ngChange (in the case of updating an existing object), but not so. AngularJS documentation suggests "custom controls might also pass objects to this method. In this case, we should make a copy of the object before passing it to $setViewValue.". See https://docs.angularjs.org/api/ng/type/ngModel.NgModelController – Arkiliknam Jul 04 '16 at 13:49
10

After some research, it seems that the best approach is to use $timeout(callback, 0).

It automatically launches a $digest cycle just after the callback is executed.

So, in my case, the solution was to use

$timeout(scope.ngChange, 0);

This way, it doesn't matter what is the signature of your callback, it will be executed just as you defined it in the parent scope.

Here is the plunkr with such changes: http://plnkr.co/edit/9MGptJpSQslk8g8tD2bZ?p=preview

simeg
  • 1,889
  • 2
  • 26
  • 34
htellez
  • 1,269
  • 2
  • 13
  • 25
  • This actually worked great for me, since I already had a $timeout related fn or handling text fields. – Scott Sword Mar 31 '15 at 22:43
  • 3
    Just as an FYI, the reason this works is because `$timeout` automatically wraps the function in a `$scope.$apply`, which causes a $digest cycle to be kicked off. If you don't want this, you pass a third boolean parameter to $timeout instructing it to not do this. – Gautham C. May 15 '15 at 16:53
0

Samuli Ulmanen and lucienBertin's answers nail it, although a bit of further reading in the AngularJS documentation provides further advise on how to handle this (see https://docs.angularjs.org/api/ng/type/ngModel.NgModelController).

Specifically in the cases where you are passing objects to $setViewValue(myObj). AngularJS Documentatation states:

When used with standard inputs, the view value will always be a string (which is in some cases parsed into another type, such as a Date object for input[date].) However, custom controls might also pass objects to this method. In this case, we should make a copy of the object before passing it to $setViewValue. This is because ngModel does not perform a deep watch of objects, it only looks for a change of identity. If you only change the property of the object then ngModel will not realize that the object has changed and will not invoke the $parsers and $validators pipelines. For this reason, you should not change properties of the copy once it has been passed to $setViewValue. Otherwise you may cause the model value on the scope to change incorrectly.

For my specific case, my model is a moment date object, so I must clone the object first before then calling setViewValue. I am lucky here as moment provides a simple clone method: var b = moment(a);

link : function(scope, elements, attrs, ctrl) {
    scope.updateModel = function (value) {
        if (ctrl.$viewValue == value) {
            var copyOfObject = moment(value);
            ctrl.$setViewValue(copyOfObject);
        }
        else
        {
            ctrl.$setViewValue(value);
        }
    };
}
Arkiliknam
  • 1,805
  • 1
  • 19
  • 35
-2

The fundamental issue here is that the underlying model does not get updated until the digest cycle that happens after scope.updateModel has finished executing. If the ngChange function requires details of the update that is being made then those details can be made available explicitly to ngChange, rather than relying on the model updating having been previously applied.

This can be done by providing a map of local variable names to values when calling ngChange. In this scenario, you can mapping the new value of the model to a name which can be referenced in the ng-change expression.

For example:

scope.updateModel = function(item)
{
    scope.ngModel = item;
    scope.ngChange({newValue: item});
}

In the HTML:

<my-directive ng-model="foo" items=items ng-change="bar(newValue)"></my-directive>

See: http://plnkr.co/edit/4CQBEV1S2wFFwKWbWec3?p=preview

chrisg
  • 1,117
  • 8
  • 8
  • 2
    A true _ng-change_ should never know what the arguments are. For this particular case what you propose will be a solution, but what happens if then I try to use a callback _bar(a, b, c, ...)_ with two or more arguments? – htellez Jul 15 '14 at 17:03
  • Any values which the directive wants to expose to be able to use in the `ng-change` expression should be specified when calling `scope.ngChange`. For example: `scope.ngChange({newValue: item, a: 'something', b: 42})`. The expression can then use the exposed values as needed: `ng-change="bar(newValue, a, b)"` – chrisg Jul 16 '14 at 00:13
  • That is exactly what I don't want to happen. As you see, `updateModel` is a function inside the directive. I want the directive to be independent from who is using it. Just like the normal angular `ng-change` directive works. You don't need to redefine the angular `ng-directive` to use it. – htellez Jul 16 '14 at 05:16
  • Sorry Chris, but this would just teach bad habits to anybody reading it, so I have to vote it down. htellez is right. This is tight coupling and isn't reusable. – Suamere Oct 31 '16 at 20:53