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

Advanced AJAX ListBox Component v0.2

4.45/5 (6 votes)
22 Jun 200714 min read 1   423  
Separating horizontal scrolling function from client scroll state preservation.

Screenshot - ListBoxComponent02.gif

Introduction

In my last article we created an ASP.NET AJAX-Enabled ListBox control with client events. However, this control still falls short of release and production requirements by a long shot.

Background

Some of the missing pieces we already discussed include:

  1. It is difficult or impossible to select multiple items at once by using the SHIFT and CONTROL keys because their onkeydown events cause the scrollbars to be repositioned based on the first item(s) selected. We should read the keyCode of the event and bypass our scrolling code for "meta" keypresses like this.
  2. The ability to scroll through the items using a mouse wheel depends on both the browser and version used to load the page. We would like to make mouse scrolling a configurable option supported on all target browsers.
  3. The vertical and horizontal scrollbar positions are only preserved across postbacks when the control's HorizontalScrollEnabled property is explicitly set to true. We should be able to preserve scroll state even when HorizontalScrollEnabled is false.
  4. Our event handling strategy does not fully support Firefox 1.0 because some user interactions which change the scroll position do not execute the _onContainerScroll event handler. Additionally, if you ever try debugging this handler, you'll notice IE can execute it up to four times for a single mouse click or keystroke. Dragging a scrollbar causes my CPU to briefly jump to 30% or higher, depending on how fast I scroll. Updating the hidden field this many times places an unnecessary load on the client computer's processor.

In addition to these issues, you may also have found that running this control with FireBug causes a lot of "event is not defined" errors. This is one of a few poor coding practices used in the ListBox.js file that we're going to correct before we promote this control to version 0.2.

One Thing at a Time

Evaluating which of these four obstacles to tackle first can save us a lot of time and headache. The biggest problem from a usability standpoint is #1, but that's not what we should base our decision on. The one with the greatest impact to both client and server code is #3. We'll have to add a new server control property for #2, whereas #1 and #4 can be handled entirely in the JS file. But #3 is going to require the most changes in both files, so we'll solve that requirement in this article. First, let's get rid of those pesky red messages in FireBug, shall we?

Memory Leak (in my Head)

In favor of getting a working control while developing version 0.1, I ignored quite a few good coding practices. I copied and pasted a lot of Zivros' code into the JS file, and because it worked in IE7, took it for granted. However, the window.event object (referenced simply as event in the client handlers) is not available in Firefox. Instead, FireFox uses the "e" parameter passed to the handler. If you've ever had to handle client events in a cross-browser script, you probably compared window.event against undefined to promote consistency. Fortunately for us, Microsoft already thought of this and gave us a consistent way to handle most events in our client control prototype. I just... um... forgot about it.

There are two places where we incorrectly used the event object in the client control. We did this once in the _onKeyDown handler, then again in the _onChange handler to prevent the ListBox from allowing its events to bubble up to the DIV container. The correct way to do this using the ASP.NET AJAX Extensions framework is to call the stopPropagation() method of the event parameter "e" passed into the handler function, like so:

JavaScript
_onKeyDown : function(e)
{
    if (this.get_element() && !this.get_element().disabled)
    {
        // cancel bubble to prevent listbox from re-scrolling
        // back to the top
        e.stopPropagation();
        return true;
    }
}
,
_onChange : function(e)
{
    if (this.get_element() && !this.get_element().disabled)
    {
        updateListBoxScrollPosition(
            this.get_elementContainer(), this.get_element(), null);
        e.stopPropagation();
        return true;
    }
}
,

Don't thank me, thank Microsoft. It turns out that stopPropagation() is the official W3C method, so it's good to see them starting to follow some industry standards instead of making everything up as they go along. FireBug should now show us a nice green checkmark when these events are handled, which means our script isn't causing any client-side errors in Firefox (for now).

Promise Users What they Want...

For a minute, put yourself in the page designer's shoes. Imagine you don't care how this control works, as long as it works. You just want to set a few simple attributes on the server control's XML tag and have it magically handle itself in a cross-browser fashion. If that's what page designers want, that's what they should get. Let's start out simply by giving them the control property.

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

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);
    descriptor.AddProperty("scrollStateEnabled",
        this.ScrollStateEnabled);
    return new ScriptDescriptor[] { descriptor };
}

Okay, good. Now come back to reality! Our control currently handles 2 of 4 possible cases:

  1. HorizontalScrollEnabled==<code>true && ScrollStateEnabled==true
  2. <code><code>HorizontalScrollEnabled==false && ScrollStateEnabled==false

These properties have the same value because we treated them as the same option in version 0.1. Now, we have to refactor both the server and client code to accommodate the other 2 possible configurations:

  1. <code><code>HorizontalScrollEnabled==true && ScrollStateEnabled==false
  2. <code><code>HorizontalScrollEnabled==false && ScrollStateEnabled==true

The first question we have to ask ourselves is, when should the DIV container get rendered? Well, it's definitely needed when HorizontalScrollEnabled equals true, but it would also be nice to have when ScrollStateEnabled equals true. That way, it will be easy to find the hidden form field in client code without having to know the client control's id; we can instead just search for it in the DIV's childNodes collection like the current implementation does. In fact, the only time we don't render a DIV is when both of these properties are set to false.

Conditionally rendering the hidden form field too will save us slight bandwidth when the page travels over the network. Granted, it's only pennies on the dollar, but every byte counts. We can also skip a few CPU cycles on the server by ignoring the steps required to add the attributes to the writer and render the HTML.

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

    if (this.HorizontalScrollEnabled || this.ScrollStateEnabled)
    {
        // wrap this control in a DIV container
    }
    base.Render(writer);
    if (this.HorizontalScrollEnabled || this.ScrollStateEnabled)
    {
        if (this.ScrollStateEnabled)
        {
            // add a hidden field to store client scroll state
        }
        // close the container
    }
}

Thankfully, most of the attribute rendering code we already wrote won't change much either. The overridden AddAttributesToRender() method won't change at all, and we just have to wrap different blocks of code inside different conditional checks for the other two helper methods:

C#
protected virtual void AddContainerAttributesToRender(HtmlTextWriter writer)
{
    // the container now depends on 2 different property values
    if (this.HorizontalScrollEnabled || this.ScrollStateEnabled)
    {
        // add required container attributes

        if (this.HorizontalScrollEnabled)
        {
            // add optional container attributes
            // move style declarations from the Style attribute
            // into the DIV container.
        }
    }
}

protected virtual void AddScrollStateAttributesToRender(HtmlTextWriter writer)
{
    if (ScrollStateEnabled)    
    {
        // the hidden field should have an id,
        // name, type, and default value
    }
}

Once again, we're almost done with the server control code. And once again, we can wrap our previous method in a conditional block to save on unnecessary server instructions.

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

    if (this.ScrollStateEnabled)
    {
        // update the server control's scroll position state
    }
}

...And Work Out the Details Later

Now, if you set ScrollStateEnabled to true in your test ASPX page and try to run the example, you'll find we've really gone and screwed up our code. If you have IE script error notification turned on, you'll see a big fat error telling us we're trying to register a client property that doesn't exist. So, let's create it. And as long as we're in the property section of code, we also need to update the helper properties used to retrieve the DIV container and the hidden field.

JavaScript
// 2.)
// Define the client control's class
//
DanLudwig.Controls.Client.ListBox = function(element)
{
    // initialize base (Sys.UI.Control)

    // declare fields for use by properties
    this._scrollStateEnabled = null;
    // declare our original 3 fields
}

JavaScript
// 3d)
// Define the property get and set methods.
//
set_scrollStateEnabled : function(value)
{
    if (this._scrollStateEnabled !== value)
    {
        this._scrollStateEnabled = value;
        this.raisePropertyChanged('_scrollStateEnabled');
    }
}
,
get_scrollStateEnabled : function()
{
    return this._scrollStateEnabled;
}
,
// helper method for retrieving the ListBox's DIV container
get_elementContainer : function()
{
    // only return the container if it has been rendered
    if (this.get_horizontalScrollEnabled()
        || this.get_scrollStateEnabled())
    {
        return this.get_element().parentNode;
    }
    else
    {
        return null;
    }
}
,
get_elementState : function()
{
    // function locates and returns the hidden form field which
    // stores the scroll state data.
    if (this.get_scrollStateEnabled())
    {
        // same v0.1 logic used to locate and return the
        // hidden form field
    }
    else
    {
        return null;
    }
}
,

Once again our control should be in working order, but it still only behaves as intended when HorizontalScrollEnabled and ScrollStateEnabled are equal (that is, when they're both true or both false). In order to handle the two scenarios where they differ, we have to re-evaluate our events and client logic.

If you look carefully at our client code, there is one piece that "should" stick out like a sore thumb: set_scrollState(). Do you notice anything odd about it? Sure, it doesn't have a get method, which usually indicates a poor code design strategy. Look closer though, and you'll realize it doesn't take a parameter. So, it's not really a property at all, it's just a helper function. Furthermore, do you remember the Page_Load code snippet I added to the codebehind in the version 0.1 demo? I disabled page caching because Firefox was falling out of sync with the server's state. This was because we called set_scrollTop() and set_scrollLeft() inside of set_scrollState(). We really have no need to ever set those properties from client code. They're only used to capture scroll position from the server control to restore previous client state, not set it. If these properties could update their corresponding properties on the server, we wouldn't need the hidden field.

Furthermore, this function won't work when HorizontalScrollEnabled is false because we need to access the ListBox's scrollTop and scrollLeft properties, not the DIV's. Any way you look at it, this piece of code is in need of some serious refactoring. Here is what it should look like:

JavaScript
_onContainerScroll : function(e)
{
    // when the container is scrolled, update this control
    //OBSOLETE this.set_scrollState();
    this._updateScrollState();
}
,
// return the client scroll state data that will go in the hidden field
get_scrollState : function()
{
    var scrollingElement = this.get_elementContainer();
    if (!this.get_horizontalScrollEnabled())
        scrollingElement = this.get_element();

    return scrollingElement.scrollTop + ':' + scrollingElement.scrollLeft;
}
,
// set the hidden field if it is out of sync with the current client state
_updateScrollState : function()
{
    var scrollState = this.get_scrollState();
    var hiddenField = this.get_elementState();
    if (hiddenField.value !== scrollState)
        hiddenField.value = scrollState;
}
,

That's more like it! This is much more reusable. We now need to handle the ListBox's onscroll event when HorizontalScrollEnabled is false. Let's consolidate all of the event and UI initialization logic into separate helper methods first though:

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

    // initialize the events
    this._initializeEvents();

    // initialize the control user interface
    this._initializeUI();
}
,
_initializeEvents : function()
{
    // when horizontal scroll is enabled, use 3 zivros events
    if (this.get_horizontalScrollEnabled())
    {
        // create & add ListBox events
        this._onkeydownHandler = Function.createDelegate(
            this, this._onKeyDown);
        this._onchangeHandler = Function.createDelegate(
            this, this._onChange);
        $addHandlers(this.get_element(),
        {
             'keydown' : this._onKeyDown
            ,'change' : this._onChange
        }, this);

        // create & add DIV container event(s)
        this._onkeydownContainerHandler = Function.createDelegate(
            this, this._onContainerKeyDown);
        $addHandlers(this.get_elementContainer(),
        {
             'keydown' : this._onContainerKeyDown
        }, this);

        // need event to update hidden field when DIV is used
        if (this.get_scrollStateEnabled())
        {
            this._onscrollContainerHandler = Function.createDelegate(
                this, this._onContainerScroll);
            $addHandlers(this.get_elementContainer(),
            {
                 'scroll' : this._onContainerScroll
            }, this);
        }
    }

    // from here, it's safe to assert that horizontal scrolling
    // is not enabled
    else if (this.get_scrollStateEnabled())
    {
        this._onscrollHandler = Function.createDelegate(
            this, this._onScroll);
        $addHandlers(this.get_element(),
        {
             'scroll' : this._onScroll
        }, this);
    }
}
,

JavaScript
// 3c)
// Define the event handlers
//
_onScroll : function(e)
{
    if (this.get_element() && !this.get_element().disabled)
    {
        this._updateScrollState();
        e.stopPropagation();
        return true;
    }
}
,

Note the strategy used to initialize the events. You may have noticed that the new _updateScrollState() method we created did not check to see if scroll state was enabled before setting the hidden field's value. This is because we do all of the property checking before adding the event handlers that will actually call _updateScrollState(). This may have to be refactored again in a later article as we add more event handlers or use our existing event handlers to perform other tasks. But remember, one thing at a time! The following is a tabular representation of how events are initialized. The events in "bold" are the ones that will update the hidden field, whereas the ones in "italics" are needed to scroll from within the DIV container.

ScrollStateEnabledHorizontalScrollEnabled
TrueFalse
True
  1. ListBox.onKeyDown
  2. ListBox.onChange
  3. DIV.onKeyDown
  4. DIV.onScroll
  1. ListBox.onScroll
False
  1. ListBox.onKeyDown
  2. ListBox.onChange
  3. DIV.onKeyDown
  • No events will be registered.

All of this is fine and dandy, but testing it with these changes will still spit out a big fat error because we haven't yet written the _initializeUI() method. It would be nice to get rid of some of the hacks we used while writing it too, but as it turns out, we're actually going to have to "add" hacks to get it to work as intended. In the spirit of handling one problem at a time, let's see what happens when we use this code:

JavaScript
_initializeUI : function()
{
    var listBox = this.get_element();
    var container = this.get_elementContainer();

    if (this.get_horizontalScrollEnabled())
    {
        // before changing the listbox's size,
        // store the original size in the container.
        container.originalListBoxSize = listBox.size;

        // this should be done regardless of how many items there are
        container.style.height = this.get_correctContainerHeight() + 'px';

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

    if (this.get_scrollStateEnabled())
    {
        this._restoreScrollState();
    }

    if (this.get_horizontalScrollEnabled())
    {
        if (container.scrollWidth <= parseInt(container.style.width))
        {
            listBox.style.width = '100%';
            listBox.style.height = container.scrollHeight + 'px';
            container.style.height = this.get_correctContainerHeight()
                + 'px';

            // the Firefox hack discussed in the previous article
            // should not be dealt with here. If an application
            // wants to hack a FF bug, it should be done on the
            // server before this control is rendered.
        }
    }
}
,
get_correctContainerHeight : function()
{
    var container = this.get_elementContainer();
    var listBox = this.get_element();
    var itemHeight = container.scrollHeight / listBox.size;
    var correctHeight = itemHeight * container.originalListBoxSize;
    return correctHeight;
}
,
_restoreScrollState : function()
{
    var scrollingElement = this.get_elementContainer();
    if (!this.get_horizontalScrollEnabled())
        scrollingElement = this.get_element();

    scrollingElement.scrollTop = this.get_scrollTop();
    scrollingElement.scrollLeft = this.get_scrollLeft();
}
,

Though this code block is still unnecessarily bloated, we've actually improved it somewhat. At least now the scroll position can be correctly set after a postback, independently of the HorizontalScrollEnabled property... right? Go ahead and give it a try. In Firefox 1.5 this code works great, and appears to work well in IE7 at first glance. You can scroll, fire a postback, and the scrollTop property will be correctly restored. However, trying to scroll the ListBox again after the postback will cause IE to automatically scroll all the way back up to the top. Buggers!

As it turns out, this actually happens because IE is trying to help users just as much as we are. In my demo, there are 4 postback LinkButtons. During postback, any one of them will set the ListItem.Selected property to false for all items in each ListBox. On the client, this causes selectedIndex to be set to -1. Even though we restored the ListBox's scrollTop property after postback, IE vetos our proposed change and uses selectedIndex to set its own scroll position. Fortunately, knowing this makes it easy for us to work around it.

We need to rewind and turn our attention back to the _onScroll event handler that causes this bug. From within that block, it's possible (nay, EASY) to calculate the selectedIndex value that matches the current scrollTop value. By doing that, we can harness (nay, HACK) IE's attempt to screw up everything we've worked for this far. If we set the selectedIndex to a specific calculation, then set it back to -1, IE7 will submit to our authority. Remember though, IE only challenges us when selectedIndex is equal to -1. We don't want to mess with our user's selected items if any exist, so we should only perform this hack when selectedIndex equals -1.

Fix It in One Browser, Break It in Another

Doing that, however, will make Firefox irate. Setting selectedIndex back to -1 will now cause it to scroll all the way back to the top. Furthermore, it will prevent any scrolling whatsoever as long as selectedIndex stays equal to -1. If we compare the ListBox's scrollTop values before and after we set selectedIndex equal to -1, we'll find that it's always zero afterward no matter what it is before. We can't simply reset the scrollTop value though because doing that will cause the event handler to be triggered again and, because selectedIndex stays equal to -1, we end up in an infinite loop.

I don't claim to be a JavaScript expert by any means, so I'm not really sure if there's a best practice for preventing code in an event handler from retriggering itself by changing an element property value like in the above scenario. So, since we're writing one hack already, let's just go ahead and write two:

JavaScript
_onScroll : function(e)
{
    if (this.get_element() && !this.get_element().disabled 
        && !this._supressNextScroll)
    {
        this._updateScrollState();
        e.stopPropagation();

        //TODO -- what an ugly hack!!!!!
        var listBox = this.get_element();
        var itemSize = listBox.scrollHeight / listBox.options.length;
        if (listBox.selectedIndex == -1)
        {
            var oldScrollTop = listBox.scrollTop;
            listBox.selectedIndex = Math.round(
                listBox.scrollTop / itemSize);
            this._supressNextScroll = true;
            listBox.selectedIndex = -1;
            listBox.scrollTop = oldScrollTop;

        }
        return true;
    }
    else
    {
        this._supressNextScroll = false;
    }
}
,

Good News, Bad News

The good news is that for the first time, we can say we've achieved the behavior we originally intended for this upgrade... in the two most common browsers. Thankfully, since onscroll is executed multiple times for even a single mouse click in both browsers, skipping one doesn't hurt us. We could've sniffed for IE7, tested against document.all or window.event, but that would be an even uglier hack than the one we have now.

The bad news is, this code doesn't work so great in IE6 or FF 1.0. In IE6, I originally thought this was because the ListBox's onscroll event wasn't getting fired. It doesn't, but that's the least of our problems, since IE6 always returns zero as the value of our ListBox's scrollTop. This means we can't even determine the correct scroll state to send back to the server. IE6 always sends "0:0" in the hidden field. At this point, the only way to make scroll state work in IE6 is to enable horizontal scrolling, because the DIV container will send the correct values.

As usual, the situation is a bit less dire in Firefox. Our control will work fine when users click the scrollbars to navigate items, because those actions trigger the ListBox's onscroll event. The scroll state is only improperly recorded when keypresses and mouse wheels are used to navigate items. We're going to try and take care of the mouse wheel in another article, but we can solve the keypress problem right here by executing the _onScroll handler (or the _onContainerScroll handler) during the ListBox's onchange event when ScrollStateEnabled is true.

JavaScript
// from here, it's safe to assert that horizontal scrolling
// is not enabled
    else if (this.get_scrollStateEnabled())
    {
        this._onscrollHandler = Function.createDelegate(
            this, this._onScroll);
        $addHandlers(this.get_element(),
        {
            'scroll' : this._onScroll
            ,'change' : this._onScroll
        }, this);
    }

JavaScript
_onChange : function(e)
{
    if (this.get_element() && !this.get_element().disabled)
    {
        updateListBoxScrollPosition(
            this.get_elementContainer(), this.get_element(), null);
        e.stopPropagation();

        if (this.get_scrollStateEnabled())
        {
            this._onContainerScroll(e);
        }
        return true;
    }
}
,

Because keypresses in the ListBox force the selectedIndex to change, the _onScroll handler (or _onContainerScroll) can be used when we attach it to the onchange event. Although we can now say this ListBox supports Firefox 1.0, the demo for this article uses UpdatePanels and asynchronous postbacks. Unfortunately, Microsoft says ASP.NET AJAX doesn't support FF 1.0. I have actually had mixed success... FF 1.0 on one machine would handle the demo fine, whereas FF 1.0 on another machine would complain about the UpdatePanels. You can try commenting out the Page_Init handler in the demo codebehind, but regardless of UpdatePanels, our ListBox definitely works in FF 1.0.

Going Out on Safari

I know I promised much shorter follow-up articles on this series, but I really mean it when I promise you now that we're almost through. I was surprised to find out that version 0.2 of our ListBox works better than expected on Safari 2.0.4 (Mac OS X). Users can scroll sideways to view long text when HorizontalScrollEnabled is true. Unfortunately though, scroll state doesn't seem to be preserved between postbacks no matter what. I'm willing to live with that if you are, especially since another quirk in Safari (version 2.0.4 at least) doesn't even allow users to navigate items using letter or number keys on the keyboard. If Safari users can live with that, then they must get a kick out of scrolling the hard way... right?

I also briefly tested this on the Safari Public Beta version 3.0.1. This version of Safari does allow the rest of the keyboard to navigate ListBox items, but that feature still doesn't work well with our ListBox. Why? I'm glad you asked. It's because when the keyboard is used to change the ListBox selection, its onchange event doesn't get fired! Because of that, I'm not going to try to make this control work with any beta version browsers like Safari. We'll revisit this once Safari releases a production version. In the next article, we're going to pick up where we left off and take a deeper look into how to enforce browser compatibility.

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