Skip to content Skip to sidebar Skip to footer

Angularjs $watch With 2 Way Binding

I'm creating a page where there are range sliders that effect eachother depending on what they're set at. I have a watch function that looks like below, that works one way, however

Solution 1:

I figured out three different ways to do this bidirectional binding of computed values. I only wrote the solution for the assets sliders and not the roa sliders, since I assets is a basic sum, and roa is something else all together. (@flashpunk: Please explain, if you need more help with that aspect of your question.) The key issue for why this is difficult is that, in javascript, numbers are not represented in a precise base 10 format; when this loss of imprecision is encountered, angular goes into an infinite loop, recalculating the changes based on the changes caused by the imprecision.

In other words, the problem is that numerical calculations are lossy, these problems would not occur if the calculations were lossless.

Approach 1 (click for plunkr)

This was my first solution. It uses a (fragile) locking mechanism to switch the canonical data source depending on which value was changed. In order to make this solution more robust, I would create an angular module that extends Scope to allow for more advanced and complex watchers. If I run into the problem in the future, I may start a project on github; I'll revise this answer if I do so.

// Both of these functions can be replaced with library functions from lodash or underscore, etc.var sum = function(array) { returnarray.reduce(function(total, next) { return total + (+next); }, 0) };
var closeTo = function(a, b, threshold) { return a == b || Math.abs(a - b) <= threshold; };

$scope.sliders = [
$scope.slide1 = {
  assets: 10,
  roa: 20
}, ... ];

$scope.mainSlide = {
  assets: 0,
  roa: 0
};
// you might want to make or look for a helper function to control this "locking"// in a better fashion, that integrates and resets itself with the digest cyclevar modifiedMainAssets = false;
var modifiedCollectionAssets = false;
$scope.$watch(function() {
      return sum($scope.sliders.map(function(slider) { return slider.assets; }))
    }, function(totalAssets) {
  if (modifiedCollectionAssets) { modifiedCollectionAssets = false; return; }
  $scope.mainSlide.assets = totalAssets;
  modifiedMainAssets = true;
});
$scope.$watch('mainSlide.assets', function(totalAssets, oldTotalAssets) {
  if (modifiedMainAssets) { modifiedMainAssets = false; return; }
  if (oldTotalAssets === null || closeTo(totalAssets, oldTotalAssets, 0.1)) { return; }

  // NOTE: has issues with floating point math. either round & accept the failures,// or use a library that supports a proper IEEE 854 decimal typevar incrementAmount = (totalAssets - oldTotalAssets) / $scope.sliders.length;
  angular.forEach($scope.sliders, function(slider) { slider.assets += incrementAmount; });
  modifiedCollectionAssets = true;
});

Approach 2

In this version, I avoid the clunky locking mechanism, and unify the watchers with what's currently available in angular. I use the individual sliders' values as the canonical data source, and recalculate the total based on how much has changed. Again, the built-in facilities are inadequate for thoroughly expressing the issue, and I need to manually save the oldValues. (The code could be cleaner, but as I didn't anticipate having to perform the aforementioned operation).

$scope.calculateSum = function() {
  return sum($scope.sliders.map(function(slider) { return slider.assets; }));
};
$scope.mainSlide.assets = $scope.calculateSum();
var modifiedMainAssets = false;
var modifiedCollectionAssets = false;
var oldValues = [$scope.mainSlide.assets, $scope.mainSlide.assets];
$scope.$watchCollection('[calculateSum(), mainSlide.assets]'
    , function(newValues) {
  var newSum = newValues[0];
  var oldSum = oldValues[0];
  var newAssets = newValues[1];
  var oldAssets = oldValues[1];
  if (newSum !== oldSum) {
    $scope.mainSlide.assets = newSum;
  }
  elseif (newAssets !== oldAssets) {
    var incrementAmount = (newAssets - oldAssets) / $scope.sliders.length;
    angular.forEach($scope.sliders, function(slider) { slider.assets += incrementAmount; });
    $scope.mainSlide.assets = $scope.calculateSum();
  }
  oldValues = [$scope.mainSlide.assets, $scope.mainSlide.assets];
});

Approach 3: Back to basics

This is what I probably should have advised in the first place. Have one canonical data source, which are the individual sliders. For the main slider, create a custom slider directive that, instead of having an ng-model, fires events that report the changes made. (You can wrap or fork an existing directive to do this.)

$scope.$watch('calculateSum()', function(newSum, oldSum) {
    $scope.mainSlide.assets = newSum;
});
$scope.modifyAll = function(amount) {    
    angular.forEach($scope.sliders, function(slider) { slider.assets += amount; });
};

Revised html:

Assets: <spanng-bind="mainSlide.assets"></span><buttonng-click="modifyAll(1)">Up</button><buttonng-click="modifyAll(-1)">Down</button>

Review

This seems to be a problem that the angular team has yet to address. Event-based data binding systems (what's used in knockout, ember and backbone) would address this by simply not firing the change event on the specific computed change that is lossy; in angular's case, some tweaking needs to be done to the digest cycle. I have no idea if my first two solutions are what would be advised by Misko or another angular guru, but they work.

For now, I would prefer Approach 3. If that doesn't satisfy you, use approach 2, and clean it up; but use a lossless decimal or fraction representation, not the built-in javascript Number type (like this for decimal or this, for fractional). -- https://www.google.com/search?q=javascript+number+libaries

Solution 2:

I believe this is what you want.

$scope.watchList = [$scope.item1, $scope.item2];
$scope.$watchCollection('watchList', function(newValues){...});

That way you can watch all of your slider scope variables in the same watch.

Solution 3:

What's wrong with creating a $watch in both directions? If you have 12 different cases here, I'd suggest building a little factory function to set them up. You just have to make sure that your calculated values stabilize or otherwise you'll have an infinite recursion with each $watch statement triggering the other.

I'm guessing at your dependencies between sliders as a bit, but I'd suggest something like the following:

functionbindSliders() {

    var mainSlider = arguments[0];
    var childSliders = arguments.slice(1);

    angular.forEach(childSliders, function(slider) {

        // Forward Direction$scope.$watch(function() {
            return slider.roa + slider.assets;
        }, function (newValue, oldValue) {
            // A child slider changed// Sum up all the child sliders and update mainSlider
        });

        // Reverse Direction$scope.$watch(function() {
            return mainSlider.assett + mainSlider.roa
        }, function (newValue, oldValue) {
            // Update this `slider` based on the updated main value

        });

    });
}

bindSliders($scope.mainSlide, $scope.slide1, $scope.slide2, $scope.slide3, $scope.slide4);

Post a Comment for "Angularjs $watch With 2 Way Binding"