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 class="ui-helper-clearfix" id="pager">
  @if (currentPageIndex &gt; 1) {
    @Html.ActionLink("&lt;", "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 &gt; 1) {
    @Html.ActionLink("1", "Products", new { page = 1 }, new { @class = "ui-state-default ui-corner-all" })
    if (firstDisplayedPageIndex &gt; 3) {
      @Html.ActionLink("2", "Products", new { page = 2 }, new { @class = "ui-state-default ui-corner-all" })
    }
    if (firstDisplayedPageIndex &gt; 2) {
      <span>...</span>
    }
  }
  @for (int displayedPageIndex = firstDisplayedPageIndex; displayedPageIndex &lt;= lastDisplayedPageIndex; displayedPageIndex++) {
    if (displayedPageIndex == currentPageIndex || (currentPageIndex &lt;= 0 &amp;&amp; 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 &lt; totalPagesCount) {
    if (lastDisplayedPageIndex &lt; totalPagesCount - 1) {
      <span>...</span>
    }
    if (totalPagesCount - 2 &gt; 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 &lt; totalPagesCount) {
   @Html.ActionLink("&gt;", "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:

Simple pager

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
{
  public int CurrentPageIndex { get; set; }

  public int PageRecordsCount { get; set; }

  public int TotalRecordsCount { get; set; }

  public IEnumerable<product> Products { get; set; }
}

The last two things remaining are controller:

public class HomeController : Controller
{  
  IProductsRepository _productsRepository;

  public HomeController()
    : this(new ProductsRepository())
  { }

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

  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);
  }
}

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) &amp;&amp; (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:

[NoAjaxRequest]  
public ViewResult Products(int? page)  
{  
  return View(GetProductsViewModel(page));  
}  

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

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;  
}

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.