Contents
Introduction
This article focuses on developing an MVVM compatible ListBox-to-ListBox drag/drop helper for Silverlight. There are already many good articles out there that describe the MVVM pattern and the benefits of it - we are not going to try to confuse anyone any further.
Background
We needed a highly flexible drag and drop library for our Silverlight LOB application that was:
- MVVM pattern compliant
- Supports the
Single
, Multiple
, and Extended
ListBox selection modes
- Minimal coding required
- Drag hover tooltip with blendable templating support
- Low CPU usage
- Light weight
- Free
Looking around, we couldn't find a solution that met the above criteria. Those that were close either had too many features (too bulky) or didn't support MVVM (code-behind only).
We've used .NET 4.0 for the solution, but it wouldn't take much to convert it to .NET 3.5.
Prerequisites
We would have liked to have kept this solution non-reliant on any third-party framework; however, we required a wrapper for the MouseMove
event for the Listbox control.
So the solution makes use of an MVVM Framework - MVVM Light by Laurent Bugnion (GalaSoft). We use EventToCommand
and RelayCommand
to wire up the XAML to the ListDragDropHelper
class.
I'm sure that everyone has their favorite framework; but for this article, we wanted to keep the focus on the solution. However, it would be very easy to change this as there is only one property on the ListDragDropHelper
class, the RelayCommand
property named ChildMoveCommand
, that needs to be adjusted, and the MouseMove
event for each ListBox in the XAML code for your favorite MVVM framework equivalents.
Project Design
To help ensure that the code meets the MVVM pattern requirements, even though this is a small sample application, it is a good policy to break out the code out into separate project modules:
- SL MVVM DragDropHelper Sample - Main application containing the Views only
- Models - All data models
- ViewModels - All View Models and the View Model locator
- Services - Mock Data Service
- Helpers - Support classes
- ListDragDropSL - Drag drop service and the
DragDropToolTip
control
Below is a class diagram of the sample project that's been included. The sample project is an example of using the ListDragDropHelper class to re-tag and group products either individually or in bulk (extended multi-select).
ListDragDropHelper Class Overview
When we built the ListDragDropHelper
class, we wanted to limit the amount of work to wire up the View <-> ViewModel <-> ListDragDropHelper. So we've reduced the process to the following steps:
- Drop the
DragDropToolTip
control on a parent Panel
control (Grid
/Canvas
/etc..) that contains all the ListBoxes that will be involved in the drag/drop operation and give it a name;
- Declare a
ListDragDropHelper
property on the ViewModel for each ListBox that can initiate a drag/drop operation;
- Add a Trigger to each of the listboxes'
MouseMove
event;
- Code the hover & drop handlers in the ViewModel;
- Style the
DragDropToolTip
control.
We could have generated the DragDropToolTip
control totally in the ListDragDropHelper
class; however, we use it as a marker to quickly identify the Panel
control for the listboxes for the drag/drop operation. The upside is that this makes it easy for us to style the control in the Visual Studio XAML editor or Expression Blend.
How the ListDragDropHelper Class Works
We have selected the important parts of the class code to highlight the key functionality. To see the entire code, please download the solution from the above link included with this article. All source code is fully commented and easy to follow.
On initiation of a ListBox MouseMove
event, the ListDragDropHelper
class will:
- Checks if the mouse button is pressed;
- Figure out the XAML structure based on the positioning of the
DragDropToolTip
control;
- Position the
DragDropToolTip
control's Z-Index (for all panel types);
- Wire up the parent's (UserControl/Navigation Page)
MouseMove
and LeftMouseButtonUp
events;
Why didn't we use the ListBoxes' LeftMouseButtonDown
event? This event never fires. We've seen some solutions that use the SelectedItem
or GotFocus
events, but these events have issues. The SelectedItem
event will only fire when the item is initially selected. The other issue with the SelectedItem
event is that the drag operation will initiate on the LeftButtonMouseDown
even when the user doesn't want to drag. You could get around this by using a Timer
or watch the drag distance before initiating a drag/drop operation; however, you still have the initial issue of the SelectedItem
event only firing for a new selection. The GotFocus
event will only fire once for the ListBox control.
To get around these issues, as mentioned above, we monitor the MouseMove
event of the ListBox and use the CaptureMouse
(System.Windows.UIElement
) method to identify if the mouse button is pressed or not. There is a 'gotcha' with doing this, but we have a simple solution that is described further in the article that demonstrates how we manage this.
Private Function IsMouseDown() As Boolean
If _dragListBox IsNot Nothing AndAlso _dragListBox.CaptureMouse() Then
_dragListBox.ReleaseMouseCapture()
Return True
End If
Return False
End Function
The parent host control's MouseMove
and LeftMouseButtonUp
events will then manage the drag/drop operation to completion. During the drag/drop operation, the handlers in the ViewModel have total control over what and how the information is displayed in the DragDropToolTip
control giving the user feedback.
One of the requirements was to minimise CPU usage. Monitoring the MouseMove
event rapidly fires whilst the mouse is inside the bounding control that the event belongs to. When a drag/drop operation is in progress, we monitor the parent control's MouseMove
event so that we can bridge the XAML layers from one ListBox to the next. If you have multiple listboxes, like the included sample project, then the CPU will be heavily taxed as multiple ListDragDropHelper
classes track the position of the mouse in the same bounding panel control. To overcome this, we only monitor the parent MouseMove
once the drag/drop operation begins, and upon completion, we remove the handler. This way, only one ListDragDropHelper
class is tracking the mouse's position at any time during a drag/drop operation. The other benefit of using this approach is that it minimises the amount of coding to a single Trigger and per ListBox and a single entry point into the helper class.
Private Sub ChildContainerMouseMove(e As MouseEventArgs)
If (Not _isBusy) AndAlso (Not _isDragging) Then
If Not _isDragMode Then
If _dragListBox Is Nothing Then
_dragListBox = FindListBoxContainer(e.OriginalSource)
End If
_isDragMode = IsMouseDown()
If _isDragMode Then
...
If _DragDropToolTip IsNot Nothing Then
...
AddHandler _Host.MouseMove, AddressOf HostContainerMouseMove
AddHandler _Host.MouseLeftButtonUp, _
AddressOf HostContainerMouseLeftButtonUp
End If
End If
End If
End If
End Sub
Private Sub HostContainerMouseLeftButtonUp(Sender As Object, e As MouseEventArgs)
...
_isDragMode = IsMouseDown()
If _isDragging Then
...
RemoveHandler _Host.MouseMove, AddressOf HostContainerMouseMove
RemoveHandler _Host.MouseLeftButtonUp, _
AddressOf HostContainerMouseLeftButtonUp
End If
End Sub
A potential issue when you hook up several listboxes for drag drop operation (capturing the MouseDown
with CaptureMouse
during a MouseMove
event of a ListBox) using a single ListDragDropHelper
class is that more than one ListBox will fire a drag/drop operation. To overcome this, we have a shared (static) variable that tracks if we are in a drag/drop operation or not. This ensures that only one instance of the ListDragDropHelper
class is active at any time during a drag/drop operation.
Private Shared _isBusy As Boolean = False
...
Private Sub ChildContainerMouseMove(e As MouseEventArgs)
If (Not _isBusy) AndAlso (Not _isDragging) Then
...
End If
End Sub
Private Sub HostContainerMouseMove(Sender As Object, e As MouseEventArgs)
If _isDragMode Then
...
If Not _isDragging Then ...
_isDragging = (_isBusy = False)
If _isDragging Then _isBusy = _isDragging
...
End If
End If
End Sub
Private Sub HostContainerMouseLeftButtonUp(Sender As Object, e As MouseEventArgs)
...
_isDragMode = IsMouseDown()
If _isDragging Then
_isBusy = False
...
End If
End Sub
The DragDropToolTip
control is displayed during a drag/drop operation; however, the control can be placed anywhere in the XAML code. Rather than force a specific order of placement, we control the Z-Index from code. The Canvas
control has a Z-Index setter that makes it easy to bring a control to the front. For other panel controls like the Grid
control, the Z-Index setter does not exist. The DragDropToolTip
control normally is hidden when not in use. So to overcome the lack of an Z-Index setter for panel controls like the Grid
control, we change the position of the DragDropToolTip
control in the panel control's children - effectively bringing the DragDropToolTip
control to the front.
...
If _DragDropToolTip IsNot Nothing Then
With CType(_DragDropToolTip.Parent, Panel).Children
.Remove(_DragDropToolTip)
.Add(_DragDropToolTip)
End With
...
End If
...
The last thing we need to do for the ListDragDropHelper
class is to expose a property for the ListBox MouseMove
Trigger to bind to.
#Region "Relay Commands"
Public Property ChildMoveCommand() As RelayCommand(Of MouseEventArgs)
Private Sub _InitCommands()
ChildMoveCommand = New RelayCommand(Of MouseEventArgs)(_
Sub(e) ChildContainerMouseMove(e))
End Sub
#End Region
Configuring a ViewModel for ListBox Drag/Drop
To enable the View to talk to the ListDragDropHelper
class, we need to expose a property for each ListBox that can initiate a drag/drop operation.
#Region "View Binding Properties"
Property ProductsDragDropHelper As ListDragDropHelper
Property TagsDragDropHelper As ListDragDropHelper
Property GroupsDragDropHelper As ListDragDropHelper
#End Region
#Region "Constructor"
Sub New()
ProductsDragDropHelper = New ListDragDropHelper("DragDropToolTip", _
Function(s, d, fb) HandleHover(s, d, fb), Sub(s, d) HandleDrop(s, d))
TagsDragDropHelper = New ListDragDropHelper("DragDropToolTip", _
Function(s, d, fb) HandleHover(s, d, fb), Sub(s, d) HandleDrop(s, d))
GroupsDragDropHelper = New ListDragDropHelper("DragDropToolTip", _
Function(s, d, fb) HandleHover(s, d, fb), Sub(s, d) HandleDrop(s, d))
End Sub
#End Region
#Region "Event Handlers: Drag Hover / Drop"
Private Function HandleHover(SelectedItems As Object,_
DestItem As Object,_
DragFeedback As ListDragDropHelper.FeedbackStates) _
As ListDragDropHelper.FeedbackStates
...
End Sub
Private Sub HandleDrop(ByRef SelectedItems As Object, DestItem As Object)
...
End Sub
#End Region
Wiring up the XAML
Lastly, we need to set up a trigger to fire the ListBoxes' MouseMove
event.
<ListBox SelectionMode="Extended">
<i:Interaction.Triggers>
-->
<i:EventTrigger EventName="MouseMove">
<cmd:EventToCommand
Command="{Binding DragDropHelper.ChildMoveCommand, Mode=OneWay}"
PassEventArgsToCommand="True" />
</i:EventTrigger>
</i:Interaction.Triggers>
</ListBox>
Sample Application
The sample code included demonstrates how to use both the Single
and Extended
selection modes for listboxes. We are using three listboxes: Products, Tags, and Groups. A product can have multiple tags and only one group. The drag/drop operation allows the following rules:
- Multiple products can be selected and dragged onto a single tag or group.
- Multiple tags can be dragged onto a single product or group - Applying a set of tags to single/multiple products via the group link.
- Dragging a single group onto a single product.
Here's the code in the ViewModel that handles the hover event, giving feedback to the user based on the above rules. It identifies who started the operation based on the source's model type (ListBoxItem
(s) only) and decides on what is displayed by the destination model type. If no destination model type is found (ListBoxItem
only), then the operation must be illegal. By passing the models from the ListDragDropHelper
class to the ViewModel, the ViewModel has no knowledge of what or how the models are being displayed in the View.
Private Function HandleHover(SelectedItems As Object,_
DestItem As Object,_
DragFeedback As ListDragDropHelper.FeedbackStates) _
As ListDragDropHelper.FeedbackStates
Dim Move As New ListDragDropHelper.FeedbackStates With _
{.Drag = ListDragDropHelper.DragFeedbackState.Move,
.Drop = ListDragDropHelper.DropFeedbackState.Move}
Dim Allowed As New ListDragDropHelper.FeedbackStates With _
{.Drag = ListDragDropHelper.DragFeedbackState.Copy,
.Drop = ListDragDropHelper.DropFeedbackState.Add}
Dim NotAllowed As New ListDragDropHelper.FeedbackStates With _
{.Drag = ListDragDropHelper.DragFeedbackState.NotAllowed,
.Drop = ListDragDropHelper.DropFeedbackState.None}
If SelectedItems Is Nothing Then Return NotAllowed
Dim Items As ObservableCollection(Of Object) = _
CType(SelectedItems, ObservableCollection(Of Object))
If Items.Count = 0 Then Return NotAllowed
Dim SrcType As Type = SelectedItems(0).GetType
Select Case True
Case SrcType Is GetType(ProductModel)
If DestItem IsNot Nothing Then
Select Case True
Case DestItem.GetType Is GetType(SummaryTagModel)
With Allowed
.HoverMessage = "BULK RETAG"
.DropMessage = _
String.Format("Set '{0}'{2}for '{1} Products'",
CType(DestItem, SummaryTagModel).Tag,
SelectedItems.count,
vbNewLine)
End With
Return Allowed
Case DestItem.GetType Is GetType(SummaryGroupModel)
With Allowed
.HoverMessage = "CHANGE GROUP"
.DropMessage = _
String.Format("Set '{0}'{2}for '{1} Products'",
CType(DestItem, SummaryGroupModel).title,
SelectedItems.count,
vbNewLine)
End With
Return Allowed
Case DestItem.GetType Is GetType(ProductModel)
With Move
.HoverMessage = "SELECT"
.DropMessage = _
String.Format("Drag '{0} Products' to a " & _
"Tag to{1}Bulk Tag Change or{1}to a Group...",
SelectedItems.count,
vbNewLine)
End With
Return Move
End Select
End If
Case SrcType Is GetType(SummaryTagModel)
If DestItem IsNot Nothing Then
Select Case True
Case DestItem.GetType Is GetType(ProductModel)
With Allowed
.HoverMessage = "BULK TAG"
.DropMessage = _
String.Format("Set '{0}'{2}for '{1} Tags'",
CType(DestItem, ProductModel).title,
SelectedItems.count,
vbNewLine)
End With
Return Allowed
Case DestItem.GetType Is GetType(SummaryTagModel)
With Move
.HoverMessage = "SELECT"
.DropMessage = String.Format("Drag '{0}' to a " & _
"product {1}to set the Tag{1}or " & _
"to a Group to Bulk change Groups...",
CType(SelectedItems(0), SummaryTagModel).Tag,
vbNewLine)
End With
Return Move
Case DestItem.GetType Is GetType(SummaryGroupModel)
With Allowed
.HoverMessage = "BULK CHANGE GROUPS"
.DropMessage = _
String.Format("Set '{0}'{2}for '{1}' Products",
CType(DestItem, SummaryGroupModel).title,
CType(SelectedItems(0), SummaryTagModel).num_products,
vbNewLine)
End With
Return Allowed
End Select
End If
Case SrcType Is GetType(SummaryGroupModel)
If DestItem IsNot Nothing Then
Select Case True
Case DestItem.GetType Is GetType(ProductModel)
With Allowed
.HoverMessage = "CHANGE GROUP"
.DropMessage = _
String.Format("Set '{0}'{2}for '{1}' Products",
CType(SelectedItems(0), SummaryGroupModel).title,
CType(DestItem, ProductModel).title,
vbNewLine)
End With
Return Allowed
Case DestItem.GetType Is GetType(SummaryGroupModel)
With Move
.HoverMessage = "SELECT"
.DropMessage = _
String.Format("Drag '{0}' to a product " & _
"{1}to set the Group...", _
CType(SelectedItems(0), SummaryGroupModel).title,
vbNewLine)
End With
Return Move
End Select
End If
End Select
Return NotAllowed
End Function
And below is the code that handles the drop operation. Again, the ListDragDropHelper
class only passes the model data to the ViewModel.
Private Sub HandleDrop(ByRef SelectedItems As Object, DestItem As Object)
If SelectedItems Is Nothing Then Return
ProductsNoTagRoom.Clear()
ProductsUpdated.Clear()
ProductsSkipped.Clear()
Dim Items As ObservableCollection(Of Object) = _
CType(SelectedItems, ObservableCollection(Of Object))
If Items.Count = 0 Then Return
Dim SrcType As Type = SelectedItems(0).GetType
Select Case True
Case SrcType Is GetType(ProductModel)
If DestItem IsNot Nothing Then
ProcessProductMove(Items, DestItem)
Return
End If
Case SrcType Is GetType(SummaryTagModel)
If DestItem IsNot Nothing Then
ProcessTagsMove(Items, DestItem)
End If
Case SrcType Is GetType(SummaryGroupModel)
If DestItem IsNot Nothing Then
ProcessGroupMove(Items, DestItem)
End If
End Select
Return
End Sub
Private Sub ProcessProductMove(SelectedItems As _
ObservableCollection(Of Object), DestItem As Object)
Select Case True
Case DestItem.GetType Is GetType(SummaryTagModel)
Dim DestTag As TagModel = CType(DestItem, TagModel)
For Each item As ProductModel In SelectedItems
Dim product As ProductModel = _
Products.Where(Function(l) l.product_id = _
item.product_id).SingleOrDefault
Dim Tag As TagModel = product.tags.Where(Function(t) t.Tag = _
DestTag.Tag).SingleOrDefault
If Tag Is Nothing Then
If product.tags.Count > 10 Then
ProductsNoTagRoom.Add(product)
Else
product.tags.Add(DestTag)
ProductsUpdated.Add(product)
End If
Else
ProductsSkipped.Add(product)
End If
Next
UpdateTagSumaryList(New ObservableCollection(Of Object) From {DestItem})
Case DestItem.GetType Is GetType(SummaryGroupModel)
Dim Destgroup As GroupModel = CType(DestItem, GroupModel)
For Each item As ProductModel In SelectedItems
Dim product As ProductModel = _
Products.Where(Function(l) l.product_id = _
item.product_id).SingleOrDefault
If product.group.group_id = Destgroup.group_id Then
ProductsSkipped.Add(product)
Else
product.group = Destgroup
ProductsUpdated.Add(product)
End If
Next
UpdateGroupSumaryList(Destgroup)
Case Else
End Select
Return
End Sub
Private Sub ProcessTagsMove(SelectedItems As _
ObservableCollection(Of Object), DestItem As Object)
Select Case True
Case DestItem.GetType Is GetType(ProductModel)
For Each item As TagModel In SelectedItems
Dim product As ProductModel =
Products.Where(Function(l) l.product_id =
CType(DestItem, ProductModel).product_id).SingleOrDefault
Dim Tag As TagModel =
product.tags.Where(Function(t) t.Tag = item.Tag).SingleOrDefault
If Tag Is Nothing Then
If product.tags.Count > 10 Then
ProductsNoTagRoom.Add(product)
Else
product.tags.Add(item)
ProductsUpdated.Add(product)
End If
Else
ProductsSkipped.Add(product)
End If
Next
UpdateTagSumaryList(SelectedItems)
Case DestItem.GetType Is GetType(SummaryGroupModel)
Dim Destgroup As GroupModel = CType(DestItem, GroupModel)
For Each item As TagModel In SelectedItems
For Each product As ProductModel In Products
If (From t As TagModel In product.tags _
Where t.Tag = item.Tag).Count Then
If product.group.group_id = Destgroup.group_id Then
ProductsSkipped.Add(product)
Else
product.group = Destgroup
ProductsUpdated.Add(product)
End If
End If
Next
Next
UpdateGroupSumaryList(Destgroup)
Case Else
End Select
Return
End Sub
Private Sub ProcessGroupMove(SelectedItems As _
ObservableCollection(Of Object), DestItem As Object)
If DestItem.GetType Is GetType(ProductModel) Then
Dim srcGroup As SummaryGroupModel = _
CType(SelectedItems(0), SummaryGroupModel)
Dim product As ProductModel = _
Products.Where(Function(l) l.product_id = _
CType(DestItem, ProductModel).product_id).SingleOrDefault
If product.group.group_id = srcGroup.group_id Then
ProductsSkipped.Add(product)
Else
product.group = srcGroup
ProductsUpdated.Add(product)
End If
UpdateGroupSumaryList(SelectedItems(0))
End If
End Sub
The sample project has no conflict or mapping resolution for tags that either are already used or exceeds the number allowed. I'll leave this exercise to you.
I have however added a feedback control to the main form that shows the results of the drag/drop operation. Here's the code that updates the SummaryTagModel
and SummaryGroupModel
collections to refresh the tag and group listboxes. The code below only updates those that have changes rather than update the entire lists. The feedback control is bound to these collections and reflects any changes made.
ProductsNoTagRoom = New ObservableCollection(Of ProductModel)
ProductsUpdated = New ObservableCollection(Of ProductModel)
ProductsSkipped = New ObservableCollection(Of ProductModel)
...
Private Sub UpdateTagSumaryList(Items As ObservableCollection(Of Object))
Dim tmpTags As ObservableCollection(Of SummaryTagModel) = _
MockDataProvider.GetTags(Products)
For Each tag As SummaryTagModel In Items
Dim destTag As SummaryTagModel = _
Tags.Where(Function(t) t.Tag = tag.Tag).SingleOrDefault
destTag.SetData(tmpTags.Where(Function(t) t.Tag = _
tag.Tag).SingleOrDefault)
Next
End Sub
Private Sub UpdateGroupSumaryList(Item As SummaryGroupModel)
Dim tmpGroups As ObservableCollection(Of SummaryGroupModel) = _
MockDataProvider.GetGroups(Products)
Dim destSection As SummaryGroupModel = Groups.Where(Function(ss) _
ss.group_id = Item.group_id).SingleOrDefault
destSection.SetData(tmpGroups.Where(Function(ss) ss.group_id = _
destSection.group_id).SingleOrDefault)
End Sub
The ListDragDropHelper
class can also support reordering a listbox and other operations; however, these are not included in the sample application. Reordering a listbox, for example, is straightforward - SelectedItems
in the HandleDrop
method would be the item(s) to be moved and the DestItem
would be the new position. Remove the SelectedItems
, and .InsertAt(x)
the destItem
index. To move the SelectedItems
to the end of the list, the destItem
would be nothing. Use .Add(x)
instead of .InsertAt(x)
.
Special Mention
The icons used in the DragDropToolTip
control were originally from the Silverlight Toolkit on CodePlex.
Final Comments
This is my first submission and I hope that you've found it useful.
- 11th April 2011 - Initial release.