Wednesday, May 30, 2007

Maintaining scroll position in .NET 2.0 ListBox on PostBack

Warning: this is only a partially working solution due to some Javascript issues described (and solved) here.

A requirement I had was to maintain the scroll position of ListBoxes on PostBack. The only solution I could find was to get the scroll through Javascript (the scrollTop property of the select) and restore it on page load, however, that would have meant a lot of custom controls, not to mention lots of work, to which I am usually against.

So, I used a ControlAdapter! The ControlAdapter is something new to the NET 2.0 framework. The Control in 2.0 looks for a ControlAdapter and delegates the usual methods (like OnLoad,OnInit,Render,etc) to the adapter. You tell the site to use an adapter for a specific type of control and possibly a specific browser type (by using a browser file), and it uses that adapter for all of the controls of the selected type and also the ones inherited from them. To disallow the "adaptation" of your control, override ResolveAdapter to always return null.

Ok, the code!
C# code
/// This class saves the vertical scroll of listboxes
/// Set Attributes["resetScroll"] to something when you want to reset the scroll
public class ListBoxScrollAdapter : ControlAdapter
    protected override void OnLoad(EventArgs e)
        if ((Page != null) && (Control is WebControl))
            WebControl ctrl = (WebControl) Control;
            string scrollTop = Page.Request.Form[Control.ClientID + "_scrollTop"];
            ScriptManagerHelper.RegisterHiddenField(Page, Control.ClientID + "_scrollTop", scrollTop);
            string script =
                    "var hf=document.getElementById('{0}_scrollTop');var lb=document.getElementById('{0}');if(hf&&lb) hf.value=lb.scrollTop;",
            ScriptManagerHelper.RegisterOnSubmitStatement(Page, Page.GetType(), Control.UniqueID + "_saveScroll",
            if (string.IsNullOrEmpty(ctrl.Attributes["resetScroll"]))
                script =
                        "var hf=document.getElementById('{0}_scrollTop');var lb=document.getElementById('{0}');if(hf&&lb) lb.scrollTop=hf.value;",
                ScriptManagerHelper.RegisterStartupScript(Page, Page.GetType(), Control.ClientID + "_restoreScroll",
                                                          script, true);
            } else
                ctrl.Attributes["resetScroll"] = null;

Browser file content<browsers>
<browser refID="Default">
<adapter controlType ="System.Web.UI.WebControls.ListBox"
adapterType="Siderite.Web.WebAdapters.ListBoxScrollAdapter" />

Of course, you will ask me What is that ScriptManagerHelper? It's a little something that tries to get the ScriptManager class without having to reference the System.Web.Extensions library for Ajax. That means that if there is Ajax around, it will use ScriptManager.[method] and if it is not it will use ClientScript.[method]. To.Int(object) is obviously something that gets the integer value from a string.

There is another thing, at the beginning I've inherited this adapter from a WebControlAdapter, but it resulted in showing all the options in the select (all the items in the ListBox) with empty text. The value was set as well as the number of options. It might be because in WebControlAdapter the Render method looks like this:
protected internal override void Render(HtmlTextWriter writer)

instead of just calling the control Render method.


Anonymous said...

geat solution to this problem, i'm going to use it.

Siderite said...

Let me know how it works out... If there is any problem , I will fix it.

Anonymous said...

I just spent all afternoon working on a similiar solution and when I finished, I had it at 1 line of code. Try this also (it's in VB, so replace the &'s and put a semi-colon in, you know the story), this needs to be run everytime so don't nest it in a IsPostBack block. Also, the if statement is required because on the initial load those objects don't exist yet... they will on subsequent postbacks.

ScriptManager.RegisterClientScriptBlock(Page, btnAdd.GetType, "Reposition", "if (document.getElementById('lstPrograms')) { document.getElementById('lstPrograms').selectedIndex=" & lstPrograms.SelectedIndex & "; }", True)

Siderite said...

Your solution works only for single selection listboxes. Also, it doesn't preserve the scroll, it only scrolls to the selected item.

Anonymous said...

You are correct on both of your statements. Our end goals were different, in my brain fog I thought they were closer, my apologies.

In my case, they've already moved the contents from a left hand box to a right hand box (with a multi-select) on a post back and the only spec was to keep the scroll back on the first selected (I would prefer the last selected though at which point we could just loop through to find it's index).

You are right though, my example does not preserve the multiple selections, just the first selected scroll position.

I forgot to mention also, yours is a great solution, many thanks for sharing it. :)

vernard sloggett said...

this seems to work great in .net 3.5
with this slight modification
add declaration /assignment
ClientScriptManager cs = Page.ClientScript;

change references to scriptmanagerhelper to cs and remove page as parameter in the three cs. method calls

that takes care of nonajax

also to take care of items in update panals repeat the codeblock in another if statement
(Page != null) && (Control is WebControl) && ScriptManager.GetCurrent(Page) != null)
and in this codeblock replace scriptmanagerhelper with ScriptManager

Siderite said...

Thanks, Vernard!

Anonymous said...

Thanks very much!!!!
solution to a big dilema...

David Keylock said...

First easily implemented solution I've found after A LOT of looking, many thanks!