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

Advanced AJAX ListBox Component v0.5

0.00/5 (No votes)
9 Apr 2008CPOL6 min read 1   269  
Enforcing mouse scroll wheel behavior across target browsers.

Image 1

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:

  1. 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.
  2. 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.
  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 the scroll state event 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.

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 BehaviorRequiresContainerScroll == trueRequiresContainerScroll == false
IE7Only scrolls when mouse is positioned over scrollbars.Scrolls the ListBox by default.
IE6Scrolls the DIV container by default.n/a
FF1Scrolling is prevented by default.Scrolls the ListBox by default when the ListBox has focus.
FF1.5Scrolls the ListBox by default.Scrolls the ListBox by default.
FF2Scrolls the ListBox by default.Scrolls the ListBox by default.
Opera9Scrolls 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

JavaScript
// 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!

JavaScript
_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.

JavaScript
_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.

JavaScript
// 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.

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
    }

    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:

C#
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:

C#
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...

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)