Click here to Skip to main content
Click here to Skip to main content

Two-Way Data Binding in ASP.NET

, 12 Apr 2005
Rate this:
Please Sign up or sign in to vote.
Demonstrates how to get two-way databinding work in ASP.NET without subclassing all your controls.

Foreword

I was just finishing this article off when Manuel Abadia released an article with the same name ('Two way data binding in ASP.NET'), topic and (perhaps not surprisingly) some of the same class names. I'd been gazumped (if not just plain beaten)! This came as a bit of a shock, but I've decided to release this anyway since our approaches are slightly different and hopefully between the two people may find something useful. Unfortunately the submission of this article got delayed by about eight months whilst I went traveling (I'm finishing it off on the laptop camped by a creek in Australia's NT), so it's not quite as 'hot off the press' as it once was, and is almost obsolete already (since VS 2005 supports two-way binding). Ho hum.

Still, for what it's worth, here's how to implement the "Two-way databinding in ASP.NET".

NB: Throughout the article where I refer to 'databinding' without specifying which type (simple or complex) I'm referring to simple databinding.

Introduction

This article describes a two-way databinding scheme for ASP.NET, which extends the built-in simple databinding support to allow for automatic updates back to the original DataSource(s). The intent was to support a scheme that - like the built-in simple databinding - required no support from the bound controls themselves, rather was a 'framework' functionality available through a Page class. Additionally I hoped to design a scheme that would work similar to how a two-way databinding in VS 2005 would work when released (which I still haven't got round to verifying yet) so pages should require minimum work to port over.

Background

After my initial honeymoon period with ASP.NET had elapsed, the absence of two-way binding was the first of various 'features' to drive me back into slightly more critical mode, but I never got round to doing anything about it. I was eventually goaded into action by various articles describing 'the answer' as being sub classing all of your controls to support databinding intrinsically. This would have been bearable, had I not then ended up working for a company in which the developers had done exactly that, and it wasn't a nice sight.

Now just in case anyone's wondering what's wrong with the 'subclass everything' approach:

  • It's highly labour intensive.
  • It pre-supposes the controls you wish to use aren't sealed, and are amenable to sub classing in this manner. If not then you're going to have to compose them and delegate most of the functionality.
  • It's not integrated with the built in binding support in the designer.

The first is, surely, the killer. For every type of control you want to use in your two-way binding you're going to subclass it? You've got to be kidding. It defeats the whole point of having reusable controls in the first place.

The real solution, surely, is to come up with a scheme that can take the existing databinding information and perform its function in reverse, and that's what I set about. My solution had to be along the lines of how I thought Microsoft would have probably done it had they had more time, which became especially pertinent when I learnt that Whidbey will (should) support two-way databinding - I'm hoping pages built using this system will just 'slot in' to Whidbey when it comes out with minimum tweaking.

As I saw it, there were three main parts to the solution:

First, I needed to make sure I understood what happened normally. Along the way I learnt some things about simple databinding that I didn't know or had forgotten, so even guru's might learn something in this next section.

Simple DataBinding - a (brief) refresher

Simple databinding is set up by entering databinding expressions into the relevant attribute of the control's ASPX tag. So to bind the Text property of a TextBox 'txtName' to DataSet 'demoData1', table 'Table1', column 'Name', you'd do something like this:

   <asp:TextBox id="txtName" Runat="server" 
     Text='<%# DataBinder.Eval(demoData1, 
       "Tables[Table1].DefaultView.[0].Name") %>'/>

These databinding expressions work for all System.UI.Web.Controls, but are only supported in the designer for controls derived from System.UI.Web.WebControls.WebControl. The designer support (apart from not showing the databinding expression as the contents of the control) provides the ability to create and edit the binding expressions without having to go into the ASPX source code - just select the ellipsis ('...') next to (DataBindings) in the control's property grid.

The designer can do this because all Controls implement IDataBindingsAccessor, which allows access to a DataBindingCollection detailing which properties of that control are bound to what expressions. Unfortunately (and this would be a very short article otherwise) this collection is only available at design time, having been dynamically built from the ASPX source when the page loads in the designer. The actual persistence format for the databinding information is that embedded in the ASPX source as shown above.

This is important because it enables databinding to be performed with a simple, declarative syntax, and doesn't require the use of Visual Studio .NET. However from the point of view of re-using that binding information it leaves a lot to be desired.

So what happens at run-time? If you'd hoped that all that binding information would get parsed by the page builder and stored somewhere, you'd be wrong. The ultimate destination for that information is the dynamically-generated Page class, where each control's bindings are used to generate a method that's hooked to the relevant control's DataBind event:

[An example of the auto-generated code generated from your ASPX page at runtime:]

private System.Web.UI.Control __BuildControltxtName() {
// Other contents removed for clarity

#line 18 "c:\inetpub\wwwroot\TwoWayDataBindingDemo\BuiltInSimpleBinding.aspx"
__ctrl.DataBinding += 
        new System.EventHandler(this.__DataBindtxtName);

return __ctrl;
}

public void __DataBindtxtName(object sender, System.EventArgs e) {
   System.Web.UI.Control Container;
   System.Web.UI.WebControls.TextBox target;
    
   #line 18 "c:\inetpub\wwwroot\TwoWayDataBindingDemo\BuiltInSimpleBinding.aspx"
   target = ((System.Web.UI.WebControls.TextBox)(sender));
    
   #line default
    
   #line 18 "c:\inetpub\wwwroot\TwoWayDataBindingDemo\BuiltInSimpleBinding.aspx"
   Container = ((System.Web.UI.Control)(target.BindingContainer));
    
   #line default
    
   #line 18 "c:\inetpub\wwwroot\TwoWayDataBindingDemo\BuiltInSimpleBinding.aspx"
    target.Text = System.Convert.ToString(DataBinder.Eval(demoData1, 
                               "Tables[Table1].DefaultView.[0].Name"));
   #line default
}

There're two things to note here:

  1. We're not going to be able to do anything useful with this. The binding information is buried in the source and it would be pretty nasty to have to extract it.
  2. The inline type conversion that happens at binding is not the smartest. Apart from Convert.ToString(), it pretty much assumes that the data value can be cast straight to the control property type. Having spent a lot of time implementing TypeConverters for better design-time support I'd rather hoped for something better when it came to databinding - as much as anything it falls over for DBNull, which is not uncommon.
  3. Most of the work is still being delegated to Databinder.Eval().

Retrieving the binding information

As a result of the above it became clear that one way or another the binding information had to be loaded back into the page. Since it was stored in the ASPX source, I was going to parse the source and retrieve it. Fortunately I'd already written a class that made this a lot easier - HtmlTag - so all I had to do was:

  • Load up the ASPX source for the page.
  • Create HtmlTag instances for each tag in the document source.

(Author's note: I never really liked this, and ManuDev's solution of using a custom control to persist the binding information somewhere else on the page at design time to make it available for runtime is much much neater. It does suffer, however, in that it requires Visual Studio .NET and duplicates the binding information (so HTML-level code edits need to be done in two places if you're not going to go back through the designer). Nevertheless its generally more palatable.)

To store the binding information, I created a class DataBindingInfo which represents an individual attribute binding on a given server side control. DataBindingInfo stores the binding expression already 'split up', so our example binding:

   <asp:TextBox id="txtName" Runat="server" 
       Text='<%# DataBinder.Eval(demoData1, 
         "Tables[Table1].DefaultView.[0].Name") %>'/>

would be used to generate a DataBindingInfo like this:

   new DataBindingInfo(txtName.ID, "Text", "demoData1", 
             "Tables[Table1].DefaultView.[0].Name", "");

where txtName is the server side control generated from the tag at runtime, "demoData1" will later on be resolved to a property demoData1 on the Page class and the final empty string argument represents DataBinder.Eval()'s optional format string argument (in fact in the code sample you won't see the constructor being explicitly called like this, since DataBindingInfoCollection has an overloaded Add() method that takes the same arguments and calls the constructor for you).

The job was then simply to populate a collection of these from the binding information parsed from the source.

I'm not going to go into too much detail about this, because all I actually did was to re-use an existing class I'd written - HtmlTag - to look for tags in the source that had attributes that matched the databinding syntax (expressed in the Regex dataBoundAttributeMatcher). For those I used the HtmlTag's ID property to find the control instance using page.FindControl() and created a new DataBindingInfo from the combination of the control, the name of the attribute (which maps to a property on the control) being bound and the binding expression that is being used for the binding (split as detailed above).

The code ended up looking like this (please note that System.Web.UI has been aliased to the WebUI namespace in this sample):

/// <span class="code-SummaryComment"><summary>
</span>
/// Generate at DataBindings dictionary by parsing the ASPX source
/// for the hosting page
/// <span class="code-SummaryComment"></summary>
</span>
private DataBindingInfoCollection CreateDataBindings() {
    DataBindingInfoCollection bindings = new DataBindingInfoCollection();

    // Load the page's source ASPX into a big fat string
    string pageSource =GetFileContents(page.Request.PhysicalPath);

    // Attack the string looking for Html tags
    // TODO: Want to clean this up. Should just 
    // enumerate through HtmlTag.FindTags() or something...
    MatchCollection matches =new Regex(HtmlParsing.RegExPatterns.HtmlTag, 
      RegexOptions.Compiled | RegexOptions.IgnoreCase).Matches(pageSource);
    HtmlParsing.HtmlTag tag;

    foreach(Match tagMatch in matches){
        tag =new HtmlParsing.HtmlTag(tagMatch.Value);

        // Defer most of the real work to a helper routine
        AddBindingsForTag(tag, bindings);
    }

    return bindings;
}

/// <span class="code-SummaryComment"><summary>
</span>
/// If we can resolve a <span class="code-SummaryComment"><see cref="Control="/> for this tag,
</span>
/// loop through all its attributes, and create bindings for
/// any that have contain binding expressions
/// <span class="code-SummaryComment"></summary>
</span>
/// <span class="code-SummaryComment"><param name="bindings">A <see cref="DataBindingCollection"/>
</span>
/// to add the newly created bindings too<span class="code-SummaryComment"></param>
</span>
private void AddBindingsForTag(HtmlParsing.HtmlTag tag, 
                           DataBindingInfoCollection bindings){
    string attribName, attribValue;
    BindingExpression attribExpression;
    DataBindingInfo bindingInfo;

    // If we can actually determine the control for this tag...
    if (tag.ID!=String.Empty){
        Control control    =page.FindControl(tag.ID);
        if (control!=null){

            // Then we loop through the attributes looking 
            // for databinding expressions
            foreach(System.Collections.DictionaryEntry item in tag.Attributes){
                attribName        =item.Key.ToString();
                attribValue        =item.Value.ToString();

                // If it's a <%# ... %> type attribute
                if (attribValue!=String.Empty && 
                      attribValue.StartsWith(webBindingPrefix) && 
                      attribValue.EndsWith(webBindingSuffix)){

                    // Trim off the start/end delimiters
                    attribValue = attribValue.Substring(webBindingPrefix.Length, 
                                  attribValue.Length - webBindingPrefix.Length - 
                                                       webBindingSuffix.Length);
                    
                    // Now see if it's still a valid binding expression
                    attribExpression    =BindingExpression.Parse(attribValue);
                    if (attribExpression!=null){

                        // ...and if so add it to the bindings 
                        // collection for this control
                        bindingInfo =bindings.Add(control.ID, attribName, 
                                            attribExpression.DataSource, 
                                            attribExpression.DataExpression, 
                                            attribExpression.FormatString);
                        bindingInfo.IsTwoWay = 
                                      IsTwoWayProperty(attribName, control);
                    }
                }
            }
        }
    }
}

(The source for the HtmlTag is included with the demo project if you're interested - it's just a wrapper over a couple of RegEx's).

For performance reasons, the DataBindingInfoCollection for any given page is stuffed in the ASP Cache object, so it's not always being re-calculated:

/// <span class="code-SummaryComment"><summary>
</span>
/// Generate at DataBindings dictionary by parsing the ASPX source
/// for the hosting page, or retrieving from the <span class="code-SummaryComment"><see cref="Cache"/>
</span>
/// if possible
/// <span class="code-SummaryComment"></summary>
</span>
public DataBindingInfoCollection GetDataBindings(){
    DataBindingInfoCollection bindings = 
                          page.Cache[CacheKey] as DataBindingInfoCollection;
    if (bindings==null){
        // No bindings in the cache, create new and add
        // There's an argument for having a lock{} statement here,
        // but there's no harm in having an overlap
        // Object identity of the binding collection in the cache is not
        // a big issue, but the locking could cause concurrency issues
        bindings    =CreateDataBindings();
        CacheDependency depends =new CacheDependency(page.Request.PhysicalPath);
        page.Cache.Add(CacheKey, bindings, depends, Cache.NoAbsoluteExpiration, 
               Cache.NoSlidingExpiration, CacheItemPriority.BelowNormal, null);
    }
    return bindings;
}

Now I always had a few reservations about having to do this, so in fact this is just one possible strategy that a DataBoundPage can use to retrieve its DataBindingInfoCollection. Specifically the derived class can override the BindingInfoProvider property to return an IDataBindingInfoProvider of its choice. I've been experimenting with one that collects the binding information at design-time in the IDE - DesignerBindingInfoProvider- which is included, but not yet entirely functional. This is implemented as a functioning IExtenderProvider, thanks to Wouter van Vugt's article 'ASPExtenderSerializer', which provides a custom CodeDomSerializer to replace VS 2002/3's buggy one.

Unfortunately it looks like this will stop working in VS 2005, because IComponents aren't properly supported in the ASP.NET designer any more. This is a bigger issue than this soon-to-be-obsolete article, so start complaining. I want my IExtenderProviders...

Resurrecting the DataSource

Typically the original DataSource is created in a fairly ad-hoc manner, just before consumption, normally something like this:

private void Page_Load(object sender, System.EventArgs e)
{
    if (!IsPostBack){

        // This assumes your controls are bound to dataSet1 in some way
        dataAdapter.Fill(dataSet1);
        DataBind();
    }
}

Once bound the controls retain what data was bound into them (for postbacks), but the original DataSet is not persisted automatically. This is intentional - DataSets can be quite large structures, with lots of metadata and versioning information, and to persist it (either in ViewState or in Session) would have performance implications (not to mention there being no easy way to determine which DataSets to persist and which were transient).

What this does mean is that before we unbind the data, we've got to get the DataSet back. I've provided a framework for doing this in the DataBoundPage class. This defines a template method DataUnbind for the steps you need to perform for reverse databinding. Specifically implementing pages should override EnsureDataSource() to re-create the DataSet (if necessary) as this method is called prior to performing 'unbinding'. You worry about getting the DataSet back, we'll do the rest:

/// <span class="code-SummaryComment"><summary>
</span>
/// Updates the datasource from the bound controls, firing the relevant events
/// <span class="code-SummaryComment"></summary>
</span>
protected virtual void DataUnbind(){
    EnsureDataBindings();
    EnsureDataSource();
    OnDataUnbinding();
    AutoDataUnbind();
}

Unfortunately without the original DataSet you've lost all your concurrency protection. If you merely implement EnsureDataSource() to essentially re-run the original query again and the data has been changed, the 'original' row in our new dataset will contain the newly changed data, so the concurrency violation won't get caught automatically and we'll overwrite someone else's changes.

So what we need is a space-efficient way of persisting the original DataSet, complete with original/modified copies of changed rows, for which there are essentially two options:

  • Save the original DataSet into ViewState, but Remove() all the rows other than the bound row first to save space.
  • Save an XML DiffGram of any bound rows, and use it to re-create just those rows in the DataSet later.
  • Re-generate the DataSet from the DataSource, but manually check for concurrency issues at the point you load the data. This is simplest if your database supports a timestamp column of some nature that can be used as a version marker for a given row.
  • Store a version marker (or a hash) of the row in ViewState and implement your own concurrency handling.

Which you choose will depend on your exact needs, and whether you really want to have to tinker with all of those IDE-generated stored procs in your database or not.

TwoWayDataBinder.Eval()

This is, of course, the interesting bit. We've retrieved all the binding information and resurrected the DataSource, but without a way of being able to re-map the bound control data back from where it came we've achieved nothing. What we need is to be able to replicate DataBinder.Eval()'s behaviour, but in reverse.

To recap, the DataBinder.Eval() method performs the handy task of taking an object reference and a string expression and 'walks' the expression, resolving each part into a property (or indexer) on the object, retrieving its value and then using that value to recourse down into the next part of the expression. That's a complicated way of explaining something that's quite intuitive to understand when you see what it does (pseudo code):

DataBinder.Eval(obj1, "property1.property2")

// value of 'property1' evaluated on obj
// value of 'property2' evaluated on above value (obj.property1)
// result of above returned (obj.property1.property2)

It's actually fairly easy to write some code to do all this (using reflection), and so it's fairly easy to write some code that does exactly the same but when it reaches the end of the expression it sets the property to a value passed in as an extra argument, rather than getting and returning the value of the property. Assuming we'd called this method DataBinder.UnEval(), reversing the databinding would then be a case of looping through a DataBindingInfoCollection, and performing the following steps to each item:

  • Resolve the property on the control specified by the attribute name, and retrieve its value (e.g. for a Textbox typically you'd bind to the Text property).
  • Pass that value to DataBinder.UnEval() method along with the control reference and the binding expression. It'd then walk the binding expression and set the appropriate value in the data source to whatever Text was set to.

However this produces a chicken-and-egg situation when it comes to type conversion. Say property2 is an Int32. You've bound it to textBox.Text just fine (since the built in databinding can stretch to turning an integer into a string), but to reverse the binding you've got to convert it back into an integer. However you don't know that property2 is an integer beforehand, because its only when you parse the expression that you get a reference to property2, so how do you know what to convert it to? You could use DataBinder.Eval() to get property2's current value, then use the type of that to convert Text (if required) and then run DataBinder.UnEval(), but it seems a bit wasteful to have to walk the expression twice (not to mention the problems you'd get if the initial value was null).

What you need is a way of evaluating the expression and returning a reference to the property at the end of the expression, rather than its value. That way the expression parsing code gets run once, and the result can be used to get, set, and look at type information. But since .NET's reflection property handlers (PropertyInfo and PropertyDescriptor) are instance-less (that is to say once we had a reference to property2 we'd still need the value of property1to actually get/set the property2value) we're going to have to return both the property and the object to which it refers.

For this I created IMemberInstance. An IMemberInstance represents a property reference bound up with the original object instance on which the property can be found. As such an IMemberInstance can be used to get/set the value of the property without having to supply the object instance to work on, which makes it ideal for a return value for our TwoWayDataBinder.Eval() method. Additionally, since IMemberInstance is fairly generic, it can be used as the common ground for different types of 'property instance' - i.e. IMemberInstance implementations can use PropertyInfo, PropertyDescriptoror other mechanisms internally and it doesn't matter to the client. This was definitely a win-win, as we'll see later. I have covered this topic in more detail in a separate article about stateful reflection using IMemberInstance.

/// <span class="code-SummaryComment"><summary>
</span>
/// Interface for a 'property' on a specific object instance.
/// The interface allows get/set of the property value without
/// having to re-suppy the object instance, or knowlege of
/// the underlying implementation of the 'property'
/// <span class="code-SummaryComment"></summary>
</span>
public interface IMemberInstance
{
    /// <span class="code-SummaryComment"><summary>
</span>
    /// Retrieve the object instance that this 
    /// IMemberInstance manipulates
    /// <span class="code-SummaryComment"></summary>
</span>
    object Instance{
        get;
    }

    /// <span class="code-SummaryComment"><summary>
</span>
    /// Retrieves the name of the property
    /// <span class="code-SummaryComment"></summary>
</span>
    string Name {
        get;
    }

    /// <span class="code-SummaryComment"><summary>
</span>
    /// Gets/sets the value of <span class="code-SummaryComment"><c>Name</c> on <c>Instance</c>
</span>
    /// <span class="code-SummaryComment"></summary>
</span>
    object Value {
        get;
        set;
    }

    /// <span class="code-SummaryComment"><summary>
</span>
    /// Retrieves the type of the property
    /// <span class="code-SummaryComment"></summary>
</span>
    Type Type {
        get;
    }

    /// <span class="code-SummaryComment"><summary>
</span>
    /// Retrieves an appropriate TypeConverter for 
    /// the property, or null if none retrieved
    /// <span class="code-SummaryComment"></summary>
</span>
    TypeConverter Converter {
        get;
    }
}

So now rather than writing an UnEval() method, we're writing a replacement Eval() method - TwoWayDataBinder.Eval() - that returns IMemberInstance rather than object.

Performing a bind/unbind is then as simple as:

  • Getting an IMemberInstance for the bound member on the DataBound control (myTextBox.Text for example).
  • Getting an IMemberInstance for the member in the DataSource pointed to by the binding expression stored in our DataBindingInfo (this is where TwoWayDataBinder.Eval() comes in).
  • Assigning the value of one to the other, with a little type conversion thrown in.
/// <span class="code-SummaryComment"><summary>
</span>
/// Perform the actual work of unbinding the datasource
/// from the bound controls. It is recommended to execute
/// this via calls to <span class="code-SummaryComment"><see cref="DataUnbind"/> to ensure
</span>
/// the relevant events fire to resurrect the datasource
/// (as neccessary)
/// <span class="code-SummaryComment"></summary>
</span>
protected void AutoDataUnbind()
{
    Debug.WriteLine("Unbinding data...");
    foreach(DataBindingInfo info in DataBindings)
    {
        try
        {
          if (!info.IsTwoWay)
              continue;

          object boundObject = FindControl(info.BoundObject);
          object bindingContainer = 
                             GetBindingContainer(boundObject);
          IMemberInstance boundProp = 
                     TwoWayDataBinder.GetProperty(boundObject, 
                                            info.BoundMember);
          object dataSource = 
                    TwoWayDataBinder.GetField(bindingContainer, 
                                        info.DataSource).Value;
          IMemberInstance dataMember = TwoWayDataBinder.Eval(dataSource, 
                                               info.Expression);
          object typedValue;

          // We use a custom converter if supplied with the bindings
          if (info.HasConverter && info.Converter.CanConvertTo(dataMember.Type))
              typedValue =
                     info.Converter.ConvertTo(boundProp.Value, dataMember.Type);

              // For blanks being assigned to non-string types we set DBNull
          else if (dataMember.Type!=typeof(string) && (boundProp.Value==null || 
                                         boundProp.Value.Equals(string.Empty)))
              typedValue =info.EmptyValue;

              // For enums we attempt to use a converter from the control
          else if (boundProp.Type.IsEnum && boundProp.Converter!=null && 
                            boundProp.Converter.CanConvertTo(dataMember.Type))
              typedValue =boundProp.Converter.ConvertTo(boundProp.Value, 
                                                             dataMember.Type);

              // Otherwise we use some generic type conversion code
          else
              typedValue =TwoWayDataBinder.ConvertType(boundProp.Value, 
                                    dataMember.Type, info.FormatString);
        
              dataMember.Value =typedValue;

              TraceBindingAssignment("Unbinding", typedValue, info);
        }
        catch(Exception err)
        {
          throw new ApplicationException(String.Format("Failed to set " + 
                     "column {0} ({1})", info.Expression, err.Message), err);
        }
    }
}

Points of interest

When you really dig into the binding framework in .NET there's some quite clever stuff going on. There's also some serious omissions.

  • Those declarative binding expressions are fine - because they don't force you to use VS - but they should go somewhere useful at runtime, and not just get plonked into a generated class.
  • ICustomTypeDescriptor is pretty neat, but the ability for controls / DataSources to provide custom TypeConverters through it is totally ignored in ASP.NET databinding.
  • System.Web.UI.Controls expose a property BindingContainer that specifies their context as far as binding is concerned; that is to say controls nested in controls that are DataBound reference their parent as the binding container. Unfortunately this isn't documented anywhere (it's one of those 'this is for us, not for you' entries in MSDN).

License

This article has no explicit license attached to it but may contain usage terms in the article text or the download files themselves. If in doubt please contact the author via the discussion board below.

A list of licenses authors might use can be found here

Share

About the Author

piers7
Web Developer
Australia Australia
There's some kinda mutex between money and the time to enjoy it, and it's called work.

Comments and Discussions

 
GeneralAnother way to implement two way databinding in ASP.NET PinmemberMember 72754342-Aug-10 18:44 
GeneralUsing ASP.NET expression to bind the control properties at run time Pingroupelizas22-Mar-10 5:11 
GeneralHelp Pinmemberksbecker21-Apr-06 10:00 
GeneralMissing Code Pinsussartes_x11-Apr-05 19:07 
GeneralRe: Missing Code Pinmemberpiers712-Apr-05 5:45 
GeneralRe: Missing Code Pinsussartes_x13-Apr-05 7:25 

General General    News News    Suggestion Suggestion    Question Question    Bug Bug    Answer Answer    Joke Joke    Rant Rant    Admin Admin   

Use Ctrl+Left/Right to switch messages, Ctrl+Up/Down to switch threads, Ctrl+Shift+Left/Right to switch pages.

| Advertise | Privacy | Terms of Use | Mobile
Web02 | 2.8.141223.1 | Last Updated 12 Apr 2005
Article Copyright 2005 by piers7
Everything else Copyright © CodeProject, 1999-2014
Layout: fixed | fluid