MVC paging extension with scrolling page numbers

In a project I worked on recently, I got the request to page the results of a search without showing all the page numbers, but only a limited set. I had to show only 10 pages and let them scroll forward if the user clicked a page in the right half (6 to 10) or backward if the click was in the left half (1 to 5).

Here are the steps I followed to have it done.

First, what we need are the total records, the records per page and the page number we are actually showing to the user.

Let us assume we are working with a class named Product with these properties

namespace Domain.Entities
{
  public abstract class AggregateRoot
  {
    public Guid Id { get; private set; }

    public AggregateRoot()
    {
      Id = Guid.NewGuid();
    }
  }

  public class Product : AggregateRoot
  {
    public string Title { get; private set; }
    public decimal Price { get; private set; }

    public Product(string title, decimal price)
    {
      Title = title;
      Price = price;
    }
  }
}

And a very simple layer for the queries. In the constructor, we also create some sample records

  public interface IQueries; where T : AggregateRoot
  {
    IList GetAll(int pageIndex, int pageSize, out int totalRecords);
    IList GetAll(Expression> where, int pageIndex, int pageSize, out int totalRecords);
  }

  public class ProductQueries : IQueries
  {
    private IList products;
    public ProductQueries()
    {
      products = new List();
      for (int i = 0; i < 200; i++)
        products.Add(new Product(String.Format("product{0}", i), i * 1.75M));
    }

    public IList GetAll(int pageIndex, int pageSize, out int totalRecords)
    {
      return GetAll(null, pageIndex, pageSize, out totalRecords);
    }

    public IList GetAll(Expression> where, int pageIndex, int pageSize, out int totalRecords)
    {
      pageIndex--;
      if (pageIndex < 0)
        pageIndex = 0;
      totalRecords = 0;
      IList result = new List();
      var query = products.AsQueryable();
      if (where != null)
        query = query.Where(where);
      totalRecords = query.Count();
      if (pageSize > 0)
        result = query.Skip(pageIndex * pageSize).Take(pageSize).ToList();
      else
        result = query.ToList();
      return result;
    }
  }

Now that we have a super simple infrastructure, that will give us the total records, we can concentrate on the helper. As stated before, we need the page size, page index and total records to do the math, but we also need the URL to render for each page. Something like “products/page1”, “products/page2” and so on. The solution is to pass a function with the Url.Action with where to put the page number

  public static class PageHelper
  {
    public static MvcHtmlString PageLinks(this HtmlHelper html, int totalItems, int itemsPerPage, int currentPage, string cssActive, int scrollPages, Func pageUrl)
    {
      if (totalItems == 0)
        return MvcHtmlString.Create("");
      if (currentPage < 1)
        currentPage = 1;
      var totalPages = (int)Math.Ceiling((decimal)totalItems / itemsPerPage);
      var startIndex = 1;
      var endIndex = totalPages;      
      
      var result = new StringBuilder();
      result.AppendLine("
    "); for (var i = startIndex; i <= endIndex; i++) { var liTag = new TagBuilder("li"); var tag = new TagBuilder("a"); tag.MergeAttribute("href", pageUrl(i)); tag.InnerHtml = i.ToString(); if (i == currentPage && !String.IsNullOrEmpty(cssActive)) liTag.AddCssClass(cssActive); liTag.InnerHtml = tag.ToString(); result.AppendLine(liTag.ToString()); } result.AppendLine("
"); return MvcHtmlString.Create(result.ToString());} }

The use of this helper will be like this:

@Html.PageLinks(Model.TotalRecords, Model.PageSize, Model.Page, "active", 10, x => Url.Action("Index", "Home", new { page = x }))

The missing piece is just the algorithm to show only a certain number of pages based on the page index.
What we must take into account, is the event the pages to show are an odd number, for example 5.
The question is, if a user click the three, what will happen? Should we go forward or backward? My choice is to do nothing and stay put.

Here are the bits with the solution I came up to

 if (scrollPages > 0)
 {
   bool isOdd = scrollPages % 2 != 0;
   int countRef = (int)Math.Ceiling((decimal)(((isOdd) ? scrollPages - 1 : scrollPages) / 2));
   if (isOdd)
     countRef++;
   if (currentPage > countRef)
   {
     startIndex = currentPage - countRef + 1;
     if (isOdd)
       countRef--;
     endIndex = currentPage + countRef;
   }
   else
   {
     startIndex = 1;
     endIndex = scrollPages;
   }
   if (endIndex > totalPages)
   {
     endIndex = totalPages;
     startIndex = totalPages - scrollPages + 1;
   }
   if (startIndex < 0)
   {
     currentPage = 1;
     startIndex = 1;
   }
 }

As you can see, I simply check if the number of pages to render is odd and act accordingly.
Last, from the controller, you just populate a ViewModel and pass it over to the View

    public ActionResult Index(int? page)
    {
      var model = new ProductsViewModel();
      model.Page = page ?? 0;
      int total = 0;
      model.Products = productQueries.GetAll(model.Page, model.PageSize, out total);
      model.TotalRecords = total;
      return View(model);
    }

The route rule I used is as follow

routes.MapRoute("", "products/page{page}", new { controller = "Home", action = "Index", page = 0 }, new { page = @"d+" });

The last part of the rule, use a RegEx to ensure that the parameter passed is only a number.
I hope this can give you some hints on how to solve a common problem. As usual you can find a working sample with the relevant tests on my GitHub at this URL.
Happy coding 🙂