Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How to handle scroll position on hashchange in Backbone.js application?

I've read through many related threads but none of them seem to provide a solution.

What I'm trying to do is handle the scrollbar intelligently in my Backbone.js app. Like many others, I have multiple #mypage hash routes. Some of these routes are hierarchical. e.g. I have a #list page that lists some items, I click on an item in the list. Then it opens up a #view/ITEMID page.

My pages all share the same Content div in the HTML layout. On a navigation change, I inject a new div representing the view for that route into the Content div, replacing whatever was there before.

So now my problem:

If the item is far down in the list I might have to scroll to get there. When I click on it, the "default" Backbone behavior is that the #view/ITEMID page is displayed at the same scroll position that the #list view was. Fixing that is easy enough; just add a $(document).scrollTop(0) whenever a new view is injected.

The problem is if I hit the back button I would like to go back to the #list view at the scroll position it was previously.

I tried to take the obvious solution to this. Storing a map of routes to scroll positions in memory. I write to this map at the beginning of the handler for the hashchange event, but before the new view is actually put into the DOM. I read from the map at the end of the hashchange handler, after the new view is in the DOM.

What I'm noticing is that something, somewhere, in Firefox at least, is scrolling the page as part of a hashchange event, so that by the time my write-to-map code gets called, the document has a wonky scroll position that was definitely not explicitly made by the user.

Anyone know how to fix this, or a best practice that I should be using instead?

I double checked and there are no anchor tags in my DOM that match the hashes I'm using.

like image 570
schematic Avatar asked Jun 26 '12 21:06

schematic


People also ask

How do I scroll to a specific position in HTML?

The scrollTo() method scrolls the document to specified coordinates.

How does Backbone JS work?

BackboneJS provides various building blocks such as models, views, events, routers and collections for assembling the client side web applications. When a model changes, it automatically updates the HTML of your application. BackboneJS is a simple library that helps in separating business and user interface logic.

What is backbone JS give its features?

Backbone. js allows developers to develop one page applications and front-end much easier and better using JavaScript functions. Backbone provides different types of building blocks like models, views, events, routers and collections for assembling client side web applications.


1 Answers

My solution to this ended up being something less automatic than I wanted, but at least it's consistent.

This was my code for saving and restoring. This code was pretty much carried from my attempt over to my actual solution, just called it on different events. "soft" is a flag that this came from a browser action (back, forward, or hash click) as opposed to a "hard" call to Router.navigate(). During a navigate() call I wanted to just scroll to the top.

restoreScrollPosition: function(route, soft) {     var pos = 0;     if (soft) {         if (this.routesToScrollPositions[route]) {             pos = this.routesToScrollPositions[route];         }     }     else {         delete this.routesToScrollPositions[route];     }     $(window).scrollTop(pos); },  saveScrollPosition: function(route) {     var pos = $(window).scrollTop();     this.routesToScrollPositions[route] = pos; } 

I also modified Backbone.History so that we can tell the difference between reacting to a "soft" history change (which calls checkUrl) versus programmatically triggering a "hard" history change. It passes this flag to the Router callback.

_.extend(Backbone.History.prototype, {      // react to a back/forward button, or an href click.  a "soft" route    checkUrl: function(e) {         var current = this.getFragment();         if (current == this.fragment && this.iframe)             current = this.getFragment(this.getHash(this.iframe));         if (current == this.fragment) return false;         if (this.iframe) this.navigate(current);         // CHANGE: tell loadUrl this is a soft route         this.loadUrl(undefined, true) || this.loadUrl(this.getHash(), true);     },      // this is called in the whether a soft route or a hard Router.navigate call     loadUrl: function(fragmentOverride, soft) {         var fragment = this.fragment = this.getFragment(fragmentOverride);         var matched = _.any(this.handlers, function(handler) {             if (handler.route.test(fragment)) {                 // CHANGE: tell Router if this was a soft route                 handler.callback(fragment, soft);                 return true;             }         });         return matched;     }, }); 

Originally I was trying to do the scroll saving and restoring entirely during the hashchange handler. More specifically, within Router's callback wrapper, the anonymous function that invokes your actual route handler.

route: function(route, name, callback) {     Backbone.history || (Backbone.history = new Backbone.History);     if (!_.isRegExp(route)) route = this._routeToRegExp(route);     if (!callback) callback = this[name];     Backbone.history.route(route, _.bind(function(fragment, soft) {          // CHANGE: save scroll position of old route prior to invoking callback         // & changing DOM         displayManager.saveScrollPosition(foo.lastRoute);          var args = this._extractParameters(route, fragment);         callback && callback.apply(this, args);         this.trigger.apply(this, ['route:' + name].concat(args));          // CHANGE: restore scroll position of current route after DOM was changed         // in callback         displayManager.restoreScrollPosition(fragment, soft);         foo.lastRoute = fragment;          Backbone.history.trigger('route', this, name, args);     }, this));     return this; }, 

I wanted to handle things this way because it allows saving in all cases, whether an href click, back button, forward button, or navigate() call.

The browser has a "feature" that tries to remember your scroll on a hashchange, and move to it when going back to a hash. Normally this would have been great, and would save me all the trouble of implementing it myself. The problem is my app, like many, changes the height of the DOM from page to page.

For example, I'm on a tall #list view and have scrolled to the bottom, then click an item and go to a short #detail view that has no scrollbar at all. When I press the Back button, the browser will try to scroll me to the last position I was for the #list view. But the document isn't that tall yet, so it is unable to do so. By the time my route for #list gets called and I re-show the list, the scroll position is lost.

So, couldn't use the browser's built-in scroll memory. Unless I made the document a fixed height or did some DOM trickery, which I didn't want to do.

Moreover that built-in scroll behavior messes up the above attempt, because the call to saveScrollPosition is made too late--the browser has already changed the scroll position by then.

The solution to this, which should have been obvious, was calling saveScrollPosition from Router.navigate() instead of the route callback wrapper. This guarantees that I'm saving the scroll position before the browser does anything on hashchange.

route: function(route, name, callback) {     Backbone.history || (Backbone.history = new Backbone.History);     if (!_.isRegExp(route)) route = this._routeToRegExp(route);     if (!callback) callback = this[name];     Backbone.history.route(route, _.bind(function(fragment, soft) {          // CHANGE: don't saveScrollPosition at this point, it's too late.          var args = this._extractParameters(route, fragment);         callback && callback.apply(this, args);         this.trigger.apply(this, ['route:' + name].concat(args));          // CHANGE: restore scroll position of current route after DOM was changed         // in callback         displayManager.restoreScrollPosition(fragment, soft);         foo.lastRoute = fragment;          Backbone.history.trigger('route', this, name, args);     }, this));     return this; },  navigate: function(route, options) {     // CHANGE: save scroll position prior to triggering hash change     nationalcity.displayManager.saveScrollPosition(foo.lastRoute);     Backbone.Router.prototype.navigate.call(this, route, options); }, 

Unfortunately it also means I always have to explicitly call navigate() if I'm interested in saving scroll position, as opposed to just using href="#myhash" in my templates.

Oh well. It works. :-)

like image 169
schematic Avatar answered Oct 19 '22 23:10

schematic