Introduction
My open source AJAX Web Portal, www.dropthings.com, has an ASP.NET AJAX Extender which provides multi-column drag and drop for widgets. You can see similar drag and drop behavior in commercial AJAX Start Pages like Pageflakes. The Extender allows reordering of content on the same column and drag and drop content between columns. It also supports client-side notification so that you can call a web service and store the position of the content behind the scene without producing (async) postback.
Background
Drag and Drop is very popular in AJAX websites. You can rearrange content on a website as you like, and this gives you some level of personalization. However, free form drag drop is a problem because the content gets messy as you drag things around on the page, and there's no logical organization. So, a popular choice for drag and drop is to use column-wise content flow where you can drag and drop content within a column or across columns. This ASP.NET AJAX Extender allows you to do that very easily.
I first thought of going for a plain vanilla JavaScript based solution for drag and drop. It requires less code, less architectural complexity, and provides better speed. Another reason was the high learning curve for making Extenders the proper way in ASP.NET AJAX. However, writing a proper extender which pushes ASP.NET AJAX to the limit is a very good way to learn under-the-hood secrets of the ASP.NET AJAX framework itself. So, the two extenders I will introduce here will tell you almost everything you need to know about ASP.NET AJAX Extenders.
The AJAX Control Toolkit (ACT) comes with a DragPanel
extender which I could use to provide drag and drop support to panels. It also has a ReorderList
control which I could use to provide reordering of items in a single list. Widgets are basically panels that flow vertically in each column. So, it might be possible that I could create a reorder list in each column and use the DragPanel
to drag the widgets. But I could not use the ReorderList
because:
ReorderList
strictly uses the HTML Table to render its items in a column. I have no table inside the columns. Only a Panel
is there inside an UpdatePanel
.ReorderList
takes a Drag Handle template and creates a drag handle for each item at runtime. I already have a drag handle created inside a Widget which is the widget header. So, I cannot allow the ReorderList
to create another drag handle.- I need client-side callback on drag and drop so that I can make AJAX calls and persist the widget positions. The callback must give me the
Panel
where the widget is dropped, which is dropped, and at what position.
The next challenge is with the DragPanel
extender. The default implement of Drag & Drop in the AJAX Control Toolkit has some problems:
- When you start dragging, the item becomes absolutely positioned, but when you drop it, it does not become static positioned. There's a small hack needed for restoring the original position to "static".
- It does not put the dragging item on top of all items. As a result, when you start dragging, you see the item being dragged below other items, which makes the drag get stuck especially when there's an
IFRAME
.
So, I have made a CustomDragDropExtender
and a CustomFloatingExtender
. CustomDragDropExtender
is for the column containers where the widgets are placed. It provides the reordering support. You can attach this extender to any Panel
control.
How to use the extender
Here's how you can attach this extender to any Panel
and make that Panel
support drag and drop of Widgets:
1: <asp:Panel ID="LeftPanel" runat="server" class="widget_holder" columnNo="0">
2: <div id="DropCue1" class="widget_dropcue">
3: </div>
4: </asp:Panel>
5:
6: <cdd:CustomDragDropExtender ID="CustomDragDropExtender1"
7: runat="server"
8: TargetControlID="LeftPanel"
9: DragItemClass="widget"
10: DragItemHandleClass="widget_header"
11: DropCueID="DropCue1"
12: OnClientDrop="onDrop" />
<cdd:CustomDragDropExtender>
offers the following properties:
TargetControlID
- ID of the Panel
which becomes the Drop zoneDragItemClass
- All child elements inside the Panel
having this class will become draggable. E.g., Widget DIV
has this class so that it can become draggable.DragItemHandleClass
- Any child element having this class inside the draggable elements will become the drag handle for the draggable element. E.g., Widget Header
area has this class, so it acts as the drag handle for the Widget.DropCueID
- ID of an element inside the Panel
which acts as the Drop Cue.OnClientDrop
- Name of a JavaScript function which is called when the Widget is dropped on the Panel
.
The LeftPanel
becomes a widget container which allows widgets to be dropped on it and reordered. The DragItemClass
attribute on the extender defines the items which can be ordered. This prevents non-widget HTML Div
s from getting ordered. Only the DIV
s with the class "widget" are ordered. Say, there are five DIV
s with their class
named "widget". It will allow reordering of only those five DIV
s:
1: <div id="LeftPanel" class="widget_holder" >
2: <div class="widget"> ... </div>
3: <div class="widget"> ... </div>
4:
5: <div class="widget"> ... </div>
6: <div class="widget"> ... </div>
7: <div class="widget"> ... </div>
8:
9: <div>This DIV will not move</div>
10: <div id="DropCue1" class="widget_dropcue"></div>
11: </div>
When a widget is dropped on the Panel
, the extender fires the function specified in OnClientDrop
. It offers standard AJAX Events. But, unlike basic AJAX events where you have to programmatically bind to events, you can define a property and specify the function name to call. So, instead of doing this:
1: function pageLoad( sender, e ) {
2:
3: var extender1 = $get("CustomDragDropExtender1");
4: extender1.add_onDrop( onDrop );
5:
6: }
you can do this:
1: <cdd:CustomDragDropExtender ID="CustomDragDropExtender1"
2: runat="server"
3: OnClientDrop="onDrop" />
When the event is raised, the function named onDrop
gets fired. This is done with the help of some handy library available in the AJAX Control Toolkit project.
When the event is fired, it sends the container, the widget, and the position of the widget where the widget is dropped.
1: function onDrop( sender, e )
2: {
3: var container = e.get_container();
4: var item = e.get_droppedItem();
5: var position = e.get_position();
6:
7:
8:
9: var instanceId = parseInt(item.getAttribute("InstanceId"));
10: var columnNo = parseInt(container.getAttribute("columnNo"));
11: var row = position;
12:
13: WidgetService.MoveWidgetInstance( instanceId, columnNo, row );
14: }
The widget location is updated by calling WidgetService
.MoveWidgetInstance.
CustomDragDropExtender
has three files:
- CustomDragDropExtender.cs - The server side extender implementation
- CustomDragDropDesigner.cs - The designer class for the extender
- CustomDragDropExtender.js - The client side script for the extender
The server-side class CustomDragDropExtender.cs has the following code:
1: [assembly: System.Web.UI.WebResource("CustomDragDrop.CustomDragDropBehavior.js",
"text/javascript")]
2:
3: namespace CustomDragDrop
4: {
5: [Designer(typeof(CustomDragDropDesigner))]
6: [ClientScriptResource("CustomDragDrop.CustomDragDropBehavior",
"CustomDragDrop.CustomDragDropBehavior.js")]
7: [TargetControlType(typeof(WebControl))]
8: [RequiredScript(typeof(CustomFloatingBehaviorScript))]
9: [RequiredScript(typeof(DragDropScripts))]
10: public class CustomDragDropExtender : ExtenderControlBase
11: {
12:
13:
14: [ExtenderControlProperty]
15: public string DragItemClass
16: {
17: get
18: {
19: return GetPropertyValue<String>("DragItemClass", string.Empty);
20: }
21: set
22: {
23: SetPropertyValue<String>("DragItemClass", value);
24: }
25: }
26:
27: [ExtenderControlProperty]
28: public string DragItemHandleClass
29: {
30: get
31: {
32: return GetPropertyValue<String>("DragItemHandleClass", string.Empty);
33: }
34: set
35: {
36: SetPropertyValue<String>("DragItemHandleClass", value);
37: }
38: }
39:
40: [ExtenderControlProperty]
41: [IDReferenceProperty(typeof(WebControl))]
42: public string DropCueID
43: {
44: get
45: {
46: return GetPropertyValue<String>("DropCueID", string.Empty);
47: }
48: set
49: {
50: SetPropertyValue<String>("DropCueID", value);
51: }
52: }
53:
54: [ExtenderControlProperty()]
55: [DefaultValue("")]
56: [ClientPropertyName("onDrop")]
57: public string OnClientDrop
58: {
59: get
60: {
61: return GetPropertyValue<String>("OnClientDrop", string.Empty);
62: }
63: set
64: {
65: SetPropertyValue<String>("OnClientDrop", value);
66: }
67: }
68:
69: }
70: }
Most of the code in the extender defines the property. The important part is the declaration of the class:
[assembly: System.Web.UI.WebResource("CustomDragDrop.CustomDragDropBehavior.js",
"text/javascript")]
namespace CustomDragDrop
{
[Designer(typeof(CustomDragDropDesigner))]
[ClientScriptResource("CustomDragDrop.CustomDragDropBehavior",
"CustomDragDrop.CustomDragDropBehavior.js")]
[TargetControlType(typeof(WebControl))]
[RequiredScript(typeof(CommonToolkitScripts))]
[RequiredScript(typeof(TimerScript))]
[RequiredScript(typeof(FloatingBehaviorScript))]
[RequiredScript(typeof(DragDropScripts))]
[RequiredScript(typeof(DragPanelExtender))]
[RequiredScript(typeof(CustomFloatingBehaviorScript))]
public class CustomDragDropExtender : ExtenderControlBase
{
The extender class inherits from ExtenderControlBase
defined in the AJAX Control Toolkit (ACT) project. This base class has additional features over the AJAX runtime provided Extender
base class. It allows you to define the RequiredScript
attribute, which makes sure all the required scripts are downloaded before the extender script is downloaded and initialized. This extender has dependency over another extender named CustomFloatingBehavior
. It also depends on ACT's DragDropManager
. So, the RequiredScript
attribute makes sure those are downloaded before this extender's script is downloaded. The ExtenderControlBase
is a pretty big class and does a lot of work for us. It contains default implementations for discovering all the script files for the extender and rendering them properly.
The [assembly:System.Web.UI.WebResource]
attribute defines the script file containing the script for the extender. The script file is an embedded resource file.
The [ClientScriptResource]
attribute defines the scripts required for the extender. This class is also defined in the ACT. ExtenderControlBase
uses this attribute to find out which .js files are working for the extender, and renders them properly.
The challenge is to make the client-side JavaScript for the extender. On the js file, there's a JavaScript pseudo class:
1: Type.registerNamespace('CustomDragDrop');
2:
3: CustomDragDrop.CustomDragDropBehavior = function(element) {
4:
5: CustomDragDrop.CustomDragDropBehavior.initializeBase(this, [element]);
6:
7: this._DragItemClassValue = null;
8: this._DragItemHandleClassValue = null;
9: this._DropCueIDValue = null;
10: this._dropCue = null;
11: this._floatingBehaviors = [];
12: }
During initialize, it hooks on the Panel
it is attached to and the drop cue to show while drag and drop is going on over the Panel
:
1: CustomDragDrop.CustomDragDropBehavior.prototype = {
2:
3: initialize : function() {
4:
5: AjaxControlToolkit.DragDropManager.registerDropTarget(this);
6:
7:
8:
9: window.setTimeout( Function.createDelegate( this,
this._initializeDraggableItems ), 3000 );
10:
11: this._dropCue = $get(this.get_DropCueID());
12: },
After initializing the DragDropManager
and marking the Panel
as a drop target, it starts a timer to discover the draggable items inside the Panel
and create the floating behavior for them. Floating behavior is the one which makes a DIV
draggable.
FloatingBehavior
makes a DIV
freely draggable on the page. But it does not offer drop functionality. DragDropBehavior
offers the drop functionality, which allows a freely moving DIV
to rest on a fixed position.
Discovering and initializing floating behavior for the draggable items is the challenging work:
1:
2:
3: _initializeDraggableItems : function()
4: {
5: this._clearFloatingBehaviors();
6:
7: var el = this.get_element();
8:
9: var child = el.firstChild;
10: while( child != null )
11: {
12: if( child.className == this._DragItemClassValue && child != this._dropCue)
13: {
14: var handle = this._findChildByClass(child,
this._DragItemHandleClassValue);
15: if( handle )
16: {
17: var handleId = handle.id;
18: var behaviorId = child.id + "_WidgetFloatingBehavior";
19:
20:
21: var floatingBehavior =
$create(CustomDragDrop.CustomFloatingBehavior,
22: {"DragHandleID":handleId, "id":behaviorId,
"name": behaviorId}, {}, {}, child);
23:
24: Array.add( this._floatingBehaviors, floatingBehavior );
25: }
26: }
27: child = child.nextSibling;
28: }
29: },
Here's the algorithm:
- Run through all immediate child elements of the control where the extender is attached to
- If the child item has the class for draggable item, then:
- Find any element under the child item which has the class for the Drag handle
- If such an item is found, then attach a
CustomFloatingBehavior
with the child item
The _findChildByClass
function recursively iterates through all the child elements and looks for an element which has the defined class. It's an expensive function. So, it is important that the drag handle is very close to the draggable element.
1: _findChildByClass : function(item, className)
2: {
3:
4: var child = item.firstChild;
5: while( child != null )
6: {
7: if( child.className == className ) return child;
8: child = child.nextSibling;
9: }
10:
11:
12: child = item.firstChild;
13: while( child != null )
14: {
15: var found = this._findChildByClass( child, className );
16: if( found != null ) return found;
17: child = child.nextSibling;
18: }
19: },
When the user drags an item over the Panel
where the extender is attached to, DragDropManager
fires the following events:
1: onDragEnterTarget : function(dragMode, type, data) {
2: this._showDropCue(data);
3: },
4:
5: onDragLeaveTarget : function(dragMode, type, data) {
6: this._hideDropCue(data);
7: },
8:
9: onDragInTarget : function(dragMode, type, data) {
10: this._repositionDropCue(data);
11: },
Here, we deal with the drop cue. The challenging work is to find out the right position for the drop cue.
We need to find out where we should show the drop cue based on where the user is dragging the item. The idea is to find out the widget which is immediately below the dragged item. The item is pushed down by one position and the drop cue takes its place. While dragging, the position of the drag item can be found easily. Based on that, I locate the widget below the drag item:
1: _findItemAt : function(x,y, item)
2: {
3: var el = this.get_element();
4:
5: var child = el.firstChild;
6: while( child != null )
7: {
8: if( child.className == this._DragItemClassValue &&
child != this._dropCue && child != item )
9: {
10: var pos = Sys.UI.DomElement.getLocation(child);
11:
12: if( y <= pos.y )
13: {
14: return child;
15: }
16: }
17: child = child.nextSibling;
18: }
19:
20: return null;
21: },
This function returns the widget which is immediately under the dragged item. Now, I add the drop cue immediately above the widget:
1: _repositionDropCue : function(data)
2: {
3: var location = Sys.UI.DomElement.getLocation(data.item);
4: var nearestChild = this._findItemAt(location.x, location.y, data.item);
5:
6: var el = this.get_element();
7:
8: if( null == nearestChild )
9: {
10: if( el.lastChild != this._dropCue )
11: {
12: el.removeChild(this._dropCue);
13: el.appendChild(this._dropCue);
14: }
15: }
16: else
17: {
18: if( nearestChild.previousSibling != this._dropCue )
19: {
20: el.removeChild(this._dropCue);
21: el.insertBefore(this._dropCue, nearestChild);
22: }
23: }
24: },
One exception to consider here is that there can be no widget immediately below the dragged item. It happens when the user is trying to drop the widget at the bottom of a column. In that case, the drop cue is shown at the bottom of the column.
When the user releases the widget, it drops right on top of the drop cue and the drop cue disappears. After the drop, the onDrop
event is raised to notify where the widget is dropped.
1: _placeItem : function(data)
2: {
3: var el = this.get_element();
4:
5: data.item.parentNode.removeChild( data.item );
6: el.insertBefore( data.item, this._dropCue );
7:
8:
9: var position = 0;
10: var item = el.firstChild;
11: while( item != data.item )
12: {
13: if( item.className == this._DragItemClassValue ) position++;
14: item = item.nextSibling;
15: }
16: this._raiseDropEvent( el,
data.item,
position );
17: }
Generally, you can make events in extenders by adding two functions in the extender:
1: add_onDrop : function(handler) {
2: this.get_events().addHandler("onDrop", handler);
3: },
4:
5: remove_onDrop : function(handler) {
6: this.get_events().removeHandler("onDrop", handler);
7: },
But this does not give you the support for defining the event listener name in the ASP.NET declaration:
1: <cdd:CustomDragDropExtender ID="CustomDragDropExtender1"
2: runat="server"
3: OnClientDrop="onDrop" />
The declaration only allows properties. In order to support such declarative assignment of events, we need to first introduce a property named OnClientDrop
in the extender. Then, during the assignment of the property, we need to find out the function specified there and attach the event notification on that function. The discovery of the function from its name is done by CommonToolkitScripts.resolveFunction
, which is available in the ACT project.
1:
2: get_onDrop : function() {
3: return this.get_events().getHandler("onDrop");
4: },
5:
6: set_onDrop : function(value) {
7: if (value && (0 < value.length)) {
8: var func = CommonToolkitScripts.resolveFunction(value);
9: if (func) {
10: this.add_onDrop(func);
11: } else {
12: throw Error.argumentType('value', typeof(value), 'Function',
'resize handler not a function,' +
' function name, or function text.');
13: }
14: }
15: },
Raising the event is same as the basic AJAX events:
1: _raiseEvent : function( eventName, eventArgs ) {
2: var handler = this.get_events().getHandler(eventName);
3: if( handler ) {
4: if( !eventArgs ) eventArgs = Sys.EventArgs.Empty;
5: handler(this, eventArgs);
6: }
7: },
This is all about the CustomDragDropExtender
. The next challenge is to make the CustomFloatingBehavior
. The server-side class is declared as:
1: [assembly: System.Web.UI.WebResource("CustomDragDrop.CustomFloatingBehavior.js",
"text/javascript")]
2:
3: namespace CustomDragDrop
4: {
5: [Designer(typeof(CustomFloatingBehaviorDesigner))]
6: [ClientScriptResource("CustomDragDrop.CustomFloatingBehavior",
"CustomDragDrop.CustomFloatingBehavior.js")]
7: [TargetControlType(typeof(WebControl))]
8: [RequiredScript(typeof(DragDropScripts))]
9: public class CustomFloatingBehaviorExtender : ExtenderControlBase
10: {
11: [ExtenderControlProperty]
12: [IDReferenceProperty(typeof(WebControl))]
13: public string DragHandleID
14: {
15: get
16: {
17: return GetPropertyValue<String>("DragHandleID", string.Empty);
18: }
19: set
20: {
21: SetPropertyValue<String>("DragHandleID", value);
22: }
23: }
24: }
25: }
There’s only one property – DragHandleID
. The Widget’s header works as the drag handle. So, the header ID is specified here.
This extender has a dependency on DragDropManager
so the [RequiredScript(typeof(DragDropScripts))]
attribute is there.
Besides the designer class, there’s one more class which CustomDragDropExtender
needs in order to specify its dependency over this floating behavior:
1: [ClientScriptResource(null, "CustomDragDrop.CustomFloatingBehavior.js")]
2: public static class CustomFloatingBehaviorScript
3: {
4: }
This class can be used inside the RequiredScript
attribute. It only defines which script file contains the client-side code for the extender.
The client-side JavaScript is the same as the FloatingBehavior
that comes with ACT. The only difference is some hack when the drag starts. DragDropManager
does not return the item being dragged to static position once it makes it absolute. It also does not increase the zIndex
of the item. If the drag item does not become the top most item, while dragging, it goes below other elements on the page. So, I have made some changes in the mouseDownHandler
of the behavior, to add these features:
1: function mouseDownHandler(ev) {
2: window._event = ev;
3: var el = this.get_element();
4:
5: if (!this.checkCanDrag(ev.target)) return;
6:
7:
8: _location = Sys.UI.DomElement.getLocation(el);
9:
10:
11: el.style.width = el.offsetWidth + "px";
12: el.style.height = el.offsetHeight + "px";
13: Sys.UI.DomElement.setLocation(el, _location.x, _location.y);
14:
15: _dragStartLocation = Sys.UI.DomElement.getLocation(el);
16:
17: ev.preventDefault();
18:
19: this.startDragDrop(el);
20:
21:
22: el.originalPosition = "static";
23: el.originalZIndex = el.style.zIndex;
24: el.style.zIndex = "60000";
25: }
Setting el.originalPosition = static
fixes the bug in DragDropManager
. It incorrectly stores absolute has the originalPosition
when startDragDrop
is called. So, after calling this function, I set it to the correct originalPosition
which is static
.
When the drag completes, the original zIndex
is restored, and left, top, width, and height are cleared. The DragDropManager
makes the item position static, but it does not clear the left, top, width, and height attributes. This moves the element away from the place where it is dropped. This bug is fixed in the onDragEnd
event:
1: this.onDragEnd = function(canceled) {
2: if (!canceled) {
3: var handler = this.get_events().getHandler('move');
4: if(handler) {
5: var cancelArgs = new Sys.CancelEventArgs();
6: handler(this, cancelArgs);
7: canceled = cancelArgs.get_cancel();
8: }
9: }
10:
11: var el = this.get_element();
12: el.style.width = el.style.height = el.style.left = el.style.top = "";
13: el.style.zIndex = el.originalZIndex;
14: }
Conclusion
You can use the extender as it is. It should require no change in the code. It has been tested on IE 6, 7, Firefox 1.5, 2.0. Other browsers may not work. There's been a lot of discussion about this extender at my blog post. You are welcome to participate there.