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.
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. :-)
@Mirage114 Thanks for posting your solution. It works like a charm. Just a minor thing, it assumes the route functions are synchronous. If there is an async operation, for example fetching remote data before rendering a view, then window is scrolled before the view content is added to the DOM. In my case, I cache data when a route is visited for the first time. This is so that when a user hits browser back/forward button, the async operation of fetching data is avoided. However it might not always be possible to cache every data you need for a route.
A simple solution: Store the position of the list view on every scroll event in a variable:
var pos;
$(window).scroll(function() {
pos = window.pageYOffset;
});
When returning from the item view, scroll the list view to the stored position:
window.scrollTo(0, pos);
I have a slightly poor-man's fix for this. In my app, I had a similar problem. I solved it by putting the list view and the item view into a container with:
height: 100%
Then I set both the list view and the item view to have:
overflow-y: auto
height: 100%
Then when I click on an item, I hide the list and show the item view. This way when I close the item and go back to the list, I keep my place in the list. It works with the back button once, although obviously it doesn't keep your history, so multiple back button clicks won't get you where you need to be. Still, a solution with no JS, so if it's good enough...