9

Does anyone have any thoughts on the best way to implement a scrollspy in an angular app? I started out with Twitter Bootstrap's implementation but I am struggling to find a good place to initialize (and more importantly refresh after DOM changes) the plugin.

Basically my controller does an async request for data when it's loaded and can't fully render the DOM untill the request returns. Then when the DOM is fully rendered the scrollspy plugin needs to be refreshed. Right now I have my controller broadcast an event on its scope once the needed data has been received and I have a directive that picks up this event. I don't really like this solution for two reasons

  1. It just feels hacky.

  2. When I refresh the scrollspy plugin after receiving this event it is still too early since the DOM isn't updated untill later in the cycle. I tried evalAsync but in the end I had to use a timeout and just hope the DOM renders fast enough.

I had a look at the source for the Bootstrap plugin and it seems fairly straight forward to implement this from scratch. The problem I was having with that is that when I tried to make a directive for it I couldn't seem to subscribe to scroll events from the element I received in the link function.

Has anyone found a good solution to something like this, or does anyone have any suggestions? I'm in no way tied to using the Bootstrap implementation, as of right now the only dep I have on Bootstrap is the scrollspy-plugin I just added.

Pavlo
  • 43,301
  • 14
  • 77
  • 113
ivarni
  • 17,658
  • 17
  • 76
  • 92
  • This is basically where I stand today: http://stackoverflow.com/questions/14284263/refresh-bootstrap-scrollspy-after-angular-model-changes?rq=1 but TBH I'm not a big fan of that solution – ivarni Jul 04 '13 at 12:37
  • ivarni is right - a directive is the place to go, and `$timeout` should work. In that answer, you could also replace `$scope.$watch` with $attrs.$observe`. – rGil Jul 04 '13 at 19:52
  • But I would rather not rely on a $timeout. What if I suddenly run into a case where for some reason the DOM takes longer to render than the value I've set in my timeout? Then the refresh would run too early. Relying on a timeout is IMHO a hack. It's perhaps not the most horrible one I've seen but still a hack. – ivarni Jul 05 '13 at 07:00
  • 1
    The $timeout method adds another event to the browser - after the dom rendering. It will work even with a time value of 0. It feels a bit hacky because of the naming of the method, or what the method is really intended to be used for. But I think if Browsers had an identical method called `afterRender()` with no param for a delay, we would happily use it. For now, I guess, it's `setTimeout()` or `$timeout` in AngularJs. – rGil Jul 05 '13 at 12:52
  • Ah, so the $timeout is queued up after rendering. That's very useful to know. Thanks :) – ivarni Jul 08 '13 at 09:44

4 Answers4

20

Alexander Hill made a post describing an AngularJS implementation of Bootstrap's ScrollSpy: http://alxhill.com/blog/articles/angular-scrollspy/

I've translated his CoffeeScript code into JavaScript, fixed a few bugs, added a few safety checks and an extra feature for good measure. Here is the code:

app.directive('scrollSpy', function ($window) {
  return {
    restrict: 'A',
    controller: function ($scope) {
      $scope.spies = [];
      this.addSpy = function (spyObj) {
        $scope.spies.push(spyObj);
      };
    },
    link: function (scope, elem, attrs) {
      var spyElems;
      spyElems = [];

      scope.$watch('spies', function (spies) {
        var spy, _i, _len, _results;
        _results = [];

        for (_i = 0, _len = spies.length; _i < _len; _i++) {
          spy = spies[_i];

          if (spyElems[spy.id] == null) {
            _results.push(spyElems[spy.id] = elem.find('#' + spy.id));
          }
        }
        return _results;
      });

      $($window).scroll(function () {
        var highlightSpy, pos, spy, _i, _len, _ref;
        highlightSpy = null;
        _ref = scope.spies;

        // cycle through `spy` elements to find which to highlight
        for (_i = 0, _len = _ref.length; _i < _len; _i++) {
          spy = _ref[_i];
          spy.out();

          // catch case where a `spy` does not have an associated `id` anchor
          if (spyElems[spy.id].offset() === undefined) {
            continue;
          }

          if ((pos = spyElems[spy.id].offset().top) - $window.scrollY <= 0) {
            // the window has been scrolled past the top of a spy element
            spy.pos = pos;

            if (highlightSpy == null) {
              highlightSpy = spy;
            }
            if (highlightSpy.pos < spy.pos) {
              highlightSpy = spy;
            }
          }
        }

        // select the last `spy` if the scrollbar is at the bottom of the page
        if ($(window).scrollTop() + $(window).height() >= $(document).height()) {
          spy.pos = pos;
          highlightSpy = spy;
        }        

        return highlightSpy != null ? highlightSpy["in"]() : void 0;
      });
    }
  };
});

app.directive('spy', function ($location, $anchorScroll) {
  return {
    restrict: "A",
    require: "^scrollSpy",
    link: function(scope, elem, attrs, affix) {
      elem.click(function () {
        $location.hash(attrs.spy);
        $anchorScroll();
      });

      affix.addSpy({
        id: attrs.spy,
        in: function() {
          elem.addClass('active');
        },
        out: function() {
          elem.removeClass('active');
        }
      });
    }
  };
});

An HTML snippet, from Alexander's blog post, showing how to implement the directive:

<div class="row" scroll-spy>
  <div class="col-md-3 sidebar">
    <ul>
        <li spy="overview">Overview</li>
        <li spy="main">Main Content</li>
        <li spy="summary">Summary</li>
        <li spy="links">Other Links</li>
    </ul>
  </div>
  <div class="col-md-9 content">
    <h3 id="overview">Overview</h3>
    <!-- overview goes here -->
    <h3 id="main">Main Body</h3>
    <!-- main content goes here -->
    <h3 id="summary">Summary</h3>
    <!-- summary goes here -->
    <h3 id="links">Other Links</h3>
    <!-- other links go here -->
  </div>
</div>
Nicholas Pappas
  • 10,439
  • 12
  • 54
  • 87
  • 1
    The `elem` returned in the `link` functions are jqLite-wrapped elements and don't have the `find()` and `click()` functions used in this example. ( at least in the version of Angular I'm using – 1.2.15 ) If you're using jQuery just simply wrap them with jQuery and this example will work. `_results.push(spyElems[spy.id] = $(elem).find('#' + spy.id));` – `$(elem).click(function () {` – JDavis Mar 27 '14 at 17:23
  • If you load JQuery before Angular, `find()` and `click()` should be available on `elem` unless something's changed in the newest version. – ivarni Mar 28 '14 at 09:00
  • Awesome! You should add this to gist or git. To someone having problems with top fixed navigation menu: put `id` attribute to empty element which you must add before actual content. Then change `height` style property with appropriate height. Something like this: `` and in styles `.links-anchor { height: 50px; }` – Jan Święcki Apr 13 '14 at 03:26
  • 2
    @JanŚwięcki, you can find the gist of the above code here: https://gist.github.com/EvilClosetMonkey/9194765 – Nicholas Pappas Apr 14 '14 at 15:07
  • I think scope.$watch('spies' should be replaced with scope.$watchCollection('spies' – Rutger van Baren Nov 26 '14 at 15:15
  • Also had to replace elem.find with angular.element – Rutger van Baren Nov 26 '14 at 15:32
12

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

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

This can be done by $watching the element and setting a timeout for the scrollspy('refresh') call.

You also 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 on the anchor elements to prevent navigation and by attaching a scrollTo function to the ng-click.

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

Ryan Kimber
  • 495
  • 1
  • 6
  • 14
  • That looks a lot better than the solution I ended up with, going to give yours a go. Thanks for taking the time to answer an old question! – ivarni Aug 07 '13 at 05:06
  • How is the .active class being set on the
  • ?
  • – Cole Oct 25 '13 at 12:58
  • Cole, it get's added here: https://github.com/twbs/bootstrap/blob/master/js/scrollspy.js#L103 The Angular directives on Plunker are just a way to make the original Bootstrap scrollspy.js work in a dynamic context. – Jure Triglav Mar 31 '14 at 07:11