6

I am attempting to use the bootstrap scollspy to highlight list items generated by an angular repeater.

The problem I'm running into is that I'm refreshing the scrollspy plugin from an angular controller, before angular has applied the model changes to the view.

What is the angular way to ensure the scrollspy('refresh') call happens after the DOM itself has been updated (not just the angular model)?

Template:

<div class="span2" id="addressList">
    <ul class="nav nav-tabs nav-stacked affix">
       <li ng-repeat="addr in addresses"><a href="#{{addr.id}}">{{addr.id}}</a></li>
    </ul>
</div>

Controller:

$scope.httpSuccessCallback = function (data) 
     $scope.addresses.push(data);
     $('[data-spy="scroll"]').scrollspy('refresh'); //calls $('#addressList .nav > li > a')
 }
Steve Goodman
  • 1,196
  • 10
  • 22
  • This has probably been said 1,000 times. But you don't want to do DOM manipulation from your controller functions. What you want to do is create a directive that handles all of it. Then use a $watch on whatever it is that triggers the update to update it. – Ben Lesh Jan 11 '13 at 18:31
  • I'm not doing any DOM manipulation in my controller. I'm trying to update a jQuery plugin at the appropriate point in the angular lifecycle. Seems like $watch might still be the right mechanism? – Steve Goodman Jan 11 '13 at 18:37
  • 4
    You're selecting an element from the DOM, and binding events, etc... *That's* really what is the no-no I meant by "DOM manipulation". The Angular docs go over this fairly well. You shouldn't be referencing the DOM in your controllers. – Ben Lesh Jan 11 '13 at 18:39
  • 1
    Here, see the section called "Using Controllers Correctly": http://docs.angularjs.org/guide/dev_guide.mvc.understanding_controller – Ben Lesh Jan 11 '13 at 18:39

4 Answers4

5

Without knowing anything about scroll spy, here's how you generally want to use a JQuery plugin in Angular:

app.directive('scrollSpy', function (){
   return {
     restrict: 'A',
     link: function(scope, elem, attr) {
         elem.scrollSpy({ /* set up options here */ });

         //watch whatever expression was passed to the
         //scroll-spy attribute, and refresh scroll spy when it changes.
         scope.$watch(attr.scrollSpy, function(value) {
              elem.scrollSpy('refresh');
         });
     }
   };
});

Then in HTML:

<div scroll-spy="foo">Do something with: {{foo}}</div>

The above example is VERY generic, but it will basically apply your plugin to an element, and call 'refresh' or whatever on it every time $scope.foo changes.

I hope that helps.

Ben Lesh
  • 107,825
  • 47
  • 247
  • 232
  • So when the $watch fires, is it guaranteed that the view has been rendered and DOM manipulation is complete with the object's changes in effect? The DOM must be updated before we can refresh. – randomguy Jan 12 '13 at 23:58
  • 1
    I'm getting unrecognized expression error when doing this. I'm using the model to update the anchor (href="#category{{cat.id}}") and I think the $watch callback fires *before* Angular updates the DOM. The Bootstrap scroll plugin tries to use the #category{{cat.id}}, which hasn't resolved yet, giving the error. – randomguy Jan 13 '13 at 00:59
  • The angular docs mention a pre and post-linking function available from the compile() function of a directive declaration. Post-link is supposed to be safe for DOM work. I'm not sure exactly how to use it yet. http://docs.angularjs.org/guide/directive – Steve Goodman Jan 14 '13 at 16:47
  • 1
    Sure, see "Writing directives (long version)" [in the documentation here](http://docs.angularjs.org/guide/directive). – Ben Lesh Jan 14 '13 at 17:53
  • @blesh - in my example, $scope.addresses is an array I push values into from a text input. In the directive, my scope.$watch('addresses', ...) call never fires when I modify $scope.addresses. When I changed my code to scope.$watch('myTextInput',...), it fires when I modify the text input. Is this an implicit limitation of the $watch function? – Steve Goodman Jan 14 '13 at 17:53
  • Well, I'd need to see more of your code, because it sounds like you're modifying addresses in a different scope than where you're setting the watch. – Ben Lesh Jan 14 '13 at 17:55
  • so much fail these days :-/ trying to get this jsfiddle going: http://jsfiddle.net/KPu9U/2/ – Steve Goodman Jan 14 '13 at 18:14
  • [Try this](http://plnkr.co/edit/rjqccpwYQrw66q76Tr97) I put it out on plunker. JSFiddle tries to do too much and it's hard to get Angular working on it sometimes. I fixed any bugs I saw, namely that you defined: `$scope.address = []`; but were pushing on `$scope.addresses.push`. Anyhow, I hope that gets you started off. – Ben Lesh Jan 14 '13 at 18:31
  • let us [continue this discussion in chat](http://chat.stackoverflow.com/rooms/22718/discussion-between-steve-goodman-and-blesh) – Steve Goodman Jan 14 '13 at 18:38
4

How I solved this, using Blesh's answer

Template:

  <body ng-app="address" ng-controller="AddressCtrl" scroll-spy="addresses">
    <div class="container">
      <form ng-submit="lookupAddress()">
        <input type="text" ng-model="addressText" />
        <button id="addressQueryBtn">Submit</button>
      </form>

      <div id="addressList">
        <ul>
          <li ng-repeat="addr in addresses">{{addr}}</li>
       </ul>
      </div>

    </div>
  </body>

Angular JS:

angular.module('address', []).
directive('scrollSpy', function($timeout){
  return function(scope, elem, attr) {
    scope.$watch(attr.scrollSpy, function(value) {
      $timeout(function() { elem.scrollspy('refresh') }, 200);
    }, true);
  }
});

function AddressCtrl($scope, $http) {
  $scope.addresses = [];

  $scope.lookupAddress = function() {
    $scope.addresses.push($scope.addressText);
    $scope.addressText = '';
  };
}

The third argument to scope.watch(...) is needed when the watched scope var is an object or array. Unfortunately, this solution still results in the unrecognized expression problem randomguy mentions in the comments. I ultimately resolved this by using a timeout in the watch function.

Community
  • 1
  • 1
Steve Goodman
  • 1,196
  • 10
  • 22
1

There are a few challenges using Scrollspy within Angular (probably why AngularUI still doesn't include Scrollspy as of August 2013).

  1. You must refresh the scrollspy any time you make any changes to the DOM contents.

    This can be done, as has been suggested here, by $watching the element and setting a timeout for the scrollspy('refresh') call.

  2. You need to override the default behaviour of the nav elements if you wish to be able to use the nav elements to navigate the scrollable area.

This can be accomplished by using preventDefault to to prevent navigation and attaching a scrollTo function.

I've thrown a working example up on Plunker: http://plnkr.co/edit/R0a4nJi3tBUBsluBJUo2?p=preview

Ryan Kimber
  • 495
  • 1
  • 6
  • 14
-1

I have been looking for a solution to this problem for a while but I wasn't able to find one. I ended up implementing my own by creating a scroll spy service with a few directives.

The service will keep track of all the spied sections and their offset. The directives will create a scroll event, highlight nav items and tell the service where all the sections are located. Since this implementation doesn't watch a scope variable, the application needs to broadcast a message to update all sections' offsets.

You can find my example on Github.

Adrian
  • 3
  • 2