4. Going Backwards
The user is now looking at a page of details about some crazy
celebrity, and they’re bored. They want a new crazy celebrity to read about, so they go
looking for the Back button. But going back is more than just swapping the source and
destination pages again, because the animations we applied need to be reversed: the old
page needs to slide back from the left into view.
But that’s getting ahead of
ourselves; first, we need a Back button. We’ve provided one up in the header of each page
in the form of an <a> element that’s styled to look all
button-like:
listing 17. ch4/10-back.html (excerpt)
<div class="header"> <h1>Spots</h1> <a href="#" class="back">Back</a> </div>
|
And of course, we must have a handler to perform an action when the button
is clicked:
listing 18. javascripts/ch4/10-back.js (excerpt)
$("#spot-details .back").click(function(){ // Do something when clicked … });
|
Next, we need to recreate all our CSS animations—but in reverse. We’ve
already created inFromRight and outFromLeft
animations; we need to add two more to complement them: inFromLeft and
outToRight. Once these are defined, they have to be attached to our
elements with CSS selectors. We’ll continue the modular approach, and use a combination of
class selectors to leverage our existing properties:
listing 19. stylesheets/transitions.css (excerpt)
@-webkit-keyframes inFromLeft { from { -webkit-transform: translateX(-100%); } to { -webkit-transform: translateX(0); } } .push.in.reverse { -webkit-animation-name: inFromLeft; } @-webkit-keyframes outToRight { from { -webkit-transform: translateX(0); } to { -webkit-transform: translateX(100%); } } .push.out.reverse { -webkit-animation-name: outToRight; }
|
The next step is to work the new <class>
into our transition() function. We’ll add a third parameter,
reverse, that accepts a Boolean value. If the value is false, or if it’s not provided at all, we’ll do the forward version of the
transition. If the value is true, we’ll append the <reverse>
<class> to all the class manipulation
operations:
listing 20. javascripts/ch4/10-back.js (excerpt)
function transition(toPage, type, reverse){ var toPage = $(toPage), fromPage = $("#pages .current"), reverse = reverse ? "reverse" : "";
if(toPage.hasClass("current") || toPage === fromPage) { return; }; toPage .addClass("current " + type + " in " + reverse) .one("webkitAnimationEnd", function(){ fromPage.removeClass("current " + type + " out " + reverse); toPage.removeClass(type + " in " + reverse); }); fromPage.addClass(type + " out " + reverse); }
|
If we pass in true now, the new page will be assigned the
<class> attribute <push in
reverse>, and the old page will be assigned <push out
reverse>—which will trigger our new backwards animations. To see it in action,
we’ll add a call to transition() in our Back button
hander:
listing 21. javascripts/ch4/10-back.js (excerpt)
$("#page-spot .back").click(function(e){ e.preventDefault(); transition("#page-spots", "push", true); });
|
4.1. Managing History
The Back button works, but it’s a bit “manual” at the moment. For every page in our
app, we’d have to hook up a separate handler to go back. Worse still, some pages could
be reached via a number of different routes, yet our current solution only goes back to
a fixed page. To combat these problems, we’ll create our very own history system that
will keep track of each page users visit, so that when they hit the Back button, we know
where we should send them.
To start with, we’ll create a visits object, which will contain a
history array and some methods to manage it:
listing 22. javascripts/ch4/11-history.js (excerpt)
var visits = { history: [], add: function(page) { this.history.push(page); } };
|
Our visits object will maintain a stack of visited pages in the
history array. The add() method takes a
page and prepends it to the stack (via the JavaScript push()transition() function, so that every page will be
added before it’s shown:
function, which adds an element to the end of an array). We’ll call this method from
inside our
listing 23. javascripts/ch4/11-history.js (excerpt)
function transition(toPage, type, reverse) { var toPage = $(toPage), fromPage = $("#pages .current"), reverse = reverse ? "reverse" : ""; visits.add(toPage); ⋮ }
|
Note:
The assumption that every transition corresponds to a page change is convenient
for us, otherwise we’d have to call visits.add() everywhere we do a
transition. However, there might be times when you want to do a transition to a new
page, but not include it as a page change—for example, if you have some kind of
slide-up dialog. In this case, you could create a
changePage() function that handles both history management
and transitioning.
The next item to think about is our Back button. We only want it to be shown if
there’s a history item to revert to. We’ll add a helper method to the
visits object to check for us. Because the first page in the
history will be the current page, we need to check that there are at least two
pages:
listing 24. javascripts/ch4/11-history.js (excerpt)
var visits = { ⋮ hasBack: function() { return this.history.length > 1; } }
|
Now that we have this helper, we can use it in our transition code to show or hide
the Back button accordingly. The toggle() jQuery function is
very useful here; it accepts a Boolean value, and either shows or hides the element
based on that value:
listing 25. javascripts/ch4/11-history.js (excerpt)
function transition(toPage, type, reverse) { var toPage = $(toPage), fromPage = $("#pages .current"); reverse = reverse ? "reverse" : "";
visits.add(toPage); toPage.find(".back").toggle(visits.hasBack()); ⋮
|
Good! Now we need some logic in our visits object to handle a
back event. If there is history, we’ll pop the first item (the current page) off the top
of the stack. We don’t actually need this page—but we have to remove it to reach the
next item. This item is the previous page, and it’s the one we return:
listing 26. javascripts/ch4/11-history.js (excerpt)
var visits = { ⋮ back: function() { if(!this.hasBack()){ return; } var curPage = this.history.pop(); return this.history.pop(); } }
|
Note:
The push() and pop() methods add or remove an element from the
end of an array, respectively. Both methods modify the original array in place. The
pop() method returns the element that has been removed (in
our example, we use this to get the previous page), whereas the
push() method returns the new length of the array.
Finally, we can wire up all our application’s Back buttons. When a request to go
back is issued, we grab the previous page and, if it exists, we transition back to it.
We
just replace our hardcoded click handler with a general-purpose one:
listing 27. javascripts/ch4/11-history.js (excerpt)
$(".back").live("click",function(){ var lastPage = visits.back(); if(lastPage) { transition(lastPage, "push", true); } });
|
There’s still a problem, though: we never
add the initial page to the history stack, so
there’s no way to navigate back to it. That’s easy enough to fix—we’ll just remove the
<current>
<class> from the initial <div>, and call our transition function to show the first
page when the document loads:
listing 28. javascripts/ch4/11-history.js (excerpt)
$(document).ready(function() { ⋮ transition($("#page-spots"), "show"); });
|
To hook up that “show” transition, we’ll reuse our fade animation,
but with an extremely short duration:
listing 29. stylesheets/transitions.css (excerpt)
.show.in { -webkit-animation-name: fade-in; -webkit-animation-duration: 10ms; }
|
Many native apps only track history between master and details pages; in our case,
for example, a list of stars leads to the star’s details, and the Back button allows you
to jump back up to the list. If you change areas of the application (for example, by
clicking on one of the main navigation links), the history is reset. We can mimic this
behavior by adding a clear() method:
listing 30. javascripts/ch4/11-history.js (excerpt)
var visits = { ⋮ clear: function() { this.history = []; } }
|
This simply erases our history stack. We’ll call this method whenever the user moves
to a new section:
listing 31. javascripts/ch4/11-history.js (excerpt)
$("#tab-bar a").click(function(e){ // Clear visit history visits.clear(); ⋮ });
|
This has a very “app” feeling, and, as an added bonus, we don’t have to wire up so
many Back button events!
4.2. Back with Hardware Buttons
Our current Back button system is good, but it doesn’t take into account the fact
that a mobile device will often have its own Back button—either in the form of a
physical button, or a soft button in the browser. As it stands, if a user hits their
device’s Back button after clicking a few internal links in our app, the browser will
simply move to the last HTML page it loaded, or exit completely. This will definitely
break our users’ illusion of our site as a full-fledged app, so let’s see if we can find
a fix for this problem.
What we really need is to be able to listen to, and modify, the browser’s built-in
history, instead of our own custom stack of pages. To accomplish this, the HTML5 History API is here to
help us out.
The History API lets us add pages to the history stack, as well as move forward and
backwards between pages in the stack. To add pages, we use the
window.history.pushState() method. This method is analogous
to our visits.add() method from earlier, but takes three
parameters: any arbitrary data we want to remember about the page; a page title (if
applicable); and the URL of the page.
We’re going to create a method changePage() that combines
both adding a page using the history API, and doing our regular transition. We’ll keep
track of the transition inside the history, so that when the user presses back, we can
look at the transition and do the opposite. This is nicer than our previous version,
where we’d only ever do a reverse slide for the back transition.
Here’s a first stab at writing out this new method:
listing 32. javascripts/ch4/12-hardware-back.js (excerpt)
function changePage(page, type, reverse) {
window.history.pushState({ page: page, transition: type, reverse: !!reverse }, "", page); // Do the real transition transition(page, type, reverse) }
|
The first parameter to pushState() is referred to as the
state object. You can use it to pass any amount of data between
pages in your app in the form of a JavaScript object. In our case, we’re passing the
page, the transition type, and whether or not it’s a reverse transition.
To use this new function in our code, we merely change all occurrences of
transition() to changePage(), for
example:
changePage("#page-spots", "show");
Now, as the user moves through our application, the history is being stored away. If
they hit the physical Back button, you can see the page history in the URL bar, but
nothing special happens. This is to be expected: we’ve just pushed a series of page
strings onto the history stack, but we haven’t told the app how to navigate back to
them.
The window.onPopState event is fired whenever
a real page load event happens, or when the user hits Back or Forward. The event is fed
an object called state that contains the state object we put
there with pushStack() (if the state is undefined, it means the
event was fired from a page load, rather than a history change—so it’s of no concern).
Let’s create a handler for this event:
listing 33. javascripts/ch4/12-hardware-back.js (excerpt)
window.addEventListener("popstate", function(event) { if(!event.state){ return; }
// Transition back - but in reverse. transition( event.state.page, event.state.transition, !event.state.reverse ); }, false);
|
Note:
For this example, we’ve just used a standard DOM event listener rather than the
jQuery bind() method. This is just for clarity for the
popstate event. If we bound it using $(window).bind("popstate", …),
the event object passed to the callback
would be a jQuery event object, not the browser’s native
popstate event. Usually that’s what we want, but jQuery’s event
wrapper doesn’t include the properties from the History
API, so we’d need to call
event.originalEvent to retrieve the browser event. There’s
nothing wrong with that—you can feel free to use whichever approach you find
simplest.
Fantastic! The animations all appear to be working in reverse when we hit the
browser Back button … or are they? If you look closely, you might notice something
strange. Sometimes we see “slide” transitions that should be simple “show” transitions,
and vice versa. What’s going on?
Actually, we have an off-by-one error happening here: when moving backwards, we
don’t want to use the transition of the page we are transitioning
to, but the page we are transitioning from.
Unfortunately, this means we need to call pushState() with the
next transition that happens. But we’re unable to see the future
… how can we know what transition is going to happen next?
Thankfully, the History API provides us with another method,
replaceState(). It’s almost identical to
pushState(), but instead of adding to the stack, it
replaces the current (topmost) page on the stack. To solve our
problem, we’ll hang on to the details of the previous
pushState(); then, before we add the next item, we’ll use
replaceState() to update the page with the “next”
transition:
listing 34. javascripts/ch4/12-hardware-back.js (excerpt)
var pageState = {}; function changePage(page, type, reverse) { // Store the transition with the state if(pageState.url){ // Update the previous transition to be the NEXT transition pageState.state.transition = type; window.history.replaceState( pageState.state, pageState.title, pageState.url); } // Keep the state details for next time! pageState = { state: { page: page, transition: type, reverse: reverse }, title: "", url: page } window.history.pushState(pageState.state, pageState.title, pageState.url); // Do the real transition transition(page, type, reverse) }
|
We also need to update our pageState variable when the user goes
back; otherwise, it would fall out of sync with the browser’s history, and our
replaceState() calls would end up inserting bogus entries
into the history:
listing 35. javascripts/ch4/12-hardware-back.js (excerpt)
window.addEventListener("popstate", function(event) { if(!event.state){ return; } // Transition back - but in reverse. transition( event.state.page, event.state.transition, !event.state.reverse ); pageState = { state: { page: event.state.page, transition: event.state.transition, reverse: event.state.reverse }, title: "", url: event.state.page } }, false);
|
There we go. The physical Back button now works beautifully. But what about our
custom application Back button? We can wire that up to trigger a history event, and
therefore tie into all that History API jazz we just wrote using a quick call to
history.back():
listing 36. javascripts/ch4/12-hardware-back.js (excerpt)
$(".back").live("click",function(e){ window.history.back(); });
|
Now our application Back button works exactly like the browser or physical Back
button. You can also wire up a Forward button and trigger it with history.forward(), or skip to a particular page in the stack with history.go(-3).
You might have noticed that we’ve been a bit quiet on the
Forward button handling. There are two reasons for this:
first, most mobile browsers
lack a Forward button, and second, it’s impossible to know
if the popstate event occurred because of the Back or the Forward
button.
The only way you could get around this pickle would be to combine the popstate method with the manual history management system
we built in the previous section,
looking at the URLs or other data to determine the direction of the stack movement. This
is a lot of work for very little return in terms of usability, so we’ll settle for the
history and back functionality we’ve built, and move on to the next challenge.