文章

DIY two-way binding in AngularJs

AngularJs provides a convenient two way bindings to wire up your model and view. But sometimes you would like to intercept that binding.

I’ve come across a case that I need to prompt for user about change for a dropdown field. When user click yes, it will reveal some extra UI. When user click no, it will revert the dropdown back to its previous.

Here I need:

  1. an value that the extra UI can watch on to reveal the UI for change
  2. an old value to revert to
  3. a way to update the model without trigger change
  4. when the model update from others (not the dropdown), it updates the UI

The solution would be to introduce another model value (“view model”) and try to wire it with the real model.

    +--------+        +----------+
    |dropdown+------->|View model+-------+
    +--------+        +----------+       v
                           ^        +---------+
                           |        |confirm? |
                           |        +----+----+
    +--------+        +----+-----+       |
    |Others  +------->|   model  |<------+
    +--------+        +----------+
                           ^
                           | watch
                      +----+-----+
                      |Extra UI  |
                      +----------+

So here we are the actual dropdown would bind to the view model sView first

<select ng-model="sView" ng-change="confirm()" ...>...</select>

At the controller, the confirm() method would play with the real model s and view model sView

$scope.confirm = (){
    $modal.open(...).result.then(updateModel, updateViewModel);
}

function updateModel(){
    $scope.s = $scope.sView;
}

function updateViewModel(){
    $scope.sView = $scope.s;
}

$scope.$watch('s', function(){
    $scope.sView = $scope.s;
});

In Angular POV, you should wrap this up in a directive so that it is re-usable. The desired html code would look like

<select ng-model="sView" 
  defer-change="confirm()" 
  defer-change-delegate="s" ...>...</select>

So how can we listen to the ngChange event? Take a look at the ngChange directive source code:

var ngChangeDirective = valueFn({
  require: 'ngModel',
  link: function(scope, element, attr, ctrl) {
    ctrl.$viewChangeListeners.push(function() {
      scope.$eval(attr.ngChange);
    });
  }
});

So it just use the $viewChangeListeners, which would be available using ngModelController. Even better, all directive components which use ngModel would have this available. That’s good!

So our little defer-change directive would finally look like:

app.directive("deferChange", function($modal){
    return{
        restrict: 'A',
        require: 'ngModel',
        scope: {
            viewModel: '=ngModel',
            model: '=deferChangeDelegate',
            defer: '&deferChange'
        },
        link: function(scope, el, attrs, ctrl){
            function updateModel(){
                scope.model = ctrl.$modelValue;
            }
            function updateViewModel(){
                scope.viewModel = scope.model;
            }
            ctrl.$viewChangeListeners.push(function(){
                scope.deferChange()
                    .then(updateModel, updateViewModel);
            });
            scope.$watch('model', updateViewModel);  
        }
    }
});

Note that when updating model, we use ctrl.$modelValue instead of scope.viewModel as it is more steadily available when deferring.

Click here for the demo.

*