Advanced AJAX ListBox Component v0.5
Enforcing mouse scroll wheel behavior across target browsers.
Introduction
In my last article, we modified our ListBox to allow meta keys (SHIFT and CONTROL) to be used to select multiple items in the ListBox. In this article, we're going to keep our attention focused on the user and handle a different input scenario: the almighty mouse wheel.
Background
Once again, let's review the requirements checklist we drew out in the second article:
It is difficult or impossible to select multiple disparate items at once by using the SHIFT and CONTROL keys because theironkeydown
events trigger the scrollbars to be repositioned based on the first item(s) selected. We should read thekeyCode
of the event and bypass our scrolling code for "meta" key presses like this.- The ability to scroll through the items using a mouse wheel depends on both the browser and the 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'sHorizontalScrollEnabled
property is explicitly set totrue
. We should be able to preserve the scroll state event whenHorizontalScrollEnabled
isfalse
.- 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.
It should be obvious which requirement to tackle next. It wouldn't be a good idea to optimize the event code when we haven't even handled all of the required events yet!
Compatibility Shmemadability
The default effect of mouse wheel events depends on the derived RequiresContainerScroll
property, browser, and the version you are using. Here's a breakdown of how it currently behaves.
Default Behavior | RequiresContainerScroll == true |
RequiresContainerScroll == false |
IE7 | Only scrolls when mouse is positioned over scrollbars. | Scrolls the ListBox by default. |
IE6 | Scrolls the DIV container by default. |
n/a |
FF1 | Scrolling is prevented by default. | Scrolls the ListBox by default when the ListBox has focus. |
FF1.5 | Scrolls the ListBox by default. | Scrolls the ListBox by default. |
FF2 | Scrolls the ListBox by default. | Scrolls the ListBox by default. |
Opera9 | Scrolls the ListBox by default. | n/a |
It would be nice to be able to control the mouse wheel so that we can either default to the above behavior matrix, prevent wheel scrolling, or enforce it. It would be even nicer if this property could be set on the server control, either declaratively or programmatically. I like to have nice things, so let's do it like that.
Mouse Wheel 101
The good news is, we can support this for the 6 browsers we've targeted to this point (which, with the exception of Safari 2 thus far, encompasses the majority browsers supported by Microsoft's ASP.NET AJAX Extensions framework). There is a consistency problem that we have to hack around though.
Both Opera and IE have an onmousewheel
client event that can be handled using the event framework we're taking advantage of. Firefox, however, does not. What it does have is a DOMMouseScroll
event that we can listen for. Among the many differences between the two, the most significant is that the onmousewheel
event will be attached to the control prototype, whereas the DOMMouseScroll
event will be attached to the ListBox (or DIV
) element. The end result is that certain client control properties can't be referenced during the event handler, because for FF, the this
reference will point to the ListBox, not the prototype. Perhaps, there is a way to attach it to the prototype, but if there is, I sure couldn't figure out how to do it... so I wrote another hack :P
Painfully Familiar
// 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._mouseWheelScroll = null;
this._requiresContainerScroll = null;
this._scrollStateEnabled = null;
this._horizontalScrollEnabled = null;
this._scrollTop = null;
this._scrollLeft = null;
}
// 3d)
// Define the property get and set methods.
//
set_mouseWheelScroll : function(value)
{
if (this._mouseWheelScroll !== value)
{
this._mouseWheelScroll = value;
this.raisePropertyChanged('_mouseWheelScroll');
}
}
,
get_mouseWheelScroll : function()
{
return this._mouseWheelScroll;
}
,
You should be able to add a property like that in your sleep by now. We're going to write the server control property later, but let me warn you that it's not going to be a boolean. The client control's _mouseWheelScroll
field can be equal to true
, false
, or null
. Null values will not attempt to handle any responses to the mouse wheel. When false
, wheel scrolling should be prevented. When true
, wheel scrolling should be enforced. Knowing this, we have enough information to register the correct events.
Ready... Set... HACK!
_initializeEvents : function()
{
// handle mouse wheel events separately from all others
if (this.get_mouseWheelScroll() != null)
{
// IE and Opera have an onmousewheel event
this._onmousewheelHandler = Function.createDelegate(
this, this._onMouseWheel);
$addHandlers(this.get_element(),
{
'mousewheel' : this._onMouseWheel
}, this);
// also register the container's mouse wheel event
if (this.get_requiresContainerScroll())
{
$addHandlers(this.get_elementContainer(),
{
'mousewheel' : this._onMouseWheel
}, this);
}
// FF doesn't have an onmousewheel event
if (this.get_element().onmousewheel === undefined)
this.get_element().addEventListener('DOMMouseScroll',
this._onMouseWheel, false);
}
// rest of the event initialization code stays the same
}
,
// 3c)
// Define the event handlers
//
_onMouseWheel : function(e)
{
if (this._mouseWheelScroll == false)
{
e.preventDefault();
return false;
}
}
,
This code is sufficient enough to prevent mouse wheel scrolling when _mouseWheelScroll
is false
... in IE and Opera. Remember though, since FF registers the _onMouseWheel
handler using DOMMouseScroll
, it is attached to the ListBox instead of the prototype. So, this._mouseWheelScroll
is not defined for FF. The good news is, we can add this property to the ListBox just like we added an originalListBoxSize
property to the DIV
in _initializeUI()
.
Before we do that though, let's stop and think. Are there any other properties we'll need to access? Perhaps. Instead of just adding the _mouseWheelScroll
field, why don't we just add the whole prototype as a field. We're going to give the DIV
the same field while we're at it, because we're going to need it in the next article.
_initializeUI : function()
{
var listBox = this.get_element();
var container = this.get_elementContainer();
// hack to support mouse wheel scrolling
if (this.get_mouseWheelScroll() != null)
{
listBox._thisPrototype = this;
container._thisPrototype = this;
}
// rest of code stays the same
}
,
Which One is Backwards?
Now, we have everything we need to handle the case where _mouseWheelScroll
is equal to true
. In the case of IE and Opera, the e
parameter contains a wheelDelta
property inside its rawEvent
, which indicates the direction and magnitude of the mouse wheel movement. We're not concerned with the magnitude, so let's focus on direction. Moving the mouse wheel forward yields a positive integer, whereas moving the wheel towards you yields a negative one. This means that for positive values, we want to decrease the scrollTop
, and for negative values, we want to increase it. Because of this, we set our direction equal to 1 when wheelDelta
is negative, and -1 when wheelDelta
is positive.
Firefox, of course, exhibits the opposite behavior. When using the DOMMouseWheel
event, we can access a wheelDelta
-like variable using e.detail
. Rotating the wheel towards the front produces a negative value, whereas moving it down produces a positive value. So, we want to handle the direction in the opposite way we handled it for wheelDelta
. With all of this information, it's a piece of cake to set the scrollTop
property of the correct element and override the default browser behavior.
// 3c)
// Define the event handlers
//
_onMouseWheel : function(e)
{
var _this = this._thisPrototype
if (_this === undefined)
_this = this;
// stop the mouse wheel from scrolling
if (_this.get_mouseWheelScroll() == false)
{
e.preventDefault();
return false;
}
// enforce mouse wheel scrolling
else if (_this.get_mouseWheelScroll() == true)
{
var listBox = _this.get_element();
var container = _this.get_elementContainer();
var direction, scrollingElement;
if (this._thisPrototype === undefined) // IE & Opera
{
// negative wheelDelta should increase scrollTop,
// positive wheelDelta should decrease the scrollTop.
direction = (e.rawEvent.wheelDelta > 1) ? -1 : 1;
}
else
{
// detail's direction is opposite of wheelDelta
direction = (e.detail > 1) ? 1 : -1;
}
// scroll the correct element
if (_this.get_requiresContainerScroll())
scrollingElement = container;
else
scrollingElement = listBox;
// scroll the ListBox by the height of one item in the correct direction.
var stepSize = scrollingElement.scrollHeight / listBox.options.length;
var newScrollTop = scrollingElement.scrollTop + (stepSize * direction);
scrollingElement.scrollTop = newScrollTop;
// tell the browser we're taking care of the mouse wheel.
e.preventDefault();
return false;
}
}
,
Stick a Fork in It
Currently, the only way to test this is by manually changing the _mouseWheelScroll
field in the client control's constructor. The next step is to wire this client control property to a configurable server control property. We'll have an extra step involved this time though, because _mouseWheelScroll
is not a normal boolean property. Ideally, we'd like to have an enumeration that gives more meaning to these three values.
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
}
public enum ListBoxMouseWheelScrollSetting
{
NotSet,
Enforce,
Prevent
}
}
Our client control property won't recognize this enumeration though. So, we need two server control properties: one to set the value, and another to translate it into a value that the client control can work with:
public virtual ListBoxMouseWheelScrollSetting MouseWheelScroll
{
set { this.ViewState["MouseWheelScroll"] = value; }
get
{
object output = this.ViewState["MouseWheelScroll"];
if (output == null)
output = ListBoxMouseWheelScrollSetting.NotSet;
return (ListBoxMouseWheelScrollSetting)output;
}
}
protected virtual bool? MouseWheelScrollClientValue
{
get
{
if (MouseWheelScroll.Equals(
ListBoxMouseWheelScrollSetting.Enforce))
return true;
else if (MouseWheelScroll.Equals(
ListBoxMouseWheelScrollSetting.Prevent))
return false;
return null;
}
}
We can now configure the server control's MouseWheelScroll
property either declaratively in the ASPX page or programmatically in the code-behind (or other server-side code). However, during GetScriptDescriptors()
, we want to pass the derived property (a nullable bool
) to the client control, not the enumeration value:
protected virtual IEnumerable<ScriptDescriptor> GetScriptDescriptors()
{
ScriptControlDescriptor descriptor = new ScriptControlDescriptor(
"DanLudwig.Controls.Client.ListBox", this.ClientID);
descriptor.AddProperty("mouseWheelScroll",
this.MouseWheelScrollClientValue);
descriptor.AddProperty("requiresContainerScroll",
this.RequiresContainerScroll);
descriptor.AddProperty("scrollStateEnabled", this.ScrollStateEnabled);
descriptor.AddProperty("horizontalScrollEnabled",
this.HorizontalScrollEnabled);
descriptor.AddProperty("scrollTop", this.ScrollTop);
descriptor.AddProperty("scrollLeft", this.ScrollLeft);
return new ScriptDescriptor[] { descriptor };
}
Oh No, Not Again!
This is enough to satisfy most user-input scenarios in most browsers, but there are still a couple of inconsistencies. See if you can figure out what there is to fix next time...