ASP.NET October 20, 2009

Twitter style paging with ASP.NET MVC and jQuery

paging-buttons I really like the simplicity of the AJAX paging at Twitter so I decided to use the same type of paging, including a similar more button, on the start page on this site. It actually surprised me how simple it was to build it, including fallback for visitors that doesn’t have javascript enabled (such as our dear friend Google), with ASP.NET MVC and a few lines of javascript with jQuery.

Basic controller logic

Being an advocate of progressive enhancement I started out by building a non-AJAX version of the feature. In the controller for the start page I let the default (Index) method have a nullable int parameter named entryCount which tells the method how many of the latest blog entries it should return for the view to display.

public class HomeController : Controller
{
    private const int defaultEntryCount = 10;

    public ActionResult Index(int? entryCount)
    {
        if (!entryCount.HasValue)
            entryCount = defaultEntryCount;
	
        //Retrieve the first page with a page size of entryCount 
        int totalItems;
        IEnumerable<Entry> entries = GetLatestEntries(1, entryCount.Value, out totalItems);
        
        if (entryCount < totalItems)
            AddMoreUrlToViewData(entryCount.Value);

        return View(entries);
    }

    private void AddMoreUrlToViewData(int entryCount)
    {
        ViewData["moreUrl"] = Url.Action("Index", "Home", new { entryCount = entryCount + defaultEntryCount });
    }
}

The method begins by making sure that the entryCount variable has a value, setting it to a default value if the parameter is null. It then retrieves as many of the latest blog entries as entryCount specifies by calling the GetLatestEntries method. I’ve omitted the GetLatestEntries method as it’s implementation will vary depending on blogging platform. The GetLatestEntries method also has an out parameter, totalItems, which tells us the total number of blog entries. I’m not a big fan of using out parameters but that’s the way the framework that I used for my blog at the time of writing this (EPiServer Community) works so I decided to follow that pattern for consistency. If you use some other type of blogging platform I would recommend making a field of the totalItems.

The method moves on to check if there are more blog entries than the  number that will be displayed, in other words if a link for showing more entries should be displayed. If so, it calls the AddMoreUrlToViewData which, you guessed it, adds a route URL for displaying more entries to the ViewData dictionary.

Finally the method returns a ViewResult with the list of blog entries as the model.

Creating the views

The Index view for the Home controller is very simple. It simply renders a partial view named EntryTeaserList, passing along the list of blog entries (Model) and the it’s ViewData dictionary.

<%@ Page Language="C#" MasterPageFile="~/Views/Shared/Site.Master" Inherits="System.Web.Mvc.ViewPage<IEnumerable<Entry>>" %>
<asp:Content ContentPlaceHolderID="PrimaryMainContent" runat="server">
    <% Html.RenderPartial("EntryTeaserList", Model, ViewData); %>
</asp:Content>

The partial view EntryTeaserList offers a bit more excitement. It renders an ordered list and displays a teaser for each blog entry by rendering another partial view, EntryTeaser, inside a list item, passing in each individual blog entry as model to it. It also checks if the ViewData dictionary contains a URL for a more link. If it does it renders a link with that URL in the href attribute.

<%@ Control Language="C#" Inherits="System.Web.Mvc.ViewUserControl<IEnumerable<Entry>>" %>
<div id="entryTeaserList">
    <ol>
        <% foreach (Entry item in Model) { %>
            <li class="entryTeaser">
                <% Html.RenderPartial("EntryTeaser", item); %>
            </li>
        <% } %>
    </ol>
    <% if(ViewData["moreUrl"] != null) { %>
        <a href='<%= ViewData["moreUrl"] %>' id="moreLink">More</a>
    <% } %>
</div>

Finally I made the link look like a button with some CSS.

#moreLink { 
-moz-border-radius: 6px; 
-webkit-border-radius: 6px; 
border: 1px solid #666666; 
background:url('/styles/gfx/more-bg.gif') repeat-x; 
width: 100%; 
display: block; 
text-align: center; 
padding: 0.4em 0 0.4em 0; 
font-weight: bold; 
color:#9aa57c;  
}
#moreLink:hover { 
background:url('/styles/gfx/more-bg.gif') 0 -64px; repeat-x; 
border: 1px solid #888888;
text-decoration: none; 
}

As you might have noticed I used CSS to round the buttons corners. This will only work for some browsers. In this particular case I though that that was OK, but in many other situations I would instead have used images or javascript. You might also have noticed that the button has the same background image when it’s hovered over as when it isn’t. The background image is however offset vertically so it appears that it’s actually another image. I did this to keep the number of HTTP requests required to load the page to a minimum.

Spicing things up with AJAX

With the controller and views set up as described above I was done with the non-AJAX functionality. This will work fine for visitors that doesn’t have javascript enabled or debugging purposes, but this kind of paging is pretty pointless if the page has to reload. After all the point is that when someone clicks the more button the experience shouldn’t be that another page is displayed but that the list, more or less instantly, just grows a bit.

To add the AJAX functionality I begun by modifying the controllers Index method.

public ActionResult Index(int? entryCount)
{
    if (!entryCount.HasValue)
        entryCount = defaultEntryCount;

    int totalItems;

    if(Request.IsAjaxRequest())
    {
        int page = entryCount.Value / defaultEntryCount;

        //Retrieve the page specified by the page variable with a page size o defaultEntryCount
        IEnumerable<Entry> pagedEntries = GetLatestEntries(page, defaultEntryCount, out totalItems);
        
        if(entryCount < totalItems)
            AddMoreUrlToViewData(entryCount.Value);
        
        return View("EntryTeaserList", pagedEntries);
    }

    //Retrieve the first page with a page size of entryCount
    IEnumerable<Entry> entries = GetLatestEntries(1, entryCount.Value, out totalItems);
    
    if (entryCount < totalItems)
        AddMoreUrlToViewData(entryCount.Value);

    return View(entries);
}

The added code checks if the current request is an AJAX request, with the IsAjaxRequest extension method that ships with MVC. IsAjaxRequest determines if the current request is an AJAX request by looking for and at the X-Requested-With request header. If such an header, or actually any request parameter with that name, is set to “XMLHttpRequest” the method will return true. As jQuery’s AJAX methods sets that header this method works great in this example.

Anyway, if the current request is an AJAX request we know that the visitors browser already displays a number of blog entry teasers and instead of returning the full number of entries specified by the entryCount parameter we should only return those that haven’t yet been sent to the visitors browser. So, we calculate what page (as if we where using traditional paging) is requested by dividing entryCount with the defaultEntryCount constant. Then we retrieve a list of the entries on that page with a page size of defaultEntryCount. That is we retrieve the defaultEntryCount number of entries with an offset of page*defaultEntryCount.

Finally, if there are more entries we set the moreUrl in the ViewData dictionary by calling the AddMoreUrlToViewData method and return a ViewResult. This time around however we don’t return the default view for the method. Instead we return the EntryTeaserList partial view. This way we don’t return more HTML than necessary but we are able to reuse an already existing view. We could of course have returned the result as JSON or XML instead but that would have forced us to write javascript for rendering the markup to display the result and thereby duplicating the same markup in two places.

The last thing I did was to add a few lines of javascript to intercept clicks on the more link.

$(function() {
    addMoreLinkBehaviour();
});

function addMoreLinkBehaviour() {
    $('#entryTeaserList #moreLink').live("click", function() {
        $(this).html("<img src='/images/ajax-loader.gif' />");
        $.get($(this).attr("href"), function(response) {
            $('#entryTeaserList ol').append($("ol", response).html());
            $('#entryTeaserList #moreLink').replaceWith($("#moreLink", response));
        });
        return false;
    });
}

When the DOM is ready we add a function to the click event of the more link, and, since I’m using the live function, to any future objects matching that selector. When the link is clicked two things initially happen. First the link’s text is replaced with an image to give the visitor some visual feedback if the response of the AJAX request isn’t instantly returned. Then an AJAX request is made to the same URL as the link had in it’s href attribute. That is, there’s no special URL for the AJAX request. This works as the controller takes care of determining what type of request it is.

When the server has responded with the partial view, that is an ordered list and possibly a new more link the list items are appended to the existing ordered list and the more link is replaced with the new more link if it exists. This way the more link is automatically updated with a new URL in it’s href attribute and the loading image is replaced with the original text.

Conclusion

I personally find this solution pretty elegant. It requires quite few lines of code and almost no duplicate logic or markup at all. It also offers full fallback functionality for visitors without javascript. However, if I was really interested in offering the best possible experience to human visitors with javascript disabled I could also give each blog entry teaser an id with it’s number in the list and include a hash tag with entryCount + 1 - defaultEntryCount in the more link’s target URL so that they would automatically be scrolled to the first entry that was added to the list. In my case I deemed that to be overkill though.

PS. For updates about new posts, sites I find useful and the occasional rant you can follow me on Twitter. You are also most welcome to subscribe to the RSS-feed.

Joel Abrahamsson

Joel Abrahamsson

I'm a passionate web developer and systems architect living in Stockholm, Sweden. I work as CTO for a large media site and enjoy developing with all technologies, especially .NET, Node.js, and ElasticSearch. Read more

Comments

comments powered by Disqus

More about ASP.NET