Infinite Scroll in ASP.NET MVC

Recently I've decided to play a little bit with Infinite Scroll interaction design pattern as I was planning on using it in one of my upcoming projects. My key requirement was to implement the pattern as an enhancement which would not break the existing navigation/pagination mechanism for users with JavaScript disabled.

For starters I needed a view which would server content in paged chunks, so I took advantage of the Razor helpers and created this piece of code for rendering a pager:
@helper Pager(int currentPageIndex, int pageRecordsCount, int totalRecordsCount) {

int totalPagesCount = (int)Math.Ceiling((double)totalRecordsCount / (double)pageRecordsCount);
int numberOfPagesToDisplay = 10;

int firstDisplayedPageIndex = 1;
int lastDisplayedPageIndex = totalPagesCount;
if (totalPagesCount > numberOfPagesToDisplay)
{
int middleDisplayedPageIndex = (int)Math.Ceiling((double)numberOfPagesToDisplay / 2d) - 1;
firstDisplayedPageIndex = (currentPageIndex - middleDisplayedPageIndex);
lastDisplayedPageIndex = (currentPageIndex + middleDisplayedPageIndex);
if (firstDisplayedPageIndex < 4)
{
lastDisplayedPageIndex = numberOfPagesToDisplay;
firstDisplayedPageIndex = 1;
}
else if (lastDisplayedPageIndex > (totalPagesCount - 4))
{
lastDisplayedPageIndex = totalPagesCount;
firstDisplayedPageIndex = (totalPagesCount - numberOfPagesToDisplay + 1);
}
}

<div id="pager" class="ui-helper-clearfix">
@if (currentPageIndex > 1) {
@Html.ActionLink("<", "Products", new { page = currentPageIndex - 1 }, new { @class = "ui-state-default ui-corner-all" })
} else {
<span class="ui-state-default ui-corner-all ui-state-disabled">&lt;</span>
}
@if (firstDisplayedPageIndex > 1) {
@Html.ActionLink("1", "Products", new { page = 1 }, new { @class = "ui-state-default ui-corner-all" })
if (firstDisplayedPageIndex > 3) {
@Html.ActionLink("2", "Products", new { page = 2 }, new { @class = "ui-state-default ui-corner-all" })
}
if (firstDisplayedPageIndex > 2) {
<span>...</span>
}
}
@for (int displayedPageIndex = firstDisplayedPageIndex; displayedPageIndex <= lastDisplayedPageIndex; displayedPageIndex++) {
if (displayedPageIndex == currentPageIndex || (currentPageIndex <= 0 && displayedPageIndex == 0)) {
@Html.Raw(String.Format("<span class=\"ui-state-hover ui-corner-all\">{0}</span>", displayedPageIndex))
} else {
@Html.ActionLink(displayedPageIndex.ToString(), "Products", new { page = displayedPageIndex }, new { @class = "ui-state-default ui-corner-all" })
}
}
@if (lastDisplayedPageIndex < totalPagesCount) {
if (lastDisplayedPageIndex < totalPagesCount - 1) {
<span>...</span>
}
if (totalPagesCount - 2 > lastDisplayedPageIndex) {
@Html.ActionLink((totalPagesCount - 1).ToString(), "Products", new { page = totalPagesCount - 1 }, new { @class = "ui-state-default ui-corner-all" })
}
@Html.ActionLink(totalPagesCount.ToString(), "Products", new { page = totalPagesCount }, new { @class = "ui-state-default ui-corner-all" })
}
@if (currentPageIndex < totalPagesCount) {
@Html.ActionLink(">", "Products", new { page = currentPageIndex + 1 }, new { @class = "ui-state-default ui-corner-all" })
} else {
<span class="ui-state-default ui-corner-all ui-state-disabled">&gt;</span>
}
</div>
}
So this helper (with a little bit of CSS) should render nice pager like this:
jqGrid with caption layer enabled
For the model and repositories I have used good old Northwind database and Entity Framework Code First. I will skip implementation details here (all projects required by the solution are available through my repository at Codeplex) and go straight to view model:
public class ProductsViewModel
{
#region Properties
public int CurrentPageIndex { get; set; }

public int PageRecordsCount { get; set; }

public int TotalRecordsCount { get; set; }

public IEnumerable<Product> Products { get; set; }
#endregion
}
The last two things remaining are controller:
public class HomeController : Controller
{
#region Fields
IProductsRepository _productsRepository;
#endregion

#region Constructors
public HomeController()
: this(new ProductsRepository())
{ }

public HomeController(IProductsRepository productsRepository)
{
_productsRepository = productsRepository;
}
#endregion

#region Actions
public ViewResult Products(int? page)
{
ProductsViewModel viewModel = new ProductsViewModel()
{
CurrentPageIndex = page.HasValue ? page.Value : 1,
PageRecordsCount = 10,
TotalRecordsCount = _productsRepository.GetCount(String.Empty)
};
viewModel.Products = _productsRepository.FindRange(String.Empty, "Name", (viewModel.CurrentPageIndex - 1) * viewModel.PageRecordsCount, viewModel.PageRecordsCount);

return View(viewModel);
}
#endregion
}
and the actual view:
@model jQuery.InfiniteScroll.Models.ProductsViewModel
@{ ViewBag.Title = "Infinite Scroll in ASP.NET MVC"; }
@helper Pager(int currentPageIndex, int pageRecordsCount, int totalRecordsCount) {
...
}
<div id="list">
@foreach (Northwind.Model.Product product in Model.Products)
{
<div class="ui-corner-all">
<strong>Name:</strong> @product.Name<br />
<strong>Category:</strong> @product.Category.Name<br />
<strong>Supplier:</strong> @product.Supplier.Name<br />
<strong>Unit price:</strong> @product.UnitPrice<br />
<strong>Quantity per unit:</strong> @product.QuantityPerUnit<br />
<strong>Units in stock:</strong> @product.UnitsInStock<br />
</div>
}
</div>
@Pager(Model.CurrentPageIndex, Model.PageRecordsCount, Model.TotalRecordsCount)
The paged list is working. In order to enhance it with infinite scrolling I've decided to use Infinite Scroll jQuery Plugin. All what is needed to make this plugin work is reference to plugin file in the project and few lines of JavaScript in the view:
<script type="text/javascript">
$(document).ready(function () {
//Method is called on element containing the list
$('#list').infinitescroll({
//Selector for the pager
navSelector: '#pager',
//Selector for the anchor to the next page
nextSelector: '#pager span.ui-state-hover + a',
//Selector for the list items
itemSelector: '#list div.ui-corner-all',
loading: {
img: '@Url.Content("~/Content/images/ajax-loader.gif")',
msgText: '',
finishedMsg: ''
}
});
});
</script>
Now the plugin will hide the pager and make the requests for next pages when user scrolls to the bottom. If JavaScript is not enabled the old pager is still there to do the job (so this solution is completely SEO friendly). The only issue here is that users who has JavaScript enabled still need to request the entire page in the background instead of just the needed part. In some cases this can have quite huge impact on response size. So lets try to tweak this solution a little bit on the server side. The first step will be extracting the list into a partial view:
@model jQuery.InfiniteScroll.Models.ProductsViewModel
@helper Pager(int currentPageIndex, int pageRecordsCount, int totalRecordsCount) {
...
}
<div id="list">
@foreach (Northwind.Model.Product product in Model.Products)
{
<div class="ui-corner-all">
<strong>Name:</strong> @product.Name<br />
<strong>Category:</strong> @product.Category.Name<br />
<strong>Supplier:</strong> @product.Supplier.Name<br />
<strong>Unit price:</strong> @product.UnitPrice<br />
<strong>Quantity per unit:</strong> @product.QuantityPerUnit<br />
<strong>Units in stock:</strong> @product.UnitsInStock<br />
</div>
}
</div>
@Pager(Model.CurrentPageIndex, Model.PageRecordsCount, Model.TotalRecordsCount)
Now the view needs to be modified accordingly:
@model jQuery.InfiniteScroll.Models.ProductsViewModel
@{ ViewBag.Title = "Infinite Scroll in ASP.NET MVC"; }
@Html.Partial("ProductsList" , Model)
<script type="text/javascript">
$(document).ready(function () {
$('#list').infinitescroll({
navSelector: '#pager',
nextSelector: '#pager span.ui-state-hover + a',
itemSelector: '#list div.ui-corner-all',
loading: {
img: '@Url.Content("~/Content/images/ajax-loader.gif")',
msgText: '',
finishedMsg: ''
}
});
});
</script>
In the controller we could use Request.IsAjaxRequest() method to distinguish between standard and AJAX request but for testing and clarity I prefer having two separate methods. This is why I have created following attributes:
[AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = true)]
public sealed class AcceptAjaxRequestAttribute : ActionMethodSelectorAttribute
{
#region Properties
public bool Accept { get; private set; }
#endregion

#region Constructor
public AcceptAjaxRequestAttribute(bool accept)
{
Accept = accept;
}
#endregion

#region ActionMethodSelectorAttribute Members
public override bool IsValidForRequest(ControllerContext controllerContext, MethodInfo methodInfo)
{
if (controllerContext == null)
throw new ArgumentNullException("controllerContext");

bool isAjaxRequest = (controllerContext.HttpContext.Request["X-Requested-With"] == "XMLHttpRequest") || ((controllerContext.HttpContext.Request.Headers != null) && (controllerContext.HttpContext.Request.Headers["X-Requested-With"] == "XMLHttpRequest"));

return Accept == isAjaxRequest;
}
#endregion
}

[AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = true)]
public sealed class AjaxRequestAttribute : ActionMethodSelectorAttribute
{
#region Fields
private static readonly AcceptAjaxRequestAttribute _innerAttribute = new AcceptAjaxRequestAttribute(true);
#endregion

#region ActionMethodSelectorAttribute Members
public override bool IsValidForRequest(ControllerContext controllerContext, MethodInfo methodInfo)
{
return _innerAttribute.IsValidForRequest(controllerContext, methodInfo);
}
#endregion
}

[AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = true)]
public sealed class NoAjaxRequestAttribute : ActionMethodSelectorAttribute
{
#region Fields
private static readonly AcceptAjaxRequestAttribute _innerAttribute = new AcceptAjaxRequestAttribute(false);
#endregion

#region ActionMethodSelectorAttribute Members
public override bool IsValidForRequest(ControllerContext controllerContext, MethodInfo methodInfo)
{
return _innerAttribute.IsValidForRequest(controllerContext, methodInfo);
}
#endregion
}
With help of those attributes and ActionNameAttribute the controller can be modified like this:
#region Actions
[NoAjaxRequest]
public ViewResult Products(int? page)
{
return View(GetProductsViewModel(page));
}

[AjaxRequest]
[ActionName("Products")]
public PartialViewResult ProductsList(int? page)
{
return PartialView("ProductsList", GetProductsViewModel(page));
}
#endregion

#region Methods
private ProductsViewModel GetProductsViewModel(int? page)
{
ProductsViewModel viewModel = new ProductsViewModel()
{
CurrentPageIndex = page.HasValue ? page.Value : 1,
PageRecordsCount = 10,
TotalRecordsCount = _productsRepository.GetCount(String.Empty)
};
viewModel.Products = _productsRepository.FindRange(String.Empty, "Name", (viewModel.CurrentPageIndex - 1) * viewModel.PageRecordsCount, viewModel.PageRecordsCount);

return viewModel;
}
#endregion
Thanks to this the clients with JavaScript support will get only the required HTML while clients with JavaScript disabled will get the entire page for each request.

4 comments:

Anonymous said...

Doesn't work in IE8 or below

Tomasz Pęczek said...

I have just retested the sample application in IE6\IE7\IE8 and despite some issues with CSS the actual mechanism is working correctly. Are you having problems with the sample project or with your own implementation?

Jonny said...

Can you post this project in codeplex with the Northwind db already hooked up in an mdf file? Should I just be able to build project and launch this puppy into cyberspace?

Tomasz Pęczek said...

I'm not distributing Northwind with my samples because it would increase the size of downloads and I'm not sure what license is saying about redistributing of database. Also my enviroment is setup with no express version of SQL Server 2008 so that database wouldn't be usable for everybody.

The setup of Northwind is pretty straightforward, you can read description here: http://msdn.microsoft.com/en-us/library/bb399411.aspx (or in many other places on the web).