Introduction
This control allows the user to reorder ListBox
items using a drag'n'drop method. It supports the standard Items
property (with strings as well as custom objects)
and the DataSource
property, if the data source implements the IList
interface (i.e., BindingList
).
Background
There are several code snippets in the web that allow the user to sort a listbox, but most of them support only a string list (or any other that is hard-coded).
My approach is based on BFree's answer at StackOverflow: http://stackoverflow.com/a/805267/540761.
Why the native approach is not enaugh?
The code from the link above is simple and good as long as we use only the Items
property with strings (or, more generally, with one particular type).
Sometimes it is nice to use a DataSource
(for example, to use the same items list for many controls) with items other than strings. It is also a good idea to have
a control that will be item-type independent. To achieve that, we will create a UserSortableListbox
class that inherits from System.Windows.Forms.ListBox
.
To keep things simple
I decided to assume two important things to keep the code simple:
UserSortableListbox
is always user-sortable (there is no possibility to disable drag and drop)- The only supported
SelectionMode
is SelectionMode.One
.
Both functionality can be quite easily implemented and will be described later.
Drag and drop
To allow the user to reorder items using drag and drop, we have to handle three events: beginning of the reorder (MouseDown
event),
moving the element (DragOver
), and dropping an item (DragDrop
).
We start the drag and drop mechanism after the MouseDown
event:
protected override void OnMouseDown(MouseEventArgs e)
{
base.OnMouseDown(e);
if (SelectedItem == null)
{
return;
}
sourceIndex = SelectedIndex;
OnSelectedIndexChanged(e);
DoDragDrop(SelectedItem, DragDropEffects.Move);
}
sourceIndex
is simply defined somewhere in the class:
private int sourceIndex = -1;
The only thing that needs explanation here is OnSelectedIndexChanged(e)
. We need this because
SelectedIndexChanged
will not be launched when we handle MouseDown
.
Handling the move of an item is trivial:
protected override void OnDragOver(DragEventArgs e)
{
base.OnDragOver(e);
e.Effect = DragDropEffects.Move | DragDropEffects.Scroll;
}
Now, the most interesting part. After the DragDrop
event occurs, we can do the job and move the dropped item to the proper place.
Here is the first version of the OnDragDrop
method:
protected override void OnDragDrop(DragEventArgs e)
{
base.OnDragDrop(e);
Point point = PointToClient(new Point(e.X, e.Y));
int index = IndexFromPoint(point);
if (index < 0) index = Items.Count - 1;
if (index > sourceIndex)
{
Items.Insert(index + 1, Items[sourceIndex]);
Items.RemoveAt(sourceIndex);
}
else
{
Items.Insert(index, Items[sourceIndex]);
Items.RemoveAt(sourceIndex + 1);
}
SelectedIndex = index;
}
Some comments to this code:
- We don't have a simple way to indicate a new index of the dropped item, as in the
OnMouseDown
method. However, we can use the inherited IndexFromPoint
method,
which will give us what we are looking for. The only thing we must remember is to transform e.X
and e.Y
to client coordinates. - In this line, we have to decide how we will handle dropping the item below the last element in the listbox (because you cannot drag an item outside the listbox, the only situation
where
IndexFromPoint
returns -1 will be when the user drops an item below the last one). The most intuitive way to handle this situation is to set the destination index as
the last index in the list. - When we have a source and destination index, we can move items. First, we copy an item by inserting
Items[sourceIndex]
once again in Items
,
and then we remove the 'original one'. If the destination index
is greater than (below) the source, removing from sourceIndex
will affect the destination index,
so we are inserting at index + 1
. Similarly, when the destination index
is less than (above) the source, inserting at the position index will affect
sourceIndex
, so we have to remove at sourceIndex + 1
. - We remove the previously selected item, so it is time to reselect it at its new position.
We have recreated the basic solution. The only advantage is that there is no more e.Data.GetData()
in the code. Luckily enough, adding DataSource
support
is really simple now. We just have to find a common class (or interface) for DataSource
and the Items
field that will let us manipulate its elements,
especially provide the Count
, Insert
, and RemoveAt
methods. Items
has the ObjectCollection
type,
which implements IList
, ICollection
, and IEnumerable
. Because the IList
interface is exactly
what we are searching for and we can assume that our DataSource
will implement it, we will create a variable with this type called items
and replace
all Items
with items
in the OnDragDrop
method, which will do the job and allow us to use DataSource
in UserSortableListbox
.
IList items = DataSource != null ? DataSource as IList : Items;
More functionality
To make the control more useful, we can add a Reorder
event, which will be fired when the user moves an item:
public class ReorderEventArgs : EventArgs
{
public int index1, index2;
}
public delegate void ReorderHandler(object sender, ReorderEventArgs e);
public event ReorderHandler Reorder;
index1
and index2
are the source and destination indices of the moved item. Here is the complete OnDragDrop
method,
including DataSource
support and Reorder
event:
protected override void OnDragDrop(DragEventArgs e)
{
base.OnDragDrop(e);
Point point = PointToClient(new Point(e.X, e.Y));
int index = IndexFromPoint(point);
IList items = DataSource != null ? DataSource as IList : Items;
if (index < 0) index = items.Count - 1;
if (index != sourceIndex)
{
if (index > sourceIndex)
{
items.Insert(index + 1, items[sourceIndex]);
items.RemoveAt(sourceIndex);
}
else
{
items.Insert(index, items[sourceIndex]);
items.RemoveAt(sourceIndex + 1);
}
if (null != Reorder)
Reorder(this, new ReorderEventArgs() { index1 = sourceIndex, index2 = index });
}
SelectedIndex = index;
}
Keep the implementation simple
As I mentioned above, we assumed drag and drop cannot be disabled and only SelectionMode.One
would be available while using the control, so we should hide AllowDrop
and SelectionMode
from the Designer and set the proper values in the constructor:
[Browsable(false)]
new public bool AllowDrop
{
get { return true; }
set { }
}
[Browsable(false)]
new public SelectionMode SelectionMode
{
get { return SelectionMode.One; }
set { }
}
public UserSortableListBox()
{
base.AllowDrop = true;
base.SelectionMode = SelectionMode.One;
}
Of course, we can add support for those properties we had just disabled. If you want to allow disabling moving items, you just need to check
AllowDrop
(or another new property) at the beginning of OnMouseMove
and do or don't DoDragDrop()
.
Supporting other selection modes is more complicated, but still simple. Instead of moving an item and have a sourceIndex
, we would have
to add a sourceIndex[]
array, which would be copied from SelectedIndices
in OnMouseDown
, and primarySourceIndex
, which will contain
the clicked item (also in OnMouseDown
, it can be obtained from IndexFromPoint
without the need for transforming coordinates).
Then, in the OnDragDrop
method, we move all items using (primarySourceIndex - index)
positions: item
at sourceIndex[i]
will be moved to the sourceIndex[i] + primarySourceIndex - index
position.
Using the code
Using this control is as simple as using the standard ListBox
. The Reorder event is available in the Designer and can be handled easily.
userSortableListBox1.Reorder +=
new synek317.Controls.UserSortableListBox.ReorderHandler(this.optionsListBox_Reorder);
void optionsListBox_Reorder(object sender, UserSortableListBox.ReorderEventArgs e)
{
}
In the downloads section, I have included a .dll file with the compiled control so you can just add it to your project references and use it out of the box.
Points of interest
I'm not sure why SelectedIndexChanged
is not launched when the MouseDown
event is used for the listbox. However, I used a workaround and my control simply launches
the SelectedIndexChanged
event from MouseDown
.