Click here to Skip to main content
65,938 articles
CodeProject is changing. Read more.
Articles / web / ASP.NET

Advanced AJAX ListBox Component v0.1

4.68/5 (9 votes)
20 Jun 200713 min read 2   876  
How to build a more intuitive ListBox server control with ASP.NET AJAX client functionality.

Screenshot - ListBoxComponent01.gif

Introduction

Like Evyatar Ben-Shitrit says in the introduction to his article The ScrollableListBox Custom Control for ASP.NET 2.0, I too started out searching for an ASP.NET ListBox control that supports horizontal scrollbars. Like him, I too found several solutions and code snippets (including his) which, as helpful as they were, did not quite suit my requirements.

At first I thought I just wanted a ListBox that could scroll horizontally. Then I wanted a ListBox that would also adjust its scroll position when the up and down arrow keys were used to cycle through the items. Then I wanted a ListBox that would adjust its scroll position when someone used other keys (like letters and numbers) to cycle through the items. Once I had those requirements down, I wanted a ListBox that would remember its vertical and horizontal scroll position and readjust itself after a postback (asynchronous or otherwise). Finally, I wanted this ListBox to be able to exist as a normal Microsoft System.Web.UI.WebControls.ListBox by default, so that the control itself would determine its own rendering strategy using its Width, BorderXyz, and Style properties. The resulting control is what this article is about.

Background

I started out searching and immediately found lintom's article Finally a Horizontal Scroll Bar List Box in ASP.NET! Through that article I learned about the strategy of wrapping the ListBox inside a DIV and setting its OVERFLOW style to AUTO. This made it possible to get the horizontal scrollbars needed. However, when using the keyboard to traverse the items in the ListBox, the DIV did not automatically scroll. When a user selected an item that was not in the scroll window, they were out of luck. My next search yielded Shiby Chacko's article Horizontal scroll bar in a combo box or a listbox with up/down arrow key press functionality. Shiby was helpful in showing how client script can be used to overcome the problem in lintom's article. The best part of Shiby's article though was a comment left at the bottom of it by a user named zivros. It contained client script that could be used to make the DIV adjust its scroll position after any keypress event (not just the up & down arrow keys), even if the DIV element received the keypress event in lieu of the ListBox. That was a great contribution zivros, thanks for posting it. Finally, Evyatar's article addressed the idea of passing Width, BorderXyz, and other attributes from the ListBox to its containing DIV by overriding rendering methods in a custom server control.

What I can bring to this series of improvements upon a horizontally-scrollable ListBox is my familiarity with using the ASP.NET AJAX Extensions to add client functionality to server controls. If you've never implemented the System.Web.UI.IScriptControl interface or created client script prototypes for ASP.NET 2.0 server controls, don't worry, we're going to cover that. If you have a hard time following along, these 2 articles helped me get a good grasp on the core concepts:

A Comprehensive Solution

The first thing we need to do is create a new server control that extends Microsoft's System.Web.UI.WebControls.ListBox control and implements the System.Web.UI.IScriptControl interface. This is simple enough:

C#
using System;
using System.Collections;
using System.Collections.Generic;
using System.Drawing;
using System.Web;
using System.Web.UI;
using System.Web.UI.WebControls;

namespace DanLudwig.Controls.Web
{
    public class ListBox : System.Web.UI.WebControls.ListBox,
        System.Web.UI.IScriptControl
    {
        // all the server code we need will go right here
    }
}

As for configurable properties, our ListBox control will have three: HorizontalScrollEnabled, ScrollTop, and ScrollLeft. Notice that the default value of the HorizontalScrollEnabled property is false, meaning it must be explicitly enabled in an ASPX page in order to take advantage of the new features we will create. The ScrollTop and ScrollLeft properties are here to provide simplified wiring between the server control and the corresponding client script so that the ListBox will remember its scroll position between postbacks. Also note that if EnableViewState is false on the control or one of its parents, the scroll properties won't be persisted between postbacks. I've also added a get accessor to provide uniform access to the ScriptManager, which will be needed later.

C#
protected virtual ScriptManager ScriptManager
{
    get { return ScriptManager.GetCurrent(Page); }
}

public virtual bool HorizontalScrollEnabled
{
    set { this.ViewState["HorizontalScrollEnabled"] = value; }
    get
    {
        object output = this.ViewState["HorizontalScrollEnabled"];
        if (output == null)
            output = false;
        return (bool)output;
    }
}

public virtual int ScrollTop
{
    set { this.ViewState["ScrollTop"] = value; }
    get
    {
        object output = this.ViewState["ScrollTop"];
        if (output == null)
            output = 0;
        return (int)output;
    }
}

public virtual int ScrollLeft
{
    set { this.ViewState["ScrollLeft"] = value; }
    get
    {
        object output = this.ViewState["ScrollLeft"];
        if (output == null)
            output = 0;
        return (int)output;
    }
}

At this point, most other AJAX Extensions primer articles I've read discuss how to implement the System.Web.UI.IScriptControl interface in the extended control. This article will instead discuss the client script file first. If you're following along, you've already created a ListBox.cs file in your App_Code directory. In the root of your web site project, create a JScript file called ListBox.js. This file will automatically contain all the client script we need for the control when the ASPX page is rendered, as long as it contains a ScriptManager. There will be no need to manually include any script tags in the ASPX page or write any ASPX codebehind whatsoever. We do have to carefully follow Microsoft's specification though, which entails the following major steps:

  1. Register the client control's namespace (1 line of code).
  2. Define the client control's class.
  3. Define the class prototype.
    • Override / implement the initialize method of Sys.UI.Control.
    • Override / implement the dispose method of Sys.UI.Control.
    • Define the event handlers.
    • Define the property get and set methods.
  4. Optionally enable JSON Serialization (not required).
  5. Register the client control as a type that inherits from Sys.UI.Control (1 line of code).
  6. Notify the ScriptManager that this script is loaded (1 line of code).
JavaScript
//
// 1.)
// Register the client control's namespace
//
Type.registerNamespace('DanLudwig.Controls.Client');

// 2.)
// Define the client control's class
//
DanLudwig.Controls.Client.ListBox = function(element)
{
    // initialize base (Sys.UI.Control)
    DanLudwig.Controls.Client.ListBox.initializeBase(this, [element]);

    // declare fields for use by properties
    this._horizontalScrollEnabled = null;
    this._scrollTop = null;
    this._scrollLeft = null;
}

// 3.)
// Define the class prototype
//
DanLudwig.Controls.Client.ListBox.prototype =
{
    // 3a)
    // Override / implement the initialize method
    //
    initialize : function()
    {
        // call base initialize
        DanLudwig.Controls.Client.ListBox.callBaseMethod(this, 'initialize');
        // more code will go here later
    }
,
    // 3b)
    // Override / implement the dispose method
    //
    dispose : function()
    {
        // more code will go here later
        // call base dispose
        DanLudwig.Controls.Client.ListBox.callBaseMethod(this, 'dispose');
    }
,
    // 3c)
    // Define the event handlers (this will be done later)
    //
,
    // 3d)
    // Define the property get and set methods.
    //
    set_horizontalScrollEnabled : function(value)
    {
        if (this._horizontalScrollEnabled !== value)
        {
            this._horizontalScrollEnabled = value;
            this.raisePropertyChanged('_horizontalScrollEnabled');
        }
    }
,
    get_horizontalScrollEnabled : function()
    {
        return this._horizontalScrollEnabled;
    }
,
    set_scrollTop : function(value)
    {
        if (this._scrollTop !== value)
        {
            this._scrollTop = value;
            this.raisePropertyChanged('_scrollTop');
        }
    }
,
    get_scrollTop : function()
    {
        return this._scrollTop;
    }
,
    set_scrollLeft : function(value)
    {
        if (this._scrollLeft !== value)
        {
            this._scrollLeft = value;
            this.raisePropertyChanged('_scrollLeft');
        }
    }
,
    get_scrollLeft : function()
    {
        return this._scrollLeft;
    }

} // end prototype declaration

// 4.)
// Optionally enable JSON Serialization
//
DanLudwig.Controls.Client.ListBox.descriptor =
{
    properties: [
         {name: '_horizontalScrollEnabled', type: Boolean}
        ,{name: '_scrollTop', type: Number }
        ,{name: '_scrollLeft', type: Number }
    ]
}

// 5.)
// Register the client control as a type that inherits from Sys.UI.Control.
//
DanLudwig.Controls.Client.ListBox.registerClass(
    'DanLudwig.Controls.Client.ListBox', Sys.UI.Control);

// 6.)
// Notify the ScriptManager that this script is loaded.
//
if (typeof(Sys) !== 'undefined') Sys.Application.notifyScriptLoaded();    

This file doesn't give us any real functionality yet, but it defines the necessary parts so that the client control (the client JavaScript code specific only to our ListBox control) can interface with the ScriptManager. Now, if we implement the System.Web.UI.IScriptControl methods on the server control, and override both its OnPreRender and Render methods, we can finally wire up our server control properties to their corresponding client control properties.

C#
protected virtual IEnumerable<ScriptReference> GetScriptReferences()
{
    ScriptReference reference = new ScriptReference();

    // use this line when debugging the control in a web site project
    reference.Path = ResolveClientUrl("ListBox.js");

    // use these lines when the control is released with an embedded
    // js resource
    //reference.Assembly = "DanLudwig.Controls.AspAjax.ListBox";
    //reference.Name = "DanLudwig.Controls.Client.ListBox.js";

    return new ScriptReference[] { reference };
}
IEnumerable<ScriptReference> IScriptControl.GetScriptReferences()
{
    return GetScriptReferences();
}

protected virtual IEnumerable<ScriptDescriptor> GetScriptDescriptors()
{
    ScriptControlDescriptor descriptor = new ScriptControlDescriptor(
        "DanLudwig.Controls.Client.ListBox", this.ClientID);
    descriptor.AddProperty("horizontalScrollEnabled",
        this.HorizontalScrollEnabled);
    descriptor.AddProperty("scrollTop", this.ScrollTop);
    descriptor.AddProperty("scrollLeft", this.ScrollLeft);
    return new ScriptDescriptor[] { descriptor };
}
IEnumerable<ScriptDescriptor> IScriptControl.GetScriptDescriptors()
{
    return GetScriptDescriptors();
}

protected override void OnPreRender(EventArgs e)
{
    if (!this.DesignMode)
    {
        if (ScriptManager == null)
            throw new HttpException(
                "A ScriptManager control must exist on the current page.");

        ScriptManager.RegisterScriptControl(this);
    }

    base.OnPreRender(e);
}

protected override void Render(HtmlTextWriter writer)
{
    if (!this.DesignMode)
        ScriptManager.RegisterScriptDescriptors(this);

    // more code will go here later
    base.Render(writer);
    // more code will go here later
}        

This should get rid of those pesky compiler errors telling you to implement the interface you declared for the class earlier. If you're following along with our own web site project, the code discussed so far should compile and render fine at this point (though there isn't any real functionality yet). The server control will set properties on the client control, but we still have to put them to use. Be warned though, from here forward, testing the control in an ASPX page may produce funky results until we reach the end of the article.

Render a DIV around the ListBox... or not

So far we've done the common things you'll need to do every time you create a new AJAX-enabled server control with client events. The client events are what will be specific to your control. In the case of our ListBox, the client-specific events we want to handle and respond to are when users scroll the DIV element that will contain the ListBox, or when they use keystrokes to navigate the items. But where is this DIV container? Do we need to include a DIV element or an ASP Panel control around our ListBox in the ASPX page? Ideally, the control should use its HorizontalScrollEnabled property to determine whether or not it should render a DIV around itself. We also need to render a hidden Form field to pass client state data back to the server control on postbacks (to maintain scroll state).

Another thing we should do while we're rendering is pass certain characteristics of the ListBox to the DIV container. For example, if width or border styles are applied to our ListBox from the ASPX page, we want to pass those up to the DIV element. This can be a little tricky, and I might not have it exactly right, but the following code gets the job done for all of the styles I apply to this ListBox in my applications. Read the code comments to see exactly what's going on while we're rendering the control.

C#
public const string ContainerClientIdSuffix = "__LISTBOXHSCROLLCONTAINER";
public const string ScrollStateClientIdSuffix = "__LISTBOXHSCROLLSTATE";

protected override void Render(HtmlTextWriter writer)
{
    if (!this.DesignMode)
        ScriptManager.RegisterScriptDescriptors(this);

    if (this.HorizontalScrollEnabled)
    {
        // wrap this control in a DIV container
        this.AddContainerAttributesToRender(writer);
        writer.RenderBeginTag(HtmlTextWriterTag.Div);
    }
    base.Render(writer);
    if (this.HorizontalScrollEnabled)
    {
        // add a hidden field to store client scroll state
        // and close the container
        this.AddScrollStateAttributesToRender(writer);
        writer.RenderBeginTag(HtmlTextWriterTag.Input);
        writer.RenderEndTag();
        writer.RenderEndTag();
    }
}

protected virtual void AddContainerAttributesToRender(HtmlTextWriter writer)
{
    // when horizontal scrolling is enabled, width and border styles 
    // should be delegated to the container
    if (HorizontalScrollEnabled)
    {
        // add required container attributes
        writer.AddAttribute(HtmlTextWriterAttribute.Id, this.ClientID
            + ContainerClientIdSuffix);
        writer.AddStyleAttribute(HtmlTextWriterStyle.Overflow, "auto");
        writer.AddStyleAttribute(HtmlTextWriterStyle.Width,
            this.Width.ToString());

        // add optional container attributes
        Color borderColor = this.BorderColor;
        if (!borderColor.Equals(Color.Empty))
            writer.AddStyleAttribute(HtmlTextWriterStyle.BorderColor,
                string.Format("#{0}", borderColor.ToArgb().ToString("x")
                .Substring(2)));

        BorderStyle borderStyle = this.BorderStyle;
        if (!borderStyle.Equals(BorderStyle.NotSet))
            writer.AddStyleAttribute(HtmlTextWriterStyle.BorderStyle,
                borderStyle.ToString());

        Unit borderWidth = this.BorderWidth;
        if (!borderWidth.Equals(Unit.Empty))
            writer.AddStyleAttribute(HtmlTextWriterStyle.BorderWidth,
                borderWidth.ToString());

        // move style declarations from the Style attribute into the DIV
        // container.
        foreach (string key in this.Style.Keys)
        {
            writer.AddStyleAttribute(key, this.Style[key]);
        }
        this.Style.Remove(HtmlTextWriterStyle.Width);
        this.Style.Remove("width");
        this.Style.Remove(HtmlTextWriterStyle.BorderWidth);
        this.Style.Remove(HtmlTextWriterStyle.BorderStyle);
        this.Style.Remove(HtmlTextWriterStyle.BorderColor);
        this.Style.Remove("border");
    }
}

protected override void AddAttributesToRender(HtmlTextWriter writer)
{
    if (HorizontalScrollEnabled)
    {
        // take advantage of the base method by clearing the properties
        // we don't want rendered, adding the attributes, then restoring
        // the properties to their original values. BTW, why is there no
        // writer method to remove attributes? Or did I miss it???

        Unit originalWidth = this.Width;
        Unit originalBorderWidth = this.BorderWidth;
        BorderStyle originalBorderStyle = this.BorderStyle;
        Color originalBorderColor = this.BorderColor;

        this.Width = Unit.Empty;
        this.BorderWidth = Unit.Empty;
        this.BorderStyle = BorderStyle.NotSet;
        this.BorderColor = Color.Empty;

        base.AddAttributesToRender(writer);

        // get rid of default firefox border
        writer.AddStyleAttribute("border", "0px none");

        this.Width = originalWidth;
        this.BorderWidth = originalBorderWidth;
        this.BorderStyle = originalBorderStyle;
        this.BorderColor = originalBorderColor;
    }
    else
    {
        base.AddAttributesToRender(writer);
    }
}

protected virtual void AddScrollStateAttributesToRender(
    HtmlTextWriter writer)
{
    // the hidden field should have an id, name, type, and default value
    string fieldId = this.ClientID + ScrollStateClientIdSuffix;
    writer.AddAttribute(HtmlTextWriterAttribute.Id, fieldId);
    writer.AddAttribute(HtmlTextWriterAttribute.Name, fieldId);
    writer.AddAttribute(HtmlTextWriterAttribute.Type, "hidden");
    writer.AddAttribute(HtmlTextWriterAttribute.Value, string.Format(
        "{0}:{1}", this.ScrollTop, this.ScrollLeft));
}        

This is almost all of the code we need in the server control. The only thing that's missing is what the server control does when it receives new scroll position information from the client during a postback. As it turns out, all we need to do is extract the data passed by our hidden field and apply its values to our respective ScrollTop and ScrollLeft properties in the server control. Then, when IScriptControl.GetScriptDescriptors() is executed, it will pass these values back to the client control when it's rendered again after the postback. I handled this by overriding the server control's OnLoad event, since it happens at a point in the lifecycle where there is access to the Request.Form collection, but before the IScriptControl.GetScriptDescriptors() is called.

C#
public static readonly char[] ClientStateSeparator = new char[] { ':' };

protected override void OnLoad(EventArgs e)
{
    base.OnLoad(e);

    // update the server control's scroll position state
    string scrollStateClientId = this.ClientID + ScrollStateClientIdSuffix;
    object state = Page.Request.Form[scrollStateClientId];

    if (state != null)
    {
        //the state will be formatted with the pattern "scrollTop:scrollLeft"
        string[] scrollState = state.ToString().Split(
            ClientStateSeparator,2);
        int scrollTop = 0;
        if (scrollState[0] != null && int.TryParse(scrollState[0],
            out scrollTop))
            this.ScrollTop = scrollTop;

        int scrollLeft = 0;
        if (scrollState[1] != null && int.TryParse(scrollState[1],
            out scrollLeft))
            this.ScrollLeft = scrollLeft;
    }
}    

Handle Client Events to Add ListBox Functionality... or not

The rest of the code we need to write is all in the ListBox.js file. All of the user interaction with the ListBox will be handled in the client. This is where the code zivros posted comes in handy. The 3 client events that his code handled were the DIV container's onkeydown event, and the ListBox's onkeydown and onchange events. We're going to handle these 3 events, but we're also going to handle the container's onscoll event in order to update the hidden field we rendered.

In order to handle these events, we first have to add some code to our overridden initialize and dispose functions. (Note that from within the prototype definition, all functions are separated by commas.)

JavaScript
// 3.)
// Define the class prototype
//
DanLudwig.Controls.Client.ListBox.prototype =
{
    // 3a)
    // Override / implement the initialize method
    //
    initialize : function()
    {
        // call base initialize
        DanLudwig.Controls.Client.ListBox.callBaseMethod(this, 'initialize');

        // only create event handlers and delegates if horizontal
        // scrolling is enabled
        if (this.get_horizontalScrollEnabled())
        {
            // create event delegates
            this._onkeydownHandler = Function.createDelegate(
                this, this._onKeyDown);
            this._onchangeHandler = Function.createDelegate(
                this, this._onChange);
            this._onkeydownContainerHandler = Function.createDelegate(
                this, this._onContainerKeyDown);
            this._onscrollContainerHandler = Function.createDelegate(
                this, this._onContainerScroll);

            // add event handlers for the ListBox
            $addHandlers(this.get_element(),
            {
                 'keydown' : this._onKeyDown
                ,'change' : this._onChange
            }, this);

            // add event handlers for the ListBox's DIV container
            $addHandlers(this.get_elementContainer(),
            {
                 'keydown' : this._onContainerKeyDown
                ,'scroll' : this._onContainerScroll
            }, this);
        }

        // when horizontal scrolling is enabled, initialize the control
        if (this.get_horizontalScrollEnabled())
        {
            var listBox = this.get_element();
            var container = this.get_elementContainer();

            // before changing the listbox's size, store the original
            // size in the container.
            container.originalListBoxSize = listBox.size;

            if (this.get_element().options.length > this.get_element().size)
            {
                // change the listbox's size to eliminate internal scrolling
                listBox.size = listBox.options.length;

                // set the height of the container based on the
                // original listbox's size
                // (add 2 pixels of padding to prevent clipping)
                container.style.height
                    = ((container.scrollHeight / listBox.size)
                        * (container.originalListBoxSize)) + 2 + 'px';
            }

            // set the scroll position based on server state
            container.scrollTop = this.get_scrollTop();
            container.scrollLeft = this.get_scrollLeft();

            // if the ListBox is too narrow, expand it to fill the DIV
            // container
            if (container.scrollWidth <= parseInt(container.style.width))
            {
                listBox.style.width = '100%';
                listBox.style.height = container.scrollHeight + 'px';
                container.style.height
                    = ((container.scrollHeight / listBox.size)
                        * (container.originalListBoxSize)) + 'px';

                // there is a known bug in some FF versions that renders
                // 'XX' in empty selects. To overcome this issue, you could
                // add an empty item to empty ListBoxes
                //if (listBox.options.length < 1)
                //{
                //    listBox.options[0] = new Option('','');
                //}
            }
        }
    }
,
    // 3b)
    // Override / implement the dispose method
    //
    dispose : function()
    {
        // clear event handlers from the ListBox
        $clearHandlers(this.get_element());

        // can only clear event handlers from the DIV container if it exists
        if (this.get_elementContainer() != null)
        {
            $clearHandlers(this.get_elementContainer());
        }

        // call base dispose
        DanLudwig.Controls.Client.ListBox.callBaseMethod(this, 'dispose');
    }
,
    // helper method for retrieving the ListBox's DIV container
    get_elementContainer : function()
    {
        // only return the container if horizontal scrolling is enabled.
        if (this.get_horizontalScrollEnabled()
            && this.get_element().parentNode != null)
        {
            return this.get_element().parentNode;
        }
        else
        {
            return null;
        }
    }
,

In the initialize method, we created and added 4 new event handlers. Because the ListBox is the server control that is directly connected to this client control, we access it using this.get_element(). I've created a helper method called get_elementContainer() that conveniently retrieves the DIV client element for us as well. After creating the handlers, we add them respectively to the ListBox and the DIV using the $addHandlers shortcut. The rest of the code follows zivros' pattern of initializing the client-side elements, with a few of my own tweaks. After the page is loaded, when any of our 4 client events are triggered, the client will execute the event-specific code defined below:

JavaScript
    // 3c)
    // Define the event handlers
    //
    _onKeyDown : function(e)
    {
        if (this.get_element() && !this.get_element().disabled)
        {
            // cancel bubble to prevent listbox from re-scrolling
            // back to the top
            event.cancelBubble = true;
            return true;
        }
    }
,
    _onChange : function(e)
    {
        if (this.get_element() && !this.get_element().disabled)
        {
            // update the scroll position when the user changes the
            // item selection
            updateListBoxScrollPosition(this.get_elementContainer(),
                this.get_element(), null);
            event.cancelBubble = true;
            return true;
        }
    }
,
    // when keypresses are received by the container
    // (not bubbled up from the listbox),
    // they should be passed to the listbox.
    _onContainerKeyDown : function(e)
    {
        // setting focus on the listbox scrolls back to the top
        this.get_element().focus();

        // re-position the container scollbars after the focus()
        // method scrolled to the top
        setTimeout("updateListBoxScrollPosition("
            + "document.getElementById('"
            + this.get_elementContainer().id
            + "'), document.getElementById('"
            + this.get_element().id
            + "'), "
            + this.get_elementContainer().scrollTop
            + ")", "5");
    }
,
    _onContainerScroll : function(e)
    {
        // when the container is scrolled, update this control
        this.set_scrollState();
    }
,
    // set this property when the DIV container is scrolled
    set_scrollState : function()
    {
        // first of all, make sure the scroll properties are set
        this.set_scrollTop(this.get_elementContainer().scrollTop);
        this.set_scrollLeft(this.get_elementContainer().scrollLeft);

        // server control expects the state to be in the format
        // "scrollTop:scrollLeft"
        var stateValue = this.get_scrollTop() + ':' + this.get_scrollLeft();

        // save the state data in the hidden field
        this.get_elementState().value = stateValue;

        this.raisePropertyChanged('_scrollState');
    }
,
    get_elementState : function()
    {
        // function locates and returns the hidden form field which
        // stores the scroll state data.
        if (this.get_horizontalScrollEnabled()
            && this.get_element().parentNode != null)
        {
            // the second child node in the DIV container with a valid
            // VALUE property is the hidden field.
            // must find the hidden field this way because IE and FF have
            // differing childNodes collections
            var childNodeIndex = -1;
            for (i = 0; i<this.get_elementContainer().childNodes.length; i++)
            {
                if (this.get_elementContainer().childNodes[i].value != null)
                {
                    childNodeIndex++;
                    if (childNodeIndex > 0)
                    {
                        childNodeIndex = i;
                        break;
                    }
                }
            }
            return this.get_elementContainer().childNodes[childNodeIndex];
        }
        else
        {
            return null;
        }
    }
,

If you've been trying to get these functions to work before coming this far in the article, you're probably pretty frustrated. There's one more client-side function we need to add, and it's probably the most important of all. Again, thanks to zivros for the final piece that ties everything together and actually updates the DIV element's scroll position. Place the updateListBoxScrollPosition function just before the final line of code where Sys.Application.notifyScriptLoaded() is called:

JavaScript
// 5.)
// Register the client control as a type that inherits from Sys.UI.Control.
//
DanLudwig.Controls.Client.ListBox.registerClass(
    'DanLudwig.Controls.Client.ListBox', Sys.UI.Control);

// static function called by the client control(s)
function updateListBoxScrollPosition(container, listBox, realScrollTop)
{
    // realScrollTop defaults to zero when it is not set
    if (realScrollTop == null)
        realScrollTop = container.scrollTop;

    // determine the size of a single item in the ListBox
    var scrollStepHeight = container.scrollHeight / listBox.size;

    //find out what are the visible top & bottom items in the ListBox
    var minVisibleIdx = Math.round(realScrollTop / scrollStepHeight);
    var maxVisibleIdx = minVisibleIdx + container.originalListBoxSize - 2;

    // handle the case where a user is scrolling down...
    if (listBox.selectedIndex >= maxVisibleIdx)
    {
        container.scrollTop
            = (listBox.selectedIndex - container.originalListBoxSize + 2)
            * scrollStepHeight;
    }

    // handle the case where a user is scrolling up...
    else if (listBox.selectedIndex < minVisibleIdx)
    {
        container.scrollTop = listBox.selectedIndex * scrollStepHeight;
    }

    // in all other cases, set the vertical scroll to the realScrollTop
    // parameter.
    else
    {
        container.scrollTop = realScrollTop;
    }
}

// 6.)
// Notify the ScriptManager that this script is loaded.
//
if (typeof(Sys) !== 'undefined') Sys.Application.notifyScriptLoaded();

Voila

There is a lot of overlapping code here, so if you're copying and pasting it straight from this article into your own files, you may be running into the dreaded "DanLudwig is undefined" client error message. That error message, like so many others, can be misleading. I assure you, I am very well defined. However, the slightest syntax error in the ListBox.js file will cause the DanLudwig namespace to not be successfully registered. Sometimes it's because there aren't the required commas separating the different parts of the prototype declaration, and other times it's because of more obscure errors in the client script file. If you're having trouble copying and pasting, use the source files provided along with this article (but remember to change the reference in the GetScriptReferences() method if you'll be testing it in a web site project).

Perfectionism is a Disease

Now we have a fairly intuitive ListBox that can accommodate text too wide for our available screen real estate. As an added feature, it will also remember its last vertical and horizontal scroll positions and restore them after postbacks to preserve client state. But there are still more features to be added! For example, if your ListBox's SelectionMode is set to Multiple, users should be able to use the CONTROL and SHIFT keys to select multiple items simultaneously. Try this: select an item, then scroll away so that it isn't visible anymore. now, try to use either the CONTROL or SHIFT key to select more than one item. As soon as you hit the key, the ListBox scrolls back to the original item you selected. I'm not a user, but if I was, that would probably annoy me.

Another shortcoming of this control can be demonstrated by viewing it in IE7 and FireFox 1.5. In FireFox, when the mouse is positioned over the ListBox, the mouse's wheel can be used to scroll through the items. In IE7 though, this only works if the mouse is hovered over one of the scrollbars. Again, were I a user, I might complain about this too. What's even more interesting is what happens when you view the control in IE6 and FireFox 1.0... you get the opposite behavior! In the older versions of these browsers, IE scrolls by default whereas FireFox does not. Ideally, we should code for the mouse wheel such that we can turn this type of behavior on and off from the ASPX page. Some users might prefer using the mouse wheel whereas others might think it interferes too much with page scrolling. If we make it configurable, we can at least provide ourselves with the possibility of letting users turn this feature on and off for themselves using ProfileCommon.

You might also have noticed that our ListBox doesn't remember scroll position if HorizontalScrollingEnabled is set to false (or not set at all). The two should be configured separately and operate independently from one another. That, and we can also remodel how we respond to events to use fewer CPU cycles on the client machine. This will be especially useful since FireFox 1.0 doesn't respond to the onscroll event we attached to the DIV container. There are probably more things that I haven't even thought of yet, but this article is already way too long. In my next, much shorter article, I'll begin discussing how to accommodate these additional requirements.

Points of Interest

This is only the second code article I've ever written. The first, now quite obsolete More Scrolling Function in Flash 5, I wrote way back in 2000. Some of the same mathematics needed to calculate the scroll position is similar, but even I find it odd that the only two articles I've ever written have to do with this rather mundane, yet UI-critical necessity. Perhaps this ListBox control will also become obsolete if browsers ever evolve to the level of maturity where they can scroll ListBoxes sideways natively.

Exercises for You

I know this control isn't perfect, though I do think it's highly reusable when packaged correctly, and we're going to make it even better in the next article. Here are some considerations for you to tackle if you find this control falling short of your requirements:

  • Even on asynchronous postbacks, there is sometimes screen flicker on the ListBox between the time the browser renders the ListBox and the time the client code initializes the saved scroll position. Because its height is determined by its "size" attribute (which corresponds to the "Rows" attribute / property in the server control) by default, it's difficult to set the DIV's scroll position before the ListBox's size is programmatically set by the JavaScript. For my requirements the screen flicker is not a 100% reproducible problem, is barely noticeable, and for the most part, can be ignored. But can you figure out how to lose it altogether?
  • Although this control is compatible with FireFox, it always renders a vertical scrollbar even when the ListBox is bigger than the number of items it contains. Can you figure out how to get rid of this?
  • Create server properties to add CSS styles to the DIV scrollbars.
  • Create other properties to pass more CSS styling control from the ListBox to its DIV container.
  • Clean up my C# code (I'm actually certified in Java and J2EE, not ASP.NET or C#
  • I can't think of anything else. What could I be missing?

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