For our first rotator
example, we'll take a news feed and scroll the headlines, along with an
excerpt of the article, into view one at a time. The stories will flow
into view, pause to be read, and then slide up and off the page as if
there were an infinite ribbon of information rolling over the page.
Setting up the page
At its most basic level, this
feature is not very difficult to implement. But as we will soon see,
making it production-ready requires a bit of finesse.
We begin, as usual, with a chunk of HTML. We'll place the news feed in the sidebar of the page:
<h3>Recent News</h3>
<div id="news-feed">
<a href="news/index.html">News Releases</a>
</div>
So far, the content area of our news feed contains only a single link to the main news page.
This is our graceful degradation scenario, in case the user does not have JavaScript enabled. The content we'll be working with will come from an actual RSS feed instead.
The CSS for this<div>
is important as it will determine not only how much of each news item
will be shown at a time, but also where on the page the news items will
appear. Together with the style rule for the individual news items, the
CSS looks like this:
#news-feed {
headline rotatorpage, setting upposition: relative;
height: 200px;
width: 17em;
overflow: hidden;
}
.headline {
position: absolute;
height: 200px;
top: 210px;
overflow: hidden;
}
Notice here that the height of both the individual news items (represented by the headline class) and their container is 200px. Also, since headline elements are absolutely positioned relative to #news-feed, we're able to line up the top of the news items with the bottom edge of their container. That way, when we set the property of #news-feed to hidden, the headlines are not displayed initially. overflow
Setting the position of the headlines to absolute is necessary for another reason as well: for any element to have its location animated on the page, it must have either or relative positioning, rather than the default static positioning. absolute
Now that we have the HTML and CSS in place, we can inject the news items from an RSS feed. To start, we'll wrap the code in a .each() method, which will act as an if statement of sorts and contain the code inside a private namespace:
$(document).ready(function() {
$('#news-feed').each(function() {
var $container = $(this);
$container.empty();
});
});
Normally when we use the .each() method, we are iterating over a possibly large set of elements. Here, though, our selector #news-feed
is looking for an ID, so there are only two potential outcomes. The
factory function could make a jQuery object matching one unique element
with the news-feed ID, or it could find no elements on the page with that ID and produce an empty jQuery object. The .each() call takes care of executing the contained code if, and only if, the jQuery object is non-empty.
At the beginning of our .each() loop, the news feed container is emptied to make it ready for its new content.
Retrieving the feed
To retrieve the feed, we'll use the $.get()
method, one of jQuery's many AJAX functions for communicating with the
server. This method, as we have seen before, allows us to operate on
content from a remote source by using a success handler.
The content of the feed is passed to this handler as an XML structure.
We can then use jQuery's selector engine to work with this data.
$(document).ready(function() {
$('#news-feed').each(function() {
var $container = $(this);
$container.empty();
$.get('news/feed.xml', function(data) {
$('rss item', data).each(function() {
// Work with the headlines here.
});
});
});
});
For more information on $.get() and other AJAX methods, see Chapter 6.
Now, we need to combine the parts of each item into a usable block of HTML markup. We can use .each() again to go through the items in the feed and build the headline links:
$(document).ready(function() {
headline rotatorfeed, retrieving$('#news-feed').each(function() {
var $container = $(this);
$container.empty();
$.get('news/feed.xml', function(data) {
$('rss item', data).each(function() {
var $link = $('<a></a>')
.attr('href', $('link', this).text())
.text($('title', this).text());
var $headline = $('<h4></h4>').append($link);
$('<div></div>')
.append($headline)
.appendTo($container);
});
});
});
});
We get the text of each item's<title> and<link> elements, and construct the<a> element from them. This link is then wrapped in an<h4> element. We put each news item into<div id="news-feed">, but for now we're omitting the headline class on each news item's containing<div> so that we can more easily see our work in progress.
In addition to the headlines,
we want to display a bit of supporting information about each article.
We'll grab the publication date and article summary, and display these
as well.
$(document).ready(function() {
$('#news-feed').each(function() {
var $container = $(this);
$container.empty();
$.get('news/feed.xml', function(data) {
$('rss item', data).each(function() {
var $link = $('<a></a>')
.attr('href', $('link', this).text())
.text($('title', this).text());
var $headline = $('<h4></h4>').append($link);
var pubDate = new Date(
$('pubDate', this).text());
var pubMonth = pubDate.getMonth() + 1;
var pubDay = pubDate.getDate();
var pubYear = pubDate.getFullYear();
var $publication = $('<div></div>')
.addClass('publication-date')
.text(pubMonth + '/' + pubDay + '/'
+ pubYear);
var $summary = $('<div></div>')
.addClass('summary')
.html($('description', this).text());
$('<div></div>')
.append($headline, $publication, $summary)
.appendTo($container);
});
});
});
});
The date information in an RSS
feed is encoded in RFC 822 format, which includes date, time, and time
zone information (for example, Sun, 28 Sep 2008 18:01:55 +0000). This format is not particularly eye-pleasing, so we use JavaScript's built-in Date object to produce a more compact representation of the date (such as. 9/28/2008)
The summary information is easier to retrieve and format. It's worth noting, though, that in our sample feed, some HTML entities may exist in the description. To make sure that these are not automatically escaped by jQuery, we need to use the .html() method to insert the description into the page, rather than the .text() method.
With these new elements created, we insert them into the document using the .append()
method. Note here that we are using a new feature of the method; if
more than one argument is supplied, all of them get appended in
sequence.
As we can see, the title, date, link, and summary of each news item is now in place. All that's left is to add the headline class with .addClass('headline') (which will hide them from view because of the CSS we defined earlier), and we are ready to proceed with our animation.
Setting up the rotator
Since the visible news item
will change over time, we'll need a way to easily keep track of which
items are visible and where they are. First, we'll set two variables,
one for the currently visible headline and one for the headline that
has just scrolled out of view. Initially, both values will be 0.
var currentHeadline = 0, oldHeadline = 0;
Next, we'll take care of some initial positioning of the headlines. Recall that in the stylesheet we have already set the top property of the headlines to be 10 pixels greater than their container's height; because the container has an overflow property of hidden,
the headlines are initially not displayed. It'll be helpful later on if
we store that property in a variable, so that we can move headlines to
this position when needed.
var hiddenPosition = $container.height() + 10;
We also want the first headline to be visible immediately upon page load. To achieve this, we can set its top property to 0.
$('div.headline').eq(currentHeadline).css('top', 0);
The rotator area of the page is now in the correct initial state:
Finally, we'll store the
total number of headlines for later use and define a timeout variable
to be used for the pause mechanism between each rotation.
var headlineCount = $('div.headline').length;
var pause;
There is no need yet to give pause a value at this time; it will be set each time the rotation occurs. Nevertheless, we must always declare local variables using var to avoid the risk of collisions with global variables of the same name.
The headline rotate function
Now we're ready to
rotate the headlines, dates, and summaries. We'll define a function for
this task so that we can easily repeat the action each time we need it.
First, let's take care of updating the variables that are tracking which headline is active. The modulus operator (%) will let us easily cycle through the headline numbers. We can add 1 to the currentHeadline value each time our function is called, and then take this value modulus the headlineCount value to constrain the variable to valid headline numbers.
Recall that we used this same technique to cycle through row colors when striping tables in Chapter 7.
We should also update the oldHeadline value so that we can easily manipulate the headline that is moving out of view.
var headlineRotate = function() {
headline rotatorheadline rotate functioncurrentHeadline = (oldHeadline + 1) % headlineCount;
// Animate the headline positions here.
oldHeadline = currentHeadline;
};
Now we have to fill in the gap
with code that actually moves the headlines. First, we'll add an
animation that moves the old headline out of the way. Then, we'll
insert another animation that slides the new headline into view.
var headlineRotate = function() {
currentHeadline = (oldHeadline + 1) % headlineCount;
$('div.headline').eq(oldHeadline).animate(
{top: -hiddenPosition}, 'slow', function() {
$(this).css('top', hiddenPosition);
});
$('div.headline').eq(currentHeadline).animate(
{top: 0}, 'slow', function() {
pause = setTimeout(headlineRotate, 5000);
});
oldHeadline = currentHeadline;
};
In both cases, we're animating the top property of the news item. Recall that the items are hidden because they have a top value of hiddenPosition (which is a number greater than the height of the container). Animating this property to 0 brings an item into view; further animating it to -hiddenPosition moves it out of view again.
In both cases, we
also have a callback function specified to take action when the
animation is complete. When the old headline has completely slid out of
view, it gets its top property reset to hiddenPosition
so it is ready to return later. When the new headline is finished with
its animation, we want to queue up the next transition; this is done
with a call to the JavaScript setTimeout() function, which registers a function to be invoked after a specified period. In this case, we're causing to be fired again in five seconds (5000 milliseconds). headlineRotate()
We now have a cycle of
activity; once one animation completes, the next one is ready to
activate. It remains to call the function the first time; we'll do this
with another call to setTimeout(),
causing the first transition to happen 5 seconds after the RSS feed has
been retrieved. Now we have a functional headline rotator.
$(document).ready(function() {
$('#news-feed').each(function() {
var $container = $(this);
$container.empty();
$.get('news/feed.xml', function(data) {
$('rss item', data).each(function() {
var $link = $('<a></a>')
.attr('href', $('link', this).text())
.text($('title', this).text());
var $headline = $('<h4></h4>').append($link);
var pubDate = new Date($('pubDate', this).text());
var pubMonth = pubDate.getMonth() + 1;
var pubDay = pubDate.getDate();
var pubYear = pubDate.getFullYear();
var $publication = $('<div></div>')
.addClass('publication-date')
.text(pubMonth + '/' + pubDay + '/' + pubYear);
var $summary = $('<div></div>')
.addClass('summary')
.html($('description', this).text());
$('<div></div>')
.addClass('headline')
.append($headline, $publication, $summary)
.appendTo($container);
});
var currentHeadline = 0, oldHeadline = 0;
var hiddenPosition = $container.height() + 10;
$('div.headline').eq(currentHeadline).css('top', 0);
var headlineCount = $('div.headline').length;
var pause;
var headlineRotate = function() {
currentHeadline = (oldHeadline + 1) % headlineCount;
$('div.headline').eq(oldHeadline).animate(
{top: -hiddenPosition}, 'slow', function() {
$(this).css('top', hiddenPosition);
});
$('div.headline').eq(currentHeadline).animate(
{top: 0}, 'slow', function() {
pause = setTimeout(headlineRotate, 5000);
});
oldHeadline = currentHeadline;
};
pause = setTimeout(headlineRotate, 5000);
});
});
});
Partially through
the animation, we can see one headline cropped at the top, and the next
coming into view cropped at the bottom:
Pause on hover
Even though the
headline rotator is now fully functioning, there is one significant
usability issue that we should address—a headline might
scroll out of the viewable area before a user is able to click on one
of its links. This forces the user to wait until the rotator has cycled
through the full set of headlines again before getting a second chance.
We can reduce the likelihood of this problem by having the rotator
pause when the user's mouse cursor hovers anywhere within the headline.
$container.hover(function() {
headline rotatorusability issue, addressingclearTimeout(pause);
}, function() {
pause = setTimeout(headlineRotate, 250);
});
When the mouse enters the headline area, the first .hover() handler calls JavaScript's clearTimeout() function. This cancels the timer in progress, preventing from being called. When the mouse leaves, the second .hover() handler reinstates the timer, thereby invoking headlineRotate() after a 250 millisecond delay. headlineRotate()
This simple code works fine most of the time. However, if the user moves the mouse over and back out of the<div>
quickly and repeatedly, a very undesirable effect can occur. Multiple
headlines will be in motion at a time, layering on top of each other in
the visible area.
Unfortunately, we need to perform some serious surgery to remove this cancer. Before the headlineRotate() function, we'll introduce one more variable:
var rotateInProgress = false;
Now, on the very first line of our function, we can check if a rotation is currently in progress. Only if the value of rotateInProgress is false do we want the code to run again. Therefore, we wrap everything within the function in an if statement. Immediately inside this conditional, we set the variable to true, and then in the callback of the second .animate() method, we set it back to false.
var headlineRotate = function() {
if (!rotateInProgress) {
rotateInProgress = true;
currentHeadline = (oldHeadline + 1)
% headlineCount;
$('div.headline').eq(oldHeadline).animate(
{top: -hiddenPosition}, 'slow', function() {
$(this).css('top', hiddenPosition);
});
$('div.headline').eq(currentHeadline).animate(
{top: 0}, 'slow', function() {
rotateInProgress = false;
pause = setTimeout(headlineRotate, 5000);
});
oldHeadline = currentHeadline;
}
};
These few additional
lines improve our headline rotator substantially. Rapid, repeated
hovering no longer causes the headlines to pile up on top of each
other. Yet this user behavior still leaves us with one nagging problem:
the rhythm of the rotator is thrown off with two or three animations
immediately following each other, rather than all of them evenly spaced
out at five-second intervals.
The problem is that more than one timer can become active concurrently if a user mouses out of the<div> before the existing timer completes. We therefore need to put one more safeguard into place, using our pause variable as a flag indicating whether another animation is imminent. To do this, we set the variable to false
when the timeout is cleared or when one completes. Now we can test the
variable's value to make sure there is no timeout active before we put
a new one in place.
var headlineRotate = function() {
if (!rotateInProgress) {
rotateInProgress = true;
pause = false;
currentHeadline = (oldHeadline + 1)
% headlineCount;
$('div.headline').eq(oldHeadline).animate(
{top: -hiddenPosition}, 'slow', function() {
$(this).css('top', hiddenPosition);
});
$('div.headline').eq(currentHeadline).animate(
{top: 0}, 'slow', function() {
rotateInProgress = false;
if (!pause) {
pause = setTimeout(headlineRotate, 5000);
}
});
oldHeadline = currentHeadline;
}
};
if (!pause) {
pause = setTimeout(headlineRotate, 5000);
}
$container.hover(function() {
clearTimeout(pause);
pause = false;
}, function() {
if (!pause) {
pause = setTimeout(headlineRotate, 250);
}
});
At last, our headline rotator can withstand all manner of attempts by the user to thwart it.
Retrieving a feed from a different domain
The news feed that we've
been using for our example is a local file, but we might want to
retrieve a feed from another site altogether. As we saw in Chapter 6,
AJAX requests cannot, as a rule, be made to a different site than the
one hosting the page being viewed. There, we discussed the JSONP data
format as a method for circumventing this limitation. Here, though,
we'll assume we cannot modify the data source, so we need a different
solution.
To allow AJAX to fetch this file, we'll use some server-side code as a proxy
for the request, so that JavaScript believes the XML file is on our
server even though it actually resides on a different one. We will
write a short PHP script to pull the content of the news feed to our
server, and relay that data to the requesting jQuery script. This
script, which we'll call feed.php, can be called in the same way feed.xml was fetched previously:
$.get('news/feed.php', function(data) {
// ...
});
Inside the feed.php file, we pull in the content of the news feed from the remote site, then print the content as the output of the script.
<?php
header('Content-Type: text/xml');
print file_get_contents('http://jquery.com/blog/feed');
?>
Note here that we need to explicitly set the content type of the page to text/xml so that jQuery can fetch it and parse it as if it were a normal, static XML document.
Some web-hosting providers may not allow the use of the PHP file_get_contents() function to fetch remote files because of security concerns. In these cases, alternative solutions, such as using the cURL library, may be available. More information on this library can be found at http://wiki.dreamhost.com/CURL.
Adding a loading indicator
Pulling in a remote file
like this might take some time, depending on a number of factors, so we
should inform the user that loading is in progress. To do this, we'll
add a loading indicator image to the page before we issue our AJAX request.
var $loadingIndicator = $('<img/>')
.attr({
'src': 'images/loading.gif',
'alt': 'Loading. Please wait.'
})
.addClass('news-wait')
.appendTo($container);
Then, as the first line of our $.get() function's success callback, we can remove the image from the page with a simple command:
$loadingIndicator.remove();
Now, when the page first
loads, if there is a delay in retrieving the headline content, we'll
see a loading image rather than an empty area.
This image is an animated GIF, and in a web browser will spin to signify that activity is taking place.
We can easily create new animated GIF images for use as AJAX loading indicators by using the service at http://ajaxload.info/.
Gradient fade effect
Before we put away
our headline rotator example, let's give it a finishing touch, by
making the headline text appear to fade in from the bottom of its
container. The effect will be a gradient fade, appearing as if the text is opaque at the top of the effect area and transparent at the bottom.
A single text element cannot
have multiple opacities simultaneously, however. To simulate this,
we'll actually cover up the effect area with a series of elements, each
of which has a different opacity. These slices with be<div> elements with a few style properties in common, which we can declare in our stylesheet:
.fade-slice {
position: absolute;
width: 20em;
height: 2px;
background: #efd;
z-index: 3;
}
They all have the same width and background-color properties as their containing element,<div id="news-feed">. This will fool the user's eye into thinking the text is fading away, rather than being covered up by another element.
Now we can create the<div class="fade-slice">
elements. To make sure we have the right number of them, first we'll
determine a height in pixels for the entire effect area. In this case,
we're choosing 25 percent of the<div id="news-feed"> height. We'll use a for loop to iterate across the height of this area, creating a new slice element for each 2-pixel segment of the gradient:
$(document).ready(function() {
$('#news-feed').each(function() {
var $container = $(this);
$container.empty();
var fadeHeight = $container.height() / 4;
for (var yPos = 0; yPos < fadeHeight; yPos += 2) {
$('<div></div>')
.addClass('fade-slice')
.appendTo($container);
}
});
});
Now we have 25 slices (one
for each 2-pixel segment of the 50-pixel gradient area), but they are
all piled up at the top of the container. For our trick to work, we
need each one to have a different position and opacity. We can use the
iteration variable yPos to mathematically determine each element's opacity and top properties:
$(document).ready(function() {
$('#news-feed').each(function() {
var $container = $(this);
$container.empty();
var fadeHeight = $container.height() / 4;
for (var yPos = 0; yPos < fadeHeight; yPos += 2) {
$('<div></div>').css({
opacity: yPos / fadeHeight,
top: $container.height() - fadeHeight + yPos
}).addClass('fade-slice').appendTo($container);
}
});
});
These calculations can be a bit tricky to visualize, so we'll lay out
the numbers in a table. The opacity values step up incrementally from
transparent to opaque, as the top values begin at the top of the fade
area (150) and grow to the container's height:
yPos
|
opacity
|
top
|
---|
0
|
0
|
150
|
2
|
0.04
|
152
|
4
|
0.08
|
154
|
6
|
0.12
|
156
|
8
|
0.16
|
158
|
|
…
| |
40
|
0.80
|
190
|
42
|
0.84
|
192
|
44
|
0.88
|
194
|
46
|
0.92
|
196
|
48
|
0.96
|
198
|
Keep in mind that since the top position of the final<div class="fade-slice"> is 198, its 2-pixel height will neatly overlay the bottom two pixels of the 200-pixel-tall containing<div>.
With our code in place, the
text in the headline area of the page now blends beautifully from
transparent to opaque as it overlaps the bottom of the container:
The finished code
Our first rotator is now
complete. The news items are now fetched from a remote server,
formatted, animated in and out of view on schedule, and beautifully
styled:
$(document).ready(function() {
$('#news-feed').each(function() {
var $container = $(this);
$container.empty();
var fadeHeight = $container.height() / 4;
for (var yPos = 0; yPos < fadeHeight; yPos += 2) {
$('<div></div>').css({
opacity: yPos / fadeHeight,
top: $container.height() - fadeHeight + yPos
}).addClass('fade-slice').appendTo($container);
}
var $loadingIndicator = $('<img/>')
.attr({
'src': 'images/loading.gif',
'alt': 'Loading. Please wait.'
})
.addClass('news-wait')
.appendTo($container);
$.get('news/feed.php', function(data) {
$loadingIndicator.remove();
$('rss item', data).each(function() {
var $link = $('<a></a>')
.attr('href', $('link', this).text())
.text($('title', this).text());
var $headline = $('<h4></h4>').append($link);
var pubDate = new Date($('pubDate', this).text());
var pubMonth = pubDate.getMonth() + 1;
var pubDay = pubDate.getDate();
var pubYear = pubDate.getFullYear();
var $publication = $('<div></div>')
.addClass('publication-date')
.text(pubMonth + '/' + pubDay + '/' + pubYear);
var $summary = $('<div></div>')
.addClass('summary')
.html($('description', this).text());
$('<div></div>')
.addClass('headline')
headline rotatorcode.append($headline, $publication, $summary)
.appendTo($container);
});
var currentHeadline = 0, oldHeadline = 0;
var hiddenPosition = $container.height() + 10;
$('div.headline').eq(currentHeadline).css('top', 0);
var headlineCount = $('div.headline').length;
var pause;
var rotateInProgress = false;
var headlineRotate = function() {
if (!rotateInProgress) {
rotateInProgress = true;
pause = false;
currentHeadline = (oldHeadline + 1)
% headlineCount;
$('div.headline').eq(oldHeadline).animate(
{top: -hiddenPosition}, 'slow', function() {
$(this).css('top', hiddenPosition);
});
$('div.headline').eq(currentHeadline).animate(
{top: 0}, 'slow', function() {
rotateInProgress = false;
if (!pause) {
pause = setTimeout(headlineRotate, 5000);
}
});
oldHeadline = currentHeadline;
}
};
if (!pause) {
pause = setTimeout(headlineRotate, 5000);
headline rotatorcode}
$container.hover(function() {
clearTimeout(pause);
pause = false;
}, function() {
if (!pause) {
pause = setTimeout(headlineRotate, 250);
}
});
});
});
});