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 their onkeydown
events trigger 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" 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's HorizontalScrollEnabled
property is explicitly set to true
. We should be able to preserve the scroll state event 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.
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
DanLudwig.Controls.Client.ListBox = function(element)
{
DanLudwig.Controls.Client.ListBox.initializeBase(this, [element]);
this._mouseWheelScroll = null;
this._requiresContainerScroll = null;
this._scrollStateEnabled = null;
this._horizontalScrollEnabled = null;
this._scrollTop = null;
this._scrollLeft = null;
}
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()
{
if (this.get_mouseWheelScroll() != null)
{
this._onmousewheelHandler = Function.createDelegate(
this, this._onMouseWheel);
$addHandlers(this.get_element(),
{
'mousewheel' : this._onMouseWheel
}, this);
if (this.get_requiresContainerScroll())
{
$addHandlers(this.get_elementContainer(),
{
'mousewheel' : this._onMouseWheel
}, this);
}
if (this.get_element().onmousewheel === undefined)
this.get_element().addEventListener('DOMMouseScroll',
this._onMouseWheel, false);
}
}
,
_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();
if (this.get_mouseWheelScroll() != null)
{
listBox._thisPrototype = this;
container._thisPrototype = this;
}
}
,
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.
_onMouseWheel : function(e)
{
var _this = this._thisPrototype
if (_this === undefined)
_this = this;
if (_this.get_mouseWheelScroll() == false)
{
e.preventDefault();
return false;
}
else if (_this.get_mouseWheelScroll() == true)
{
var listBox = _this.get_element();
var container = _this.get_elementContainer();
var direction, scrollingElement;
if (this._thisPrototype === undefined)
{
direction = (e.rawEvent.wheelDelta > 1) ? -1 : 1;
}
else
{
direction = (e.detail > 1) ? 1 : -1;
}
if (_this.get_requiresContainerScroll())
scrollingElement = container;
else
scrollingElement = listBox;
var stepSize = scrollingElement.scrollHeight / listBox.options.length;
var newScrollTop = scrollingElement.scrollTop + (stepSize * direction);
scrollingElement.scrollTop = newScrollTop;
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
{
}
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...