Sunday, April 21, 2013

Durandal + scrollspy

Recently, I played with the Hot Towel MVC template and tried to integrate Bootstrap's scrollspy. This was very difficult for me as my main topic is rather C# and WPF than JavaScript and Html. But after a struggle here and there, I found a solution. Not perfect (any improvements are welcome), but it's sufficient for me.

The problem is that Sammy.js, which Durandal uses for routing, does the routing via hashes (#), which apparently are also used for anchor-navigation. In order to prevent Sammy.js to incorrectly interpret the anchor-links, I added an invisible route for them to the viewmodel of the current page and route it there manually. Hard to describe, see the code!

 //shell.js:
router.mapNav('alpha', 'viewmodels/alpha', 'Alpha');
router.mapRoute('beta', 'viewmodels/alpha', 'Alpha', false);
router.mapRoute('gamma', 'viewmodels/alpha', 'Alpha', false);

In shell.js, in the activate-method, we register the normal route ('alpha'), and for each anchor-link within the alpha-page, another route that maps to the same page ('beta' and 'gamma'). Now, when you click on an anchor on page 'alpha', the activate-method of the corresponding viewmodel will be called with the anchor-value as parameter (see below).

 //alpha.html:

Nothing special for the html-part. The anchor-links are just normal relative anchor-links and the classes are mainly from bootstrap for the scrollspy- and affix-plugin.

 //alpha.js:
define(['durandal/plugins/router'], function (router) {
  var vm = {
    activate: activate,
    viewAttached: viewAttached,
    loaded: false,
    items: [
      { name: "Beta", link: "beta" },
      { name: "Gamma", link: "gamma" },
    ]
  };

  return vm;

  function activate(routeParameters) {
    if (routeParameters.routeInfo.hash != "#/alpha") {
      $(document.body).animate({
        'scrollTop': $(routeParameters.routeInfo.hash.replace("/", "")).offset().top
      }, 100);

      return false;
    }

    return true;
  }

  function viewAttached() {
    if (!vm.loaded) {
      vm.loaded = true;
      $('.bs-docs-sidebar').height($(".bs-docs-sidenav").height());
      $('.bs-docs-sidenav').affix({
        offset: $('#nav').position()
      });
      $('.bs-docs-sidebar').scrollspy();
      $('[data-spy="scroll"]').each(function() {
        var $spy = $(this).scrollspy('refresh');
      });
    }
  }

The activate-function is called whenever a route to this viewmodel is activated. When the normal alpha-route is activated, it is called with "#/alpha" as hash and nothing happens. When an anchor-route is activated, it is called with "#/beta" for example and we scroll to this anchor with jQuery.
The viewAttached-function is called when the databinding has finished. Clicking an anchor-link calls this method not for the first time and nothing happens. The important work is done on the first call: the height of the navigation-div is fitted to its content, the affix-parameter is set and the scrollspy is activated (twice, because I possibly do something wrong here *g*).

 //app.css:
.affix {
  position: fixed;
}
 // index.cshtml:
< body data-spy="scroll" data-target=".bs-docs-sidebar">

Don't forget the scrollspy data-attributes for the body-tag!

That's it :)