Create a gist now

Instantly share code, notes, and snippets.

What would you like to do?
An AngularJS directive implementation of Twitter Bootstrap's ScrollSpy, ported and updated from https://gist.github.com/alxhill/6886760.
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');
}
});
}
};
});
rbaggett commented Aug 7, 2014

I am using this directive, and it works great in Chrome. However, It is not working in IE 11. The active class is not correctly assigned, and I am not able to navigate to the first anchor. Debugged it quite a bit, but not seeing anything to indicate why it is failing.

one reason it doesn't work in ie is $window.scrollY. Use pageYOffset instead.

Hi
I got this error trying to implement your code.

Error: [$compile:ctreq] Controller 'scrollSpy', required by directive 'spy', can't be found!

AngularJS docs says that The ^ prefix means that this directive searches for the controller on its parents (without the ^ prefix, the directive would look for the controller on just its own element).
In this case we have controller in scrollSpy directive, still it gives this error. Help please.

morfin commented Feb 11, 2015

Does not work:
Error: [$compile:ctreq] Controller 'scrollSpy', required by directive 'spy', can't be found!

yorch commented May 13, 2015

You are missing the scroll-spy in your markup, it should look like (notice the first line):

<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>

I have found a couple of things that may help people trying to get this code to work for them.

  1. Modify the "click" event handler on the "spy" directive to call scope.$apply().

For example:

elem.click(function () {
   scope.$apply(function () {
      $location.hash(attrs.spy);
    });
});

The call to $apply is in Alxhill's CoffeScript code, so must have accidentally gotten removed when converting to JavaScript.

  1. If you're using Angular routing (ngRoute / ngView), make sure to set "reloadOnSearch" to false so that the view doesn't reload every time $location.hash is called.

I have implemented this angular scrollspy in my single page application - which also uses ng-route and dynamically populates the sidenav based on the h1-h6 elements within #main.

See my code on Plnkr: http://plnkr.co/edit/OXyMEACVW5RDx09UG8LT?p=preview
Because my code creates some spys without id's I've included a condition in the spy directive:

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

Edit: Does anyone have a working version with nested sidenavs which keeps the .active on parent elements with .active children?

in mozilla if i m clicking on a specfic li it assign the active class in the upper li how to resolve this

Big thanks mate!

lkende commented Jul 14, 2016 edited

For this to work with dynamically generated content, i.e., ng-repeat creating the DOM for both the navigation links and the header tags with matching ids, you must add a $timeout or an $evalAsync around the code that actually calls elem.find('#' + spy.id) else those elements don't exist yet and will not work as expected.

@Ikend do you fixe the problem? If yes, can you send the solution?
As far as I understand, the $scope.spies.push(spyObj) in the this.addSpy function doesn't triggers the $watch('spies'... (watcher that adds the jquery element on the spyElems list). I'm not a master on angularjs and I cannot guess why this watcher doesn't trigger on a watched variable changes...

Thanks!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment