Wicked Code

Client-side Paging for DataGrids

Jeff Prosise

Code download available at:WickedCode0402.exe(120 KB)

Contents

Introducing the ClientPageDataGrid Control
Inside ClientPageDataGrid
Caveats
Wrapping Up

Developers enjoy a love-hate relationship with the ASP.NET DataGrid control. On one hand, DataGrids vastly simplify the task of rendering data sources into stylish HTML tables. On the other hand, DataGrids do the bulk of their work on the server side, causing pages to post back to the server more often than most developers would like. A pageable DataGrid, for example, posts back to the server every time the user navigates from one page to another. Postbacks reduce performance and inhibit responsiveness. No wonder, then, that high on many a developer's wish list is a smarter DataGrid that pages through records without repeatedly posting back to the server.

Conceptually, building a pageable HTML table that does its paging on the client side isn't difficult, thanks to the magic of client-side scripting. The HTML page in Figure 1 shows one technique for implementing client-side paging. When viewed in a browser (see Figure 2), the page displays what appears to be one table. But actually, there are three tables encapsulated in <div> elements. The style attribute attached to each <div> ensures that only one table is visible at a time, and a few lines of JavaScript wired to the Previous and Next links programmatically hide the <div> that's currently displayed, while making the one before or after it visible. The appearing and disappearing tables create the illusion that a single table is used to page through records.

Figure 1 PagingTable.html

<html> <head> <script language="javascript"> <!-- function __onPrevPage () { for (var i=2; i<4; i++) { if (document.getElementById ("Page" +i). style.display == "block") { document.getElementById ("Page" + i). style.display = "none"; document.getElementById ("Page" + (i - 1)). style.display = "block"; break; } } } function __onNextPage () { for (var i=1; i<3; i++) { if (document.getElementById ("Page" + i). style.display == "block") { document.getElementById ("Page" + i). style.display = "none"; document.getElementById ("Page" + (i + 1)). style.display = "block"; break; } } } --> </script> </head> <body> <div id="Page1" style="display: block"> <table border="1" width="40%"> <tr><td>Page 1</td><td>Page 1</td><td>Page 1</td></tr> <tr><td>Page 1</td><td>Page 1</td><td>Page 1</td></tr> <tr><td>Page 1</td><td>Page 1</td><td>Page 1</td></tr> </table> </div> <div id="Page2" style="display: none"> <table border="1" width="40%"> <tr><td>Page 2</td><td>Page 2</td><td>Page 2</td></tr> <tr><td>Page 2</td><td>Page 2</td><td>Page 2</td></tr> <tr><td>Page 2</td><td>Page 2</td><td>Page 2</td></tr> </table> </div> <div id="Page3" style="display: none"> <table border="1" width="40%"> <tr><td>Page 3</td><td>Page 3</td><td>Page 3</td></tr> <tr><td>Page 3</td><td>Page 3</td><td>Page 3</td></tr> <tr><td>Page 3</td><td>Page 3</td><td>Page 3</td></tr> </table> </div> <a href="https://javascript:__onPrevPage ();">Prev</a>   <a href="https://javascript:__onNextPage ();">Next</a> </body> </html>

Figure 2 Pageable HTML Table

Figure 2** Pageable HTML Table **

The same technique can be used to create DataGrids that support client-side paging. The challenge is to modify the DataGrid class to produce output similar to that in Figure 1. In case you haven't guessed, that's precisely what this installment of Wicked Code is about: improving the ASP.NET DataGrid control by building a plug-in replacement that pages on the client. In addition to adding a useful new tool to your toolbox, it's a wonderful example of how to modify ASP.NET controls by deriving from them and adding functionality of your own.

Introducing the ClientPageDataGrid Control

ClientPageDataGrid is a DataGrid-derived class that adds client-side paging support to ASP.NET. Because it's a functional replacement for the DataGrid, you can use it wherever you'd normally use a DataGrid. Client-side paging comes absolutely for free and can even be turned off.

The Web page in Figure 3 does paging the normal way: by repeatedly posting back to the server. It hosts a DataGrid that's configured to paginate its output (AllowPaging="true") and show 16 records per page (PageSize="16"). Clicking either of the arrows in the pager at the bottom of the grid posts back to the server and fires a PageIndexChanged event, activating the OnNewPage method. The OnNewPage method sets the DataGrid's CurrentPageIndex property to the index of the next or previous page, thus causing that page to render back to the client.

Figure 3 ServerPaging.aspx

<%@ Import Namespace="System.Data" %> <%@ Import Namespace="System.Data.SqlClient" %> <html> <body> <form runat="server"> <asp:DataGrid RunAt="server" ID="MyDataGrid" AutoGenerateColumns="false" CellPadding="2" Width="100%" Font-Name="Verdana" Font-Size="8pt" AllowPaging="true" PageSize="16" OnPageIndexChanged="OnNewPage"> <Columns> <asp:BoundColumn HeaderText="Product ID" DataField="productid" ItemStyle-HorizontalAlign="center" /> <asp:BoundColumn HeaderText="Product Name" DataField="productname" /> <asp:BoundColumn HeaderText="Units in Stock" DataField="unitsinstock" /> </Columns> <HeaderStyle BackColor="teal" ForeColor="white" Font-Bold="true" HorizontalAlign="center" /> <AlternatingItemStyle BackColor="beige" /> </asp:DataGrid> </form> </body> </html> <script language="C#" runat="server"> void Page_Load (Object sender, EventArgs e) { if (!IsPostBack) BindDataGrid (); } void OnNewPage (Object sender, DataGridPageChangedEventArgs e) { MyDataGrid.CurrentPageIndex = e.NewPageIndex; BindDataGrid (); } void BindDataGrid () { SqlDataAdapter adapter = new SqlDataAdapter ("select productid, productname, unitsinstock from products", "server=localhost;database=northwind;Integrated Security=SSPI"); DataSet ds = new DataSet (); adapter.Fill (ds); MyDataGrid.DataSource = ds; MyDataGrid.DataBind (); } </script>

Figure 4 shows the same page implemented with ClientPageDataGrid, with changes shown in red. The DataGrid control has been replaced with ClientPageDataGrid. AllowPaging="true" has been changed to AllowClientPaging="true" to enable client-side paging, and PageSize="16" now reads ClientPageSize="16". Both the OnPageIndexChanged attribute and the OnNewPage method have been deleted. Neither is needed now that paging will be handled entirely on the client.

Figure 4 ClientPaging.aspx

<%@ Import Namespace="System.Data" %> <%@ Import Namespace="System.Data.SqlClient" %> <span xmlns="https://www.w3.org/1999/xhtml"><%@ Register TagPrefix="msdn" Namespace="MSDN" Assembly="ClientPageDataGrid" %></span><html> <body> <form runat="server"> <<span xmlns="https://www.w3.org/1999/xhtml">msdn:ClientPageDataGrid</span> RunAt="server" ID="MyDataGrid" AutoGenerateColumns="false" CellPadding="2" Width="100%" Font-Name="Verdana" Font-Size="8pt" <span xmlns="https://www.w3.org/1999/xhtml">ClientPageSize="16" AllowClientPaging="true"</span>> <Columns> <asp:BoundColumn HeaderText="Product ID" DataField="productid" ItemStyle-HorizontalAlign="center" /> <asp:BoundColumn HeaderText="Product Name" DataField="productname" /> <asp:BoundColumn HeaderText="Units in Stock" DataField="unitsinstock" /> </Columns> <HeaderStyle BackColor="teal" ForeColor="white" Font-Bold="true" HorizontalAlign="center" /> <AlternatingItemStyle BackColor="beige" /> <span xmlns="https://www.w3.org/1999/xhtml"></msdn:ClientPageDataGrid></span> </form> </body> </html> <script language="C#" runat="server"> void Page_Load (Object sender, EventArgs e) { if (!IsPostBack) BindDataGrid (); } void BindDataGrid () { SqlDataAdapter adapter = new SqlDataAdapter ("select productid, productname, unitsinstock from products", "server=localhost;database=northwind;Integrated Security=SSPI"); DataSet ds = new DataSet (); adapter.Fill (ds); MyDataGrid.DataSource = ds; MyDataGrid.DataBind (); } </script>

To take the ClientPageDataGrid control for a test drive, copy ClientPaging.aspx to a virtual directory (for example, wwwroot) on your ASP.NET Web server. Then copy the control assembly—ClientPageDataGrid.dll—to the virtual directory's bin subdirectory and open ClientPaging.aspx in your browser. You'll see a page similar to the one in Figure 5. Use the arrows to page backward and forward. ClientPaging.aspx looks and feels exactly like ServerPaging.aspx, but under the hood it is quite different. ServerPaging.aspx places more load on the Web server and can only page as quickly as the connection between client and server will allow. ClientPaging.aspx, by contrast, takes slightly longer to load due to the fact that it's pulling down all of the records rather than just one page worth, but once loaded it pages quite nimbly, regardless of the connection speed.

Figure 5 ClientPageDataGrid

Figure 5** ClientPageDataGrid **

ClientPageDataGrid's programmatic interface consists of four public properties that it adds to those inherited from DataGrid (see Figure 6). AllowClientPaging turns paging on and off. The default is true, so you can omit AllowClientPaging="true" from ClientPageDataGrid tags if you want. ClientPageSize controls the number of records shown on each page, while ClientPageCount retrieves the page count. ClientCurrentPageIndex determines which page is currently displayed. The following Page_Load method configures the ClientPageDataGrid named "MyDataGrid" to initially show Page 2 (whose zero-based index is 1) rather than Page 1:

void Page_Load (Object sender, EventArgs e) { if (!IsPostBack) { BindDataGrid (); MyDataGrid.ClientCurrentPageIndex = 1; } }

Figure 6 ClientPageDataGrid Public Properties

Property Name Description Type Read Write Default
AllowClientPaging Enables and disables client-side paging bool True
ClientPageSize Gets or sets the number of records displayed per page int 10
ClientPageCount Gets the current page count int   N/A
ClientCurrentPageIndex Gets or sets the index of the current page int 0

When a page that hosts a ClientPageDataGrid posts backs to the server, the ClientCurrentPageIndex property is updated to indicate which page was displayed on the client when the postback occurred. Between postbacks, the server does not know which page the user is currently viewing.

Inside ClientPageDataGrid

The ability of ClientPageDataGrid to output paginated HTML tables begins with the Render method, which is excerpted in Figure 7. (You can find the complete code download for this column at the link at the top of this article.) Render is a virtual method that all server controls inherit from System.Web.UI.Control. The Render method is called by the Microsoft® .NET Framework to render a control into HTML each time the page hosting the control is requested.

Figure 7 ClientPageDataGrid's Render Method

protected override void Render (HtmlTextWriter writer) { if (AllowClientPaging && Items.Count > 0) { for (int i=0; i<ClientPageCount; i++) { // Output a <div> tag writer.AddAttribute (HtmlTextWriterAttribute.Id, ClientID + "_page" + i.ToString ()); writer.AddAttribute (HtmlTextWriterAttribute.Style, i == ClientCurrentPageIndex ? "display: block" : "display: none"); writer.RenderBeginTag (HtmlTextWriterTag.Div); // Render a page inside the <div> ShowItems (i * ClientPageSize, Math.Min ((i * ClientPageSize) + ClientPageSize - 1, Items.Count - 1)); UpdatePager (i); base.Render (writer); // Output a </div> tag writer.RenderEndTag (); } // Restore all DataGrid items to the visible state ShowItems (0, Items.Count - 1); } else { base.Render (writer); } }

ClientPageDataGrid overrides DataGrid's Render method and calls the base class implementation not once, but several times. Specifically, ClientPageDataGrid calls DataGrid.Render once per page of output, and it encapsulates the output in <div> elements by positioning calls to base.Render between calls to RenderBeginTag and RenderEndTag. Before calling the base class's Render method, ClientPageDataGrid.Render invokes a local method named ShowItems to hide rows that don't appear on the page currently being rendered by setting the Visible properties of those rows to false.

For a ClientPageDataGrid configured to display 16 rows per page, the result is a Page 1 consisting of a <div> containing the grid's first 16 items, a Page 2 consisting of a <div> containing the next 16 items, and so on. All <div> elements but the one representing the page that's visible include style="display: none" attributes that prevent them from being seen in a browser. Only the <div> representing the current page—the page whose index equals ClientCurrentPageIndex—is rendered with a style="display: block" attribute to make it visible to the user (see Figure 8).

Figure 8 HTML Generated by ClientPageDataGrid

<div id="MyDataGrid_page0" style="display: block"> <table ...> ... </table> </div> <div id="MyDataGrid_page1" style="display: none"> <table ...> ... </table> </div> <div id="MyDataGrid_page2" style="display: none"> <table ...> ... </table> </div> <div id="MyDataGrid_page3" style="display: none"> <table ...> ... </table> </div> <div id="MyDataGrid_page4" style="display: none"> <table ...> ... </table> </div>

Each "page" rendered by ClientPageDataGrid includes a pager. The pager is present in the DataGrid because ClientPageDataGrid overrides DataGrid's DataBind method and calls it with AllowPaging set to true and PageSize set to the total number of items in the data source. Much of the code in ClientPageDataGrid.cs is devoted to making the pager work and ensuring that it supports both previous/next-style pagers (<PagerStyle Mode="NextPrev">) and numeric pagers (<PagerStyle Mode="NumericPages">).

A conventional DataGrid pager contains LinkButtons that post back to the server and fire PageIndexChanged events. ClientPageDataGrid replaces these LinkButtons with hyperlinks pointing to JavaScript functions that page on the client. The UpdatePager method (see Figure 9), which is called just before Render calls the base class's Render method, does the replacing. UpdatePager finds the pager by scanning the DataGrid for a row whose type is ListItemType.Pager. Then it deletes the controls inside the pager and adds controls of its own.

Figure 9 UpdatePager Method

void UpdatePager (int page) { // Get a reference to the pager TableCell pager = null; for (int i = Controls[0].Controls.Count - 1; i >=0; i—) { if (((DataGridItem) Controls[0].Controls[i]).ItemType == ListItemType.Pager) { pager = (TableCell) Controls[0].Controls[i].Controls[0]; break; } } if (PagerStyle.Mode == PagerMode.NextPrev) { // Implement a client-side Next-Prev pager pager.Controls.Clear (); pager.Controls.Add (CreatePrevPageControl (page)); pager.Controls.Add (new LiteralControl (" ")); pager.Controls.Add (CreateNextPageControl (page)); } else if (PagerStyle.Mode == PagerMode.NumericPages) { pager.Controls.Clear (); if (ClientPageCount <= PagerStyle.PageButtonCount) { // Implement a "1 2 3"-style numeric pager for (int i=0; i<ClientPageCount - 1; i++) { pager.Controls.Add (CreatePageControl (page, i)); pager.Controls.Add (new LiteralControl (" ")); } pager.Controls.Add (CreatePageControl (page, ClientPageCount - 1)); } else { // Implement a "... 1 2 3 ..."-style numeric pager int sections = (ClientPageCount + PagerStyle.PageButtonCount - 1) / PagerStyle.PageButtonCount; int section = page / PagerStyle.PageButtonCount; // Add leading "..." if not first section if (section > 0) { pager.Controls.Add (CreatePrevSectionControl (page, section)); pager.Controls.Add (new LiteralControl (" ")); } // Add page numbers int start = section * PagerStyle.PageButtonCount; int end = Math.Min (start + PagerStyle.PageButtonCount -1, ClientPageCount - 1); for (int i=start; i<end; i++) { pager.Controls.Add (CreatePageControl (page, i)); pager.Controls.Add (new LiteralControl (" ")); } pager.Controls.Add (CreatePageControl (page, end)); // Add trailing "..." if not final section if (section < sections - 1) { pager.Controls.Add (new LiteralControl (" ")); pager.Controls.Add (CreateNextSectionControl (page, section)); } } } }

Here's an example of the HTML that a conventional DataGrid control outputs when it renders a pager:

<tr> <td colspan="3"> <a href= "javascript:__doPostBack('MyDataGrid$_ctl20$_ctl0','')"> &lt;</a>  <a href="https://javascript:__doPostBack('MyDataGrid$_ctl20$_ctl1','')"> &gt;</a> </td> </tr>

Here's the same pager rendered by a ClientPageDataGrid control:

<tr> <td colspan="3"> <a href="https://javascript:__onPrevPage ('MyDataGrid');">&lt;</a>  <a href="https://javascript:__onNextPage ('MyDataGrid');">&gt;</a> </td> </tr>

The JavaScript functions referenced in the anchor tags are registered when ClientPageDataGrid.OnPreRender calls Page.RegisterClientScriptBlock. Calling RegisterClientScriptBlock ensures that the script block containing the functions is included in the output only once, even if a page hosts multiple instances of the ClientPageDataGrid control.

Even though a ClientPageDataGrid pager doesn't induce any postbacks of its own, other controls on the page might—and probably will—cause postbacks to occur. This presents two challenges. First, ClientCurrentPageIndex should be updated when a postback occurs so server-side code can determine which page was displayed when the postback occurred. Second, the ClientPageDataGrid rendered back to the client following a postback should preserve the current page. In other words, if Page 3 is displayed when the page posts back to the server, ClientPageDataGrid's rendering code should attach a style="display: block" attribute to the <div> element representing Page 3 rather than to the <div> element representing Page 1.

To meet these challenges, ClientPageDataGrid registers a hidden field named controlid__ PAGENUM:

Page.RegisterHiddenField (ClientID + "__PAGENUM", ClientCurrentPageIndex.ToString ());

The JavaScript functions that page the output on the client update the hidden field each time the current page changes. When the page posts back to the server, the value of the hidden field is included in the postback data. The LoadViewState method of ClientPageDataGrid reads the value from the postback data and assigns it to ClientCurrentPageIndex by calling a local method named RestoreCurrentPageIndex, which does the following:

string page = Page.Request[ClientID + "__PAGENUM"]; ••• ClientCurrentPageIndex = Convert.ToInt32 (page);

A postback event handler can now determine which page the user was viewing by reading the ClientCurrentPageIndex property. And because ClientPageDataGrid.Render uses ClientCurrentPageIndex to determine which <div> to make visible, the page that was visible before the postback occurred will still be visible following the postback.

Restoring the current page index from postback data in LoadViewState has a couple of advantages. First, the current page index can't be set until after the control is populated with items, and it's in LoadViewState that the control gets populated following a postback. Additionally, because LoadViewState is called before the host page fires a Load event, a Page_Load method can find out what page the user was viewing when the postback occurred by reading ClientCurrentPageIndex. That might be important because the current page index could be used to affect other changes to the page.

RestoreCurrentPageIndex is also called by the DataBind method of the ClientPageDataGrid, but only if it wasn't called from LoadViewState. Why? Because if view state is disabled (that is, if the control's EnableViewState property is false), then LoadViewState isn't called by the ASP.NET runtime. If view state is disabled, the page repopulates the control by calling DataBind, so the DataBind method is the natural place for a second attempt at restoring the current page index from postback data.

You might have noticed that the DataBind method of ClientPageDataGrid binds to the data source not once, but twice (see Figure 10). The first call to the base class's DataBind method provides ClientPageDataGrid with a record count. The second call, which is made with AllowPaging set to true and PageSize set to the record count, produces a DataGrid containing all the records in the data source with a pager at the bottom. This DataGrid lies just beneath the surface of the ClientPageDataGrid and serves as the source of all rendering.

Figure 10 DataBind Method

public override void DataBind () { if (AllowClientPaging && DataSource != null) { object OriginalDataSource = null; // If the data source is a DataReader, convert // it into a DataSet if (DataSource is IDataReader) { OriginalDataSource = DataSource; DataSource = DataSetFromDataReader ((IDataReader) DataSource); } // Bind once with paging disabled (and ItemCreated // and ItemDataBound events suppressed) to get an // item count _SuppressEvents = true; AllowPaging = false; base.DataBind (); _SuppressEvents = false; // Bind again with paging enabled to create a DataGrid // containing all items with a pager at the bottom PageSize = Items.Count; AllowPaging = true; base.DataBind (); // Restore the original data source if (OriginalDataSource != null) DataSource = OriginalDataSource; // Restore the current page index from postback data // if it hasn't been restored already if (Page.IsPostBack && AllowClientPaging && !_RestoreCurrentPageIndexCalled) RestoreCurrentPageIndex (); } else { base.DataBind (); } }

Another unusual aspect of ClientPageDataGrid's DataBind method is that it converts DataReaders into DataSets. The reason is twofold. First, a DataReader can only be bound to once because it's a forward-only data source. Second, ordinary DataGrids don't support DataReaders as data sources if AllowPaging is true unless AllowCustomPaging is also true. Internally converting DataReaders into DataSets neatly solves both problems and ensures that ClientPageDataGrid works as seamlessly with DataReaders as it does with DataSets.

Caveats

Are there drawbacks to paging on the client? You bet. The larger the recordset, the more slowly the page will load because all the records—not just the records comprising the current page—are returned in the HTTP response. For relatively small recordsets—say, 1,000 records or less—you probably don't care. For very large recordsets, load time could be unacceptably high.

An additional factor to consider when it comes to bandwidth utilization is the DataGrid's propensity to serialize its contents into view state. Large DataGrids increase download times in two ways. First, they generate large HTML tables. Second, they increase the view-state size. Because ASP.NET persists view state using a hidden field, a DataGrid's contents essentially appear once in every HTTP request and twice in every HTTP response. That's all the more reason to keep the record count small and resort to server-side paging for large recordsets. If you must use a ClientPageDataGrid to page through a large number of records, consider setting the control's EnableViewState property to false and binding to the data source in every page request (as opposed to only binding when Page.IsPostBack is false). This could decrease the size of the response by up to two-thirds.

A final thought to keep in mind regarding ClientPageDataGrid is that due to the way it renders individual pages, you'll probably want to make ClientPageSize an even number if you use the AlternatingItemStyle property to render odd-numbered items differently than even-numbered items. Otherwise, Page 1 will have a non-alternating item at the top, Page 2 will have an alternating item, and so on. The user may wonder why the format of the table changes each time he or she flips to the next or previous page.

Wrapping Up

The DataGrid class is a wonderful example of one of the most compelling features of ASP.NET: its ability to hide complex rendering and behavioral logic in reusable classes. ClientPageDataGrid takes the notion of server-side controls a step further and demonstrates not only how to modify built-in control types to work the way you want them to, but how to extend them to add client-side functionality as well. Server controls that rely on client-side script to work smarter and more efficiently are an idea whose time has come. Here's hoping that control authors agree.

Send your questions and comments to Jeff at  wicked@microsoft.com.

Jeff Prosise is a contributing editor to MSDN Magazine and the author of several books, including Programming Microsoft .NET (Microsoft Press, 2002). He's also a cofounder of Wintellect (https://www.wintellect.com), a software consulting and education firm that specializes in Microsoft .NET.