1.0 Introduction
"The search page looks great," said my customer, "but after I click on a search result and go to another page, I want my search page to come back sorted and filtered in the same way as I left it."
Regardless of what the requirements say, your users are frequently going to expect pages in your web site to retain their state in between visits. This is often called making a page "sticky." This article will demonstrate how to create a PersistentPanelControl
that makes this task trivial.
2.0 Using the Code
If you are not interested in the construction of the web control itself, then you can simply download the package and incorporate the DLL into any page by writing just three lines of code.
<%@ Register TagPrefix="util" Namespace="PersistentPanel" Assembly="PersistentPanel">
<util:PersistentPanelControl runat="server" id="persistentPanel">
<!-- Drop any Controls to be persisted here -->
</util:PersistentPanelControl>
The PersistentPanelControl
will remember the state of any server controls placed inside its body on any post-back event, and will restore the state of those controls when you return.
The default behavior of the control is to save all control state data to the Session
, but you can override this behavior by implementing the IControlPersistenceProvider
interface and specifying the new implementation in your site's web.config file.
<appSettings>
<add key="ControlPersistenceProviderType"
value="PersistentPanel.SessionControlPersistenceProvider,PersistentPanel"/>
</appSettings>
3.0 Building the Control
3.1 High Level Design
3.2.1 The Base Class
Many beginner and even intermediate ASP.NET developers are intimidated by the thought of building their own web control. The prospect of creating HTML rendering methods, child control maintenance logic, and visual designer code will often discourage a developer from writing a useful web control, and instead lead him to write the functionality that should be encapsulated by a control into the code-behind of the web page itself. One way to avoid the overhead of writing a web control from scratch (that is, from the WebControl
and CompositeControl
base classes) is to derive the control from an existing ASP.NET web control, and let the base control handle most of the ASP.NET plumbing, the developer to write only the functionality that is unique to your custom control.
Upon a little reflection, we realize that the only new requirement in our new control is to save the state of all the child controls between page requests. All of the logic required to manage a set of child controls, render them to the page, display the control and its children to the Visual Studio Designer, and integrate with the ASP.NET event model is already included in ASP.NET's built in System.Web.UI.WebControls.Panel
class! Thus, inheriting from Panel
allows us to write a control that focuses almost exclusively on the business logic of saving and restoring control state between requests.
3.2.2 Control Flow
We need to answer one question before proceeding further in our design: when should the control save the state of its child controls, and when should it restore that state? Among all possible scenarios, the most common usage will be when a user wants a page to remember the values he entered when he takes some action such as searching or filtering, and to remember it when returning to the page. We can reasonably distinguish these two scenarios using ASP.NET's post-back model. The control will save the state of all its children when the request is a post-back, and restore them to their saved state when the request is not a post back.
3.3.3 Using the Provider Pattern for Persistence
Another question that we need to answer before creating our component is where should our control persist its state? Unfortunately, this question is not straight forward, and may provide different answers based on different use cases. Some pages may only need to remember state for the current session, and can store that state in the Session
object. Some may not be able to use the Session
object, or may need to store the data between sessions. In this case, we may want to use browser cookies to store data that can persist between sessions. Still other applications may need to persist data to a database in order to allow users to keep their settings even when logging on from other computers.
In order to accommodate the potential needs of future users, the control uses the provider model for persisting the actual state information via the IControlPersistenceProvider
interface.
public interface IControlPersistenceProvider
{
void SaveControlState(Control parent, Dictionary<string,object> controlState);
Dictionary<string,object> RestrieveControlState(Control parent);
}
Good design and usability tenets demand that all component controls must be able to run out of the box with little or no configuration. Thus, our control will ship with a default implementation that saves state to the Session
object - a simple implementation that will satisfy the majority of cases.
3.2 Populating the Control State
The real business of the PersistentPanelControl
is encapsulated in the method to populate a dictionary containing the current values of all child controls (the control state), and the method to restore those control values from that dictionary. Because child controls can be nested within one another, these methods are not as simple as looping through all of the PersistentPanelControl
's child controls and remembering their state. Both methods must recursively populate each child's children in order to save the state of the entire control tree.
protected void PopulateControlState
(Control parent, Dictionary<string,object> controlState)
{
foreach (Control ctrl in parent.Controls)
{
if (ctrl.ID != null
&& !(ctrl is LiteralControl)
&& !(ctrl is Literal))
{
PopulateControlState(ctrl, controlState);
}
}
As you can see, the population method is recursive. The first call to this method will use the PersistentPanelControl
instance itself and an empty control state Dictionary
.
The process of saving an actual control's state to the Dictionary
depends on the type of control that we are saving. For example, the state should contain all selected values of a ListBox
control as an array of string
s, but only a single selected value string
for single value ListControl
objects.
if (ctrl is ListBox)
{
ListBox lst = (ListBox)ctrl;
List<string> itemValues = new List<string>(lst.Items.Count);
foreach (ListItem item in lst.Items)
{
if (item.Selected)
{
itemValues.Add(item.Value);
}
}
controlState[lst.ID] = itemValues.ToArray();
}
else if (ctrl is ListControl)
{
controlState[ctrl.ID] = ((ListControl)ctrl).SelectedValue;
}
For more complex controls, more complex logic must be written to accurately save state. For example, the persistence mechanism for GridView
controls must save both the sort expression, sort direction, and page index:
else if (ctrl is GridView)
{
GridView gv = (GridView)ctrl;
if (gv.AllowSorting)
{
controlState[ctrl.ID + SORT_INDEX_SUFFIX] = gv.SortExpression;
controlState[ctrl.ID + SORT_DIRECTION_SUFFIX] = gv.SortDirection.ToString();
}
if (gv.AllowPaging)
{
controlState[ctrl.ID + PAGE_NUMBER_SUFFIX] = gv.PageIndex;
}
}
Finally, many controls implement the ITextControl
interface. Placing the ITextControl
check at the end of the list is a way to save the text of several types of controls including TextBox
, Label
, and Button
that do not match a more specific control type.
else if (ctrl is ITextControl)
{
controlState[ctrl.ID] = ((ITextControl)ctrl).Text;
}
3.3 Restoring from the Control State
Restoring the child controls to their original state is simply the inverse of populating the state object. One caveat is that while most controls have their state saved in the Dictionary
under their control ID, complex objects like GridView
have their state saved under multiple keys using the combination of the control's ID and a prefix.
if (ctrl.ID != null && controlState.ContainsKey(ctrl.ID))
{
object controlValue = controlState[ctrl.ID];
if (ctrl is ListBox)
{
string[] selectedValues = controlValue as string[];
if (selectedValues != null)
{
foreach (ListItem li in ((ListBox)ctrl).Items)
{
li.Selected = selectedValues.Contains<string>(li.Value);
}
}
}
else if (ctrl is ListControl)
{
((ListControl)ctrl).SelectedValue = controlValue as string;
}
else if (ctrl is ITextControl)
{
((ITextControl)ctrl).Text = controlValue as string;
}
}
else if (ctrl is GridView)
{
GridView gv = (GridView)ctrl;
if (controlState.ContainsKey(gv.ID + SORT_INDEX_SUFFIX))
{
gv.AllowSorting = true;
SortDirection direction = (SortDirection)Enum.Parse(typeof(SortDirection),
controlState[gv.ID + SORT_DIRECTION_SUFFIX] as string);
gv.Sort(controlState[gv.ID + SORT_INDEX_SUFFIX] as string, direction);
}
if (controlState.ContainsKey(gv.ID + PAGE_NUMBER_SUFFIX))
{
gv.AllowPaging = true;
gv.PageIndex = (int)controlState[gv.ID + PAGE_NUMBER_SUFFIX];
}
}
3.4 Initializing the Persistence Provider
The high level design of the component calls for a pluggable way to persist the actual state of the control. The control will encapsulate this logic in a Read-Only property that first checks the web.config file's <appSettings>
section to see if a provider has been named, and defaults to the SessionControlPersistenceProvider
if no type is specified. Because the implementation is set in web.config, the control can safely save the instance of the provider to the application state.
private IControlPersistenceProvider _persistenceProvider = null;
public IControlPersistenceProvider PersistenceProvider
{
get
{
if (_persistenceProvider == null)
{
if (Page.Application[ApplicationStateKey] == null)
{
string providerType = ConfigurationManager.AppSettings
["ControlPersistenceProviderType"];
if (providerType == null)
{
_persistenceProvider = new SessionControlPersistenceProvider();
}
else
{
ConstructorInfo ctor =
Type.GetType(providerType).GetConstructor(new Type[0]);
_persistenceProvider =
(IControlPersistenceProvider)ctor.Invoke(new object[0]);
Page.Application[ApplicationStateKey] = _persistenceProvider;
}
}
else
{
_persistenceProvider =
(IControlPersistenceProvider)Page.Application[ApplicationStateKey];
}
}
return _persistenceProvider;
}
}
Default implementation, saving control state to the Session
object:
public class SessionControlPersistenceProvider : IControlPersistenceProvider
{
public void SaveControlState
(Control parent, Dictionary<string, object> controlState)
{
parent.Page.Session[CreateSessionKey(parent)] = controlState;
}
public Dictionary<string, object> RestrieveControlState(Control parent)
{
return parent.Page.Session[CreateSessionKey(parent)]
as Dictionary<string, object>;
}
private string CreateSessionKey(Control parent)
{
return parent.Page.ToString() + parent.UniqueID;
}
}
3.5 Order of Execution in the ASP.NET Page Life Cycle
Understanding the ASP.NET Page Life Cycle is of the greatest importance for web control developers. When the control saves and restores the state of its children in this life cycle is critical to proper functionality. If the control saves its state before the event handlers of its children have fired, then it will miss any updates that happen within those handlers. If the control restores the state too early, then the controls themselves may not yet have been initialized.
For the PersistentPanelControl
, the opportune time to save the control state is during the OnUnload
event of post-back requests. This event is fired after all controls have already been rendered, and their values are guaranteed not to change within the control's life cycle. The restore event will be fired during the OnInit
method, which occurs immediately after all child controls are initialized.
4.0 Demo Application
This control ships with a demonstration web page in the TestWebApp
project. This project contains a single Default.aspx page that contains a PersistentPanelControl
. The panel has children of various types for users to experiment with, including a GridView
. The grid view populates a list of products from the AdventureWorks2008
database, which is required for the demo to run.
5.0 Future Enhancements
We have just covered a very basic implementation of the PersistentPanelControl
. A full implementation would include:
- Support for more types of child controls
- A property to exclude named child controls from the persistence list
- A property to allow users to override the default implementation of
IControlPersistenceProvider
on an instance by instance basis - Descriptive exception messages
- Published events that will allow developers to write code before and after populating and restoring control state.
- Implementations of the
IControlPersistenceProvider
to save control state to a database, browser cookie, or other data storage method.
6.0 Summary
Creating a custom WebControl
can be intimidating at first, but by extending existing controls they can be quite easy to create. The next time you need some "plumbing" code in your web page, ask yourself if you could encapsulate that logic in a custom web control. The PersistentPanelControl
is a good example of how you can leverage an existing ASP.NET control to create a fully encapsulated, reusable, generic WebControl
to make a page's state sticky. It is useful in many situations where your application needs to store a page's state across pages, but a permanent user profile object is not appropriate.