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:
- 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. - 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.
- 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
. - 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:
_onKeyDown : function(e)
{
if (this.get_element() && !this.get_element().disabled)
{
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.
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:
HorizontalScrollEnabled==<code>true
&& ScrollStateEnabled
==true
<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:
<code><code>HorizontalScrollEnabled
==true
&& ScrollStateEnabled
==false
<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.
protected override void Render(HtmlTextWriter writer)
{
if (!this.DesignMode)
ScriptManager.RegisterScriptDescriptors(this);
if (this.HorizontalScrollEnabled || this.ScrollStateEnabled)
{
}
base.Render(writer);
if (this.HorizontalScrollEnabled || this.ScrollStateEnabled)
{
if (this.ScrollStateEnabled)
{
}
}
}
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:
protected virtual void AddContainerAttributesToRender(HtmlTextWriter writer)
{
if (this.HorizontalScrollEnabled || this.ScrollStateEnabled)
{
if (this.HorizontalScrollEnabled)
{
}
}
}
protected virtual void AddScrollStateAttributesToRender(HtmlTextWriter writer)
{
if (ScrollStateEnabled)
{
}
}
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.
protected override void OnLoad(EventArgs e)
{
base.OnLoad(e);
if (this.ScrollStateEnabled)
{
}
}
...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.
DanLudwig.Controls.Client.ListBox = function(element)
{
this._scrollStateEnabled = null;
}
set_scrollStateEnabled : function(value)
{
if (this._scrollStateEnabled !== value)
{
this._scrollStateEnabled = value;
this.raisePropertyChanged('_scrollStateEnabled');
}
}
,
get_scrollStateEnabled : function()
{
return this._scrollStateEnabled;
}
,
get_elementContainer : function()
{
if (this.get_horizontalScrollEnabled()
|| this.get_scrollStateEnabled())
{
return this.get_element().parentNode;
}
else
{
return null;
}
}
,
get_elementState : function()
{
if (this.get_scrollStateEnabled())
{
}
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:
_onContainerScroll : function(e)
{
this._updateScrollState();
}
,
get_scrollState : function()
{
var scrollingElement = this.get_elementContainer();
if (!this.get_horizontalScrollEnabled())
scrollingElement = this.get_element();
return scrollingElement.scrollTop + ':' + scrollingElement.scrollLeft;
}
,
_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:
initialize : function()
{
DanLudwig.Controls.Client.ListBox.callBaseMethod(this, 'initialize');
this._initializeEvents();
this._initializeUI();
}
,
_initializeEvents : function()
{
if (this.get_horizontalScrollEnabled())
{
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);
this._onkeydownContainerHandler = Function.createDelegate(
this, this._onContainerKeyDown);
$addHandlers(this.get_elementContainer(),
{
'keydown' : this._onContainerKeyDown
}, this);
if (this.get_scrollStateEnabled())
{
this._onscrollContainerHandler = Function.createDelegate(
this, this._onContainerScroll);
$addHandlers(this.get_elementContainer(),
{
'scroll' : this._onContainerScroll
}, this);
}
}
else if (this.get_scrollStateEnabled())
{
this._onscrollHandler = Function.createDelegate(
this, this._onScroll);
$addHandlers(this.get_element(),
{
'scroll' : this._onScroll
}, this);
}
}
,
_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.
ScrollStateEnabled | HorizontalScrollEnabled |
True | False |
True |
ListBox.onKeyDown ListBox.onChange DIV.onKeyDown DIV.onScroll
|
ListBox.onScroll
|
False |
ListBox.onKeyDown ListBox.onChange 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:
_initializeUI : function()
{
var listBox = this.get_element();
var container = this.get_elementContainer();
if (this.get_horizontalScrollEnabled())
{
container.originalListBoxSize = listBox.size;
container.style.height = this.get_correctContainerHeight() + 'px';
if (this.get_element().options.length > this.get_element().size)
{
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';
}
}
}
,
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 LinkButton
s. 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:
_onScroll : function(e)
{
if (this.get_element() && !this.get_element().disabled
&& !this._supressNextScroll)
{
this._updateScrollState();
e.stopPropagation();
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
.
else if (this.get_scrollStateEnabled())
{
this._onscrollHandler = Function.createDelegate(
this, this._onScroll);
$addHandlers(this.get_element(),
{
'scroll' : this._onScroll
,'change' : this._onScroll
}, this);
}
_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 UpdatePanel
s 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 UpdatePanel
s. You can try commenting out the Page_Init
handler in the demo codebehind, but regardless of UpdatePanel
s, 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.