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

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.

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. :-)

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

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

Related

Why window.load isn't working when the request comes from outside the page, but otherwise it does?

SCENARIO
The web app workflow is the following:
Someone clicks on a a href element into a page. http://example.org/
The link is followed and then another page within the site is reached. http://example.org/page-2/
The link URL also contains a hash var.
(This hash var is what I intended to use in order to achieve the next point)
There is a content grid that must show the wanted part, this object was built with an out of the box CMS, so I preferably don't want to modify it. This also, only works when the user clicks on the very button filter.
(This filter is based entirely on events and not on "GUI visible" locations, thus I'm not able to call for an specific filter from the very url, the previous object -there was a previous object, yes- worked really good with hashes from URL, but the new doesn't.)
The content grid filter elements don't have any ids, they just have a data custom attribute to be identified.
And that's it.
The workaround is intended to be like this:
$( window ).load(function() {
var filter = window.location.hash.substr(1);
if(filter == "keywordA") {
$('a[data-filter=".cat-1"]').trigger('click');
}
if(filter == "keywordB"){
$('a[data-filter=".cat-2"]').trigger('click');
}
if(filter == "keywordC"){
$('a[data-filter=".cat-3"]').trigger('click');
}
if(filter == "keywordD"){
$('a[data-filter=".cat-4"]').trigger('click');
}
if(filter == "keywordE"){
$('a[data-filter=".cat-5"]').trigger('click');
}
});
Then, dark and unknown forces comes into place, because when I enter this in the address bar: http://example.org/page-2/#keywordD the DOM works well, as expected. The content grid displays exactly what I want.
But when I try to reach the same link but from an a href element within http://example.org/ it just doesn't do anything at all.
FURTHER EXPLANATION
I used window.load because that way the function is forced to be executed once everything is settled in the DOM, and after every single code instance of $(document).ready() functions, because the website already works with many of these.
Here's the problem:
When navigating from the a link http://example.org/page-2/# to a different link that is the same page, but has a different hash var, like http://example.org/page-2/#keywordD, the site doesn't actually reload. This is default behaviour, because it's meant to jump to the element on the page with the id of the hash.
Luckily, there is an event for hash changes on the site.
'onhashchange'
Now depending on how your filtering works, you might want to call a function that does all the filtering (the one that does it on loading the page), or, if this is a server-side CMS thing, you might want to reload the page.
$(window).bind('hashchange', function(e) {
// Enter your filter function here
doFiltering();
});
or if reloading the page is more appropritate.
$(window).bind('hashchange', function(e) {
document.location.reload();
});
I don't quite understand what you mean by "This filter is based entirely on events and not on 'GUI visible' locations", so you might want to elaborate a little more in a comment, if I misunderstood you, but I hope either one of these soloutions work for you.
THE ANSWER
Somehow I was triggering the event before the handler was attached, despite the window.load event is supposedly intended to trigger functions when all the DOM is entirely loaded.
https://stackoverflow.com/a/2060275/1126953
Kudos to Noah.
Based on the previous answer I could manage to set the desired behavior as it follows:
$( window ).load(function() {
setTimeout(function() {
var filter = window.location.hash.substr(1);
if(filter == "keywordA") {
$('a[data-filter=".cat-1"]').trigger('click');
}
if(filter == "keywordB"){
$('a[data-filter=".cat-2"]').trigger('click');
}
if(filter == "keywordC"){
$('a[data-filter=".cat-3"]').trigger('click');
}
if(filter == "keywordD"){
$('a[data-filter=".cat-4"]').trigger('click');
}
if(filter == "keywordE"){
$('a[data-filter=".cat-5"]').trigger('click');
}
},10);
});
Just a simple delay.
Many thanks for your time Stefan.

Programmatically change first page jQuery Mobile shows

My goal is to show a different first page depending on whether the user is logged in or not. The login check happens using a synchronous Ajax call, the outcome of which decides whether to show a login dialog or the first user page.
The normal way to do this would be to set $.mobile.autoInitialize = false and then later on initialize programmatically, as described in the answer to this question. For some reason this won't work, instead another page gets loaded every single time.
I decided to give up on this way and try out a different parcour. I now use a placeholder, empty startup page that should be shown for as long as the login check takes. After the login check it should automatically change. This is done by calling a function that performs the ajax call needed for authentication on the pagechange event that introduces this startup page. The function takes care of changing to the outcome page as well.
The trick is that it doesn't quite do that.. Instead it shows the correct page for just a short time, then changes back to the placeholder. Calling preventDefault in pagechange didn't prevent this, as described in the tutorial on dynamic pages. Adding a timer fixed this, leading me to think that the placeholder wasn't quite finished when pageshow got fired (as per this page on page events), or some side-effect of the initial page load still lingered.
I'm really clueless as to how to fix this seemingly trivial, yet burdensome problem. What causes this extra change back to the initial page? Also, if my approach to intercepting the initial page load is wrong, what would be the correct approach instead?
I use jQuery Mobile 1.4.0 and jQuery 1.10.2 (1.8.3 before).
EDIT: Below is the code to my last try before I posted the question here. It does not work: preventDefault does not prevent the transition to the placeholder page.
$(document).on("pagebeforechange", function(e, data) {
if (typeof(data.options.fromPage) === "undefined" && data.toPage[0].id === "startup") {
e.preventDefault();
initLogin();
}
});
function initLogin() {
// ... Login logic
if (!loggedIn) // Pseudo
$('body').pagecontainer("change", "#login", {});
}
If you're using a Multi-page model, you can prevent showing any page on pagebeforechange event. This event fires twice for each page, once for the page which is about to be hidden and once for the page which is about to be shown; however, no changes is commenced in this stage.
It collects data from both pages and pass them to change page function. The collected data is represented as a second argument object, that can be retrieved once the event is triggered.
What you need from this object is two properties, .toPage and .options.fromPage. Based on these properties values, you decide whether to show first page or immediately move to another one.
var logged = false; /* for demo */
$(document).on("pagebeforechange", function (e, data) {
if (!logged && data.toPage[0].id == "homePage" && typeof data.options.fromPage == "undefined") {
/* immediately show login dialig */
$.mobile.pageContainer.pagecontainer("change", "#loginDialog", {
transition: "flip"
});
e.preventDefault(); /* this will stop showing first page */
}
});
data.toPage[0].id value is first page in DOM id.
data.options.fromPage should be undefined as it shouldn't be redirected from another page within the same webapp.
Demo
I'm undergoing the same problem as the one described by #RubenVereecken, that is, a coming back to the initial page once the cange to my second page has completed. In fact, he posed the question "What causes this extra change back to the initial page?" and it hasn't been replied yet.
Unfortunately, I don't know the reason since I haven't found how the page-event order works in JQM-1.4.2 yet, but fortunately, the workaround suggested by #Omar is working for me.
It's not exactly the same code but the general idea works at the time of preventing a coming back to the initial page. My code is as follows:
$(document).on("pagebeforechange", function(event, data) {
if ( typeof (data.toPage) == "string") {
if (data.toPage.indexOf("#") == -1 && typeof (data.options.fromPage[0].id) == string") {
event.preventDefault();
}
}});
The condition data.toPage.indexOf("#") == -1 is because I checked that all the undesired coming-backs to the initial page were happening when the property '.toPage' was set to something like [http://localhost/.../index.html].

History API issues

Im make some ajax calls in my website and im trying to implement the history API to make it so I can navigate it with the browser history. This is the code for the "back button":
$(document).ready(function(){
window.addEventListener("popstate", function(e) {
//Flag I use to not fire it on the first page load
if (!ajaxhistory)
return;
do_my_ajaxs_things();
}, false);
});
But I have 2 problems
1) When I type the URL in the address bar to load the page for the first time. This also fires the ajax call, which is undesired obviously. Theres no need to ajax, since I have to load the entire page. This one, I have managed to solve it with a flag, but I wonder if theres a more elegant way to do it
2) Lets say I was in "google.com" and the I type my URL "www.mysite.com", then I make an ajax call and I go to "www.mysite.com/contacts". If I press BACK button once, i will go to "www.mysite.com" allright, but if I press BACK again, I will still be in "www.mysite.com", and I find myself I cant go back to google. How could I solve this issue?
All help appreciated. Thanks.
I think that your approach is wrong. You shouldn't need to do AJAX requests each time the user goes back - that's what the state is for, you should have all the relevant data already there. IMHO the logic should be the following:
If the window loads and window.history.state is already set - just apply this state.
Otherwise trigger an AJAX request to retrieve the default state and replace the current state with it.
Whenever the user navigates to a new "page", trigger an AJAX request to retrieve the new state and push it.
In your popstate handler you should simply apply the state you got in event.state, without doing any new AJAX requests.
Here is some example code with a fake loadPage function, normally you would put your AJAX call there. The applyState function and state data are also absolutely minimal.
<script type="text/javascript">
window.onload = function()
{
if (window.history.state)
{
applyState(window.history.state);
}
else
{
loadPage(0, function(state, title, loc)
{
window.history.replaceState(state, title, loc);
});
}
window.addEventListener("popstate", function(event)
{
applyState(event.state);
});
}
function goToPage(pageId)
{
loadPage(pageId, function(state, title, loc)
{
window.history.pushState(state, title, loc);
});
}
function loadPage(pageId, callback)
{
window.setTimeout(function()
{
var state = {text: "text" + pageId};
applyState(state);
callback(state, "page " + pageId, "#" + pageId);
}, 100);
}
function applyState(state)
{
document.getElementById("content").textContent = state.text;
}
</script>
<div id="content">
???
</div>
<button onclick="goToPage(1);">1</button>
<button onclick="goToPage(2);">2</button>
<button onclick="goToPage(3);">3</button>
For your first problem, this is really a browser issue. A browser should (in my opinion) never fire the popstate event when the page is loaded initially. I think the best thing you can do is only register the event after the page has been loaded.
History.js is a good library which smoothes out the history API quite a bit. Even if you don't use it they have good documentation about the API here:
https://github.com/browserstate/history.js/wiki/The-State-of-the-HTML5-History-API
About your second issue, the browser wil just go to google instead of firing the popstate event for your website. You don't have to worry about that, it's the browsers responsibility

Canceling a back or forward event that causes a hash change or an HTML 5 history change

Is it possible to cancel a browser back or forward event that causes a hash change or an HTML 5 history change in the same way that one can cancel a page-loading back or forward event through window.onbeforeunload?
I don't know if it helps in your situation but Sammy.js, a popular hash-routing library, has a before handler. I've used it in my application to record the previously accessed hash, and if it's the hash I want to stop them from leaving, return false will keep them on that page. You still need to rewrite the URL to display the previous page, but it seems to be working. Here's a little sample of what I did:
app.before(function (context) {
if (context.path === lastRoute) return false; // Was fired from our app.setLocation below.
if (lastRoute === "/#MyHashWhereIFirstWantToConfirm") {
if (!confirm("Are you sure you wish to leave this page?")) {
app.setLocation(lastRoute); // Send them back to /#MyHashWhereIFirstWantToConfirm
return false; // Keep the handler for the destination page from firing.
}
}
lastRoute = context.path;
return true;
});
Used in conjunction with window.onbeforeunload you could pretty well keep the user from leaving the page without a confirmation.

How to prevent page's scroll position reset after DOM manipulation?

I've got two JS functions, one that is adding options to a select box
function addOption(selectId, text, value) {
var selectbox = document.getElementById(selectId);
var optNew = document.createElement('option');
optNew.text = text;
optNew.value = value;
try {
selectbox.add(optNew, null); //page position resets after this
}
catch(ex) {
selectbox.add(optNew);
}
}
and another that is doing a document.getElementById(formId).appendChild(newHiddenInput) in a similarly simple function.
They both work, elements are added as expected. However, upon calling either of them, the page resets its scroll position to the top of the page in both IE6 and FF. There is no postback, this is purely clientside manipulation. I've set breakpoints in Firebug, and it occurs immediately after the element.appendChild or select.add gets executed. I know I can use JS to manually set a scroll position, but I didn't think it was necessary when the page isn't being re-rendered.
I'm no expert with JS or the DOM, so I may very well be missing something, but I've looked here and ran through their examples with the Try it Here options and I can't replicate the problem, indicating the codebase I'm working with is the culprit.
Any ideas why the scroll position is being reset? jQuery is available to me as well, if it provides a better alternative.
If the functions are being called from a link you might have an internal anchor in your link:
http://www.website.com/page.html#
This is causing said behavior. The default behavior is that if an anchor does not exist, the page scroll position jumps to the top (scrollTop = 0).
If this happens on every function call regardless of the source, then this can be crossed off the list.
What is activating the event?
If it's an anchor then on the click event you need to "return false;" after the call to your jQuery/Ajax/jScript code.
If it's a button you may need to do the same.
I had this issue yesterday and this was the resolution.
So My link

Categories