Our client is
unlikely to be impressed with the web-like page reloads that currently exist in our app, so
we need a way to hide the request/response cycle from the user. There are three main ways we
can do this:putting everything on one page, and then hiding and displaying sections as
required
loading in new pages via Ajax
including only the complete skeleton of the app up front, and then bringing in data
as required
1. Swapping Pages
If all our content is loaded in a single HTML page, a “page”
from the point of view of our application is no longer a full HTML document; it’s merely a
DOM node that we’re using as a container. We need to choose a suitable container and an
appropriate way to group our pages, so that our scripts can manipulate them
consistently.
We’ll start by creating a container <div> (called <pages>), which contains a
number of child <div> elements that are the actual pages.
There can only be one page visible at a time, so we’ll give that element a <class> of <current>. This
<class> will be passed to whichever page is the
active
one:
<div id="pages">
<div id="page-spots" class="current">
<!-- Spots Index -->
</div>
<div id="page-spot">
<!-- Spot Detail -->
</div>
<div id="page-sightings">
<!-- Add Sighting Form -->
</div>
<div id="page-stars">
<!-- Stars Index -->
</div>
<div id="page-star">
<!-- Star Detail -->
</div>
</div>
This
list of pages will sit below the tab bar—so no need to change the markup of our
navigation. We have, however, hooked up the links to point to the various sections by way
of their <id> attributes; this will let us use a sneaky
trick to show pages in the next
step:
<ul id="tab-bar">
<li>
<a href="#spots">Spots</a>
</li>
<li>
<a href="#sightings">Add a sighting</a>
</li>
<li>
<a href="#stars">Stars</a>
</li>
</ul>
After
this, we need a couple of styles for hiding and showing pages. In our markup, every page
is a first-level child of the main #pages container, so we can rely on
that fact and use a child selector (>). First, we’ll hide all the
pages; then we’ll unhide the page that has the <current>
<class>:
listing 1. stylesheets/transitions.css (excerpt)
#pages > div { display: none; } #pages > div.current { display: block; }
|
To actually select some pages, we need to intercept the navigation menu clicks. We’ll be using the code we wrote earlier to capture
the event and prevent the browser from navigating to the link:
listing 2. javascripts/ch4/07-swap.js (excerpt)
$("#tab-bar a").bind('click', function(e) { e.preventDefault(); // Swap pages! });
|
And here’s the trick: the links point to our page elements by using the
anchor syntax of a hash symbol (#), followed by a fragment identifier.
It coincidently happens that jQuery uses that exact same syntax to select elements by <id>, so we can funnel the hash property
of the click event directly into jQuery to select
the destination page. Very sneaky:
listing 3. javascripts/ch4/07-swap.js (excerpt)
$("#tab-bar a").bind('click', function(e) { e.preventDefault(); var nextPage = $(e.target.hash); $("#pages .current").removeClass("current"); nextPage.addClass("current"); });
|
With the target page acquired, we can hide the current page by removing the
<current>
<class> and passing it to the destination page.
Swapping between pages now works as expected, but there’s a slight problem: the selected
icon in the tab bar fails to change when you navigate to another page. Looking back at our
CSS, you’ll remember that the tab bar’s appearance is due to a <class> set on the containing <ul> element;
it’s a <class> that’s the same as the current page
<div> element’s <id>. So all we need to do is slice out the hash symbol from our string (using
slice(1) to remove the first character), and set that as the <ul>’s <class>:
listing 4. javascripts/ch4/07-swap.js (excerpt)
$("#tab-bar a").bind('click', function(e) { e.preventDefault(); var nextPage = $(e.target.hash); $("#pages .current").removeClass("current"); nextPage.addClass("current"); $("#tab-bar").attr("className", e.target.hash.slice(1)); });
|
2. Fading with WebKit Animations
The page swap we just implemented is as straightforward as it
gets. This has its advantages—it stays out of our users’ way, for one. That said,
well-placed transitions between pages not only make your apps sexier, they can provide a
clear visual cue to the user as to where they’re being taken.
After the
original iPhone was released, web developers leapt to re-implement the native transition
effects in JavaScript, but the results were less than ideal, often containing lags and
jumps that were very noticeable and distracting to users. The solution largely was to
ditch JavaScript for moving large DOM elements, and instead turn to the new and
hardware-accelerated CSS3 transitions and animations.
Before we worry about
the transitions, though, we need to lay some groundwork. To fling DOM elements around, we
need to be able to show, hide, and position them at will:
listing 5. stylesheets/transitions.css (excerpt)
#pages { position: relative; } #pages > div { display:none; position: absolute; top: 0; left: 0; width: 100%; }
|
By positioning the elements absolutely, we’ve moved every page up into the
top-left corner, giving us a neat stack of invisible cards that we can now shuffle around
and animate. They’re not all invisible, though; remember that in our HTML, we gave our
default page the <class> of <current>, which sets its display property to block.
The difference this time is that we’re going to apply CSS
animations to the pages. The incoming (new) page, and the outgoing (current) page will
have equal but opposite forces applied to them to create a smooth-looking effect. There
are three steps required to do this:
Set up the CSS animations.
Trigger the animation by setting the appropriate classes on the pages.
Remove the non-required classes when the animation is finished, and return to a
non-animating state.
Let’s start on the CSS. There are many approaches you can take with the
problem of emulating native page transitions. We’ll adopt a flexible method that’s adapted
from the jQTouch library. This is a modular approach, where we control transitions by
applying and removing the relevant parts of an animation to each page.
Before
we dive into that, though, a quick primer on CSS3 animations. These are currently
supported only in WebKit browsers with -webkit- vendor prefixes. A CSS3
animation is made up of a series of keyframes grouped together as a named animation,
created using the @-webkit-keyframes rule. Then we apply that animation
to an element using the -webkit-animation-name property. We can also
control the duration and easing of the animation with the -webkit-animation-duration and -webkit-animation-timing-function properties, respectively. If you’re new to
animations, this is probably sounding more than a little confusing to you right now; never
mind, once you see it in practice, it’ll be much clearer.
So let’s apply some
animations to our elements. First up, we’ll set a timing function and a duration for our
animations. These dictate how long a transition will take, and how the pages are eased
from the start to the end point:
listing 6. stylesheets/transitions.css (excerpt)
.in, .out { -webkit-animation-timing-function: ease-in-out; -webkit-animation-duration: 300ms; }
|
We’ve placed these properties in generic classes, so that we can reuse them
on any future animations we create.
Next, we need to create our keyframes. To
start with, let’s simply fade the new page in:
listing 7. stylesheets/transitions.css (excerpt)
@-webkit-keyframes fade-in { from { opacity: 0; } to { opacity: 1; } }
|
In the above rule, fade-in is the name of the animation,
which we’ll refer to whenever we want to animate an element using these keyframes. The
from and to keywords allow us to declare the start
and end points of the animation, and they can include any number of CSS properties you’d
like to animate. If you want more keyframes in between the start and end, you can declare
them with percentages, like this:
listing 8. stylesheets/transitions.css (excerpt)
@-webkit-keyframes fade-in-out { from { opacity: 0; } 50% { opacity: 1; } to { opacity: 0; } }
|
With our keyframes declared, we can combine them with the previous direction
classes to create the final effect. For our fade, we’ll use the animation we defined
above, and also flip the z-index on the pages to make sure the
correct page is in front:
listing 9. stylesheets/transitions.css (excerpt)
.fade.in { -webkit-animation-name: fade-in; z-index: 10; } .fade.out { z-index: 0; }
|
By declaring -webkit-animation-name, we’re telling the
browser that as soon as an element matches this selector, it should begin the named
animation.
With this CSS in place, we can move to step two. We’ll start by
applying our animation to a single navigation item, then broaden it out later so that it
will work for our whole tab bar.
The page we’re fading to (#sightings) will need to have three different classes added to it: <current> to make the page visible, <fade> to add our animation, and <in> to
apply our timing function and duration. The page we’re fading from (#spots) is visible, so it will already have the <current>
<class>; we only need to add the <fade> and <out>
classes:
var fromPage = $("#spots"),
toPage = $("#sightings");
$("#tab-sighting a").click(function(){
toPage.addClass("current fade in");
fromPage.addClass("fade out");
});
This
gives us a nice fading effect when we click on the “Add a sighting” tab, but now the pages
are stuck—stacked atop one another. This is because those <class> names are still there, so the pages now have <current> and they’re both visible. Time to remove them! We’ll do this by
binding to the webkitAnimationEnd event, which
fires when the transition is complete. When this event fires, we can remove all three
classes from the original page, and the <fade> and
<in> classes from the new page. Additionally, we must
remember to unbind the webkitAnimationEnd event so
that we don’t go adding on extra handlers the next time we fade from the
page:
var fromPage = $("#spots"),
toPage = $("#sightings");
$("#tab-sighting a").click(function(){
toPage
.addClass("current fade in")
.bind("webkitAnimationEnd", function(){
// More to do, once the animation is done.
fromPage.removeClass("current fade out");
toPage
.removeClass("fade in")
.unbind("webkitAnimationEnd");
});
fromPage.addClass("fade out");
});
There
we go. Our page is now fading nicely; however, there are a few problems with our code. The
first is structural. It will become quite ugly if we have to replicate this same click
handler for each set of pages we want to transition to! To remedy this, we’ll make a
function called transition() that will accept a page selector and
fade from the current page to the new one provided.
While we’re at it, we can
replace our bind() and unbind() calls
with jQuery’s one() method. This method will accomplish the same
task—it binds an event, and then unbinds it the first time it’s fired—but it looks a lot
cleaner:
listing 10. javascripts/ch4/08-fade.js (excerpt)
function transition(toPage) { var toPage = $(toPage), fromPage = $("#pages .current");
toPage .addClass("current fade in") .one("webkitAnimationEnd", function(){ fromPage.removeClass("current fade out"); toPage.removeClass("fade in") }); fromPage.addClass("fade out"); }
|
Warning:
Generalizing Functions
You might spy that we’ve hardcoded the current page selector inside our function.
This makes our code smaller, but reduces the reusability of the function. If you are
building a larger framework intended for more general use, you’d probably want to accept
the fromPage as a parameter, too.
Great. Now we have a reusable function that we can employ to fade between
any of the pages in our app. We can pull the link targets out of the tab bar the same way
we did earlier, and suddenly every page swap is a beautiful fade:
listing 10. javascripts/ch4/08-fade.js (excerpt)
$("#tab-bar a").click(function(e) { e.preventDefault(); var nextPage = $(e.target.hash); transition(nextPage); $("#tab-bar").attr("className", e.target.hash.slice(1)); });
|
There’s still a major problem, though, and it’s one you’ll notice if you try
to test this code on a browser that lacks support for animations, such as Firefox. Because
we’re relying on the webkitAnimationEnd event to
remove the <current>
<class> from the old page, browsers that don’t support
animations—and therefore never fire that event—will never hide the original page.
Tip:
Browser Testing
This bug—which would render the application completely unusable on non-WebKit
browsers— highlights the importance of testing
your code on as many browsers as possible. While it can be easy to assume that every
mobile browser contains an up-to-date version of WebKit (especially if you own an iPhone
or Android), the real mobile landscape is far more varied.
This problem is easy enough to solve. At the end of our
transition() function, we’ll drop in some feature detection
code that will handle the simplified page swap in the absence of
animations:
listing 11. javascripts/ch4/08-fade.js (excerpt)
function transition(toPage) { ⋮ // For non-animatey browsers if(!("WebKitTransitionEvent" in window)){ toPage.addClass("current"); fromPage.removeClass("current"); return; } }
|
With this code in place, our app now produces our beautiful fade transition
on WebKit browsers, but still swaps pages out effectively on other
browsers.
There’s still one slightly buggy behavior, one you might notice if
you become a little excited and start clicking like crazy. If you click on the link to the
current page—or if you tap quickly to start an animation when the previous one has yet to
complete—the <class> attributes we’re using to manage
the application’s state will be left in an inconsistent state. Eventually, we’ll end up
with no pages with the <current>
<class>—at which point we’ll be staring at a blank
screen.
It’s relatively easy to protect against these cases. We just need to
ensure our toPage is different from our fromPage,
and that it doesn’t already have the <current><class> on it. This safeguard goes after the variable
declaration, and before any class manipulations:
listing 12. javascripts/ch4/08-fade.js (excerpt)
function transition(toPage) { var toPage = $(toPage), fromPage = $("#pages .current"); if(toPage.hasClass("current") || toPage === fromPage) { return; }; ⋮
|