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
It just feels hacky.
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.
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>
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
I found a project on github that does the trick adding scrollspy, animated scrollTo and scroll events.
https://github.com/oblador/angular-scroll
Here you can check out the live demo and here is the example source
You can use bower to install it
$ bower install angular-scroll
I am using original Bootstrap's scrollspy. Nav links's href are like page is without Anuglar's ngRoute. Then setup ngRoute for that page with option reloadOnSearch: false
For the scrolling I'm using Angular build-in # support - if url looklikes http://host/#/path#anchor
then ngRoute will load the page for the specified '/path' and will scroll to specified 'anchor'.
You don't see it becuase without reloadOnSearch: false page is loaded each time and at the moment of scrolling the element with id like specified in the anchor is not rendered yet. For solving problem of initial scroll to anchor after initial page load and render is written a lot.
Final element of my solution is to write my own directive that is places on each Nav link - it stop click propagation, prevents default action and then set page anchor to correspond to Angular requirements. For example if page URL is http://host/#/path
and clicked link has href="#some-id" my directive will make new URL http://host/#/path#some-id
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With