Silverlight 4 ListBox Drag & Drop Timing Issue
UPDATE [08/17/2010]: Version 3.0 added
Just a quick entry to talk about an issue I recently ran into involving Drag & Drop list reordering using the ListBox control. I found numerous samples using the Silverlight Toolkit's ListBoxDragDropTarget to drag and drop an item between two ListBox instances. After digging a bit, and running through a number of articles that talked about hacks and workarounds to make it work with a single ListBox instance, I stumbled upon an article that showed that it really was as simple as it should be: Don't do anything special, it's supported out-of-the-box already. How I love those type of answers.
For me the main problem in getting it to work was learning that you had to replace the default VirtualizingStackPanel with a standard StackPanel. That was the single biggest hang-up. A close second was learning that you can't just use the collection that is returned from the RIA services call, that was solved by simply wrapping the result in an ObservableCollection [a good idea anyway]. Then, linking it up with my MVVM implementation was fairly straightforward, once I figured out the right event to respond to with the Interactivity Triggers, I was ready to go. The scenario was very simple, update the sequence value based on the index of the item in the list and submit changes back to the server.
Of course, nothing seems to ever be that simple :) Or maybe I'm just lucky. The problem I was seeing was that, very often, my reorder event was firing AND completing before the actual underlying list had been updated. This seemed very strange, I expected the list to be updated and THEN have my event called. The behavior I was seeing was doubly strange because it was happening ONLY on the first drag/drop action, ALL subsequent actions were working as expected. So, on a whim, I added a bit of code to delay the method that does the reordering, and lo-and-behold, things started to 'just work'.
Version 1.0
The XAML
<toolkit:ListBoxDragDropTarget AllowDrop="True"
HorizontalContentAlignment="Stretch"
VerticalContentAlignment="Stretch">
<i:Interaction.Triggers>
<i:EventTrigger EventName="ItemDroppedOnSource">
<ia:CallMethodAction MethodName="ReorderList" TargetObject="{Binding}" />
</i:EventTrigger>
</i:Interaction.Triggers>
<ListBox ItemsSource="{Binding Path=ItemsList}">
<ListBox.ItemTemplate>
<DataTemplate>
<TextBlock Text="{Binding Path=CustomerName}" />
</DataTemplate>
</ListBox.ItemTemplate>
<ListBox.ItemsPanel>
<ItemsPanelTemplate>
<StackPanel />
</ItemsPanelTemplate>
</ListBox.ItemsPanel>
</ListBox>
</toolkit:ListBoxDragDropTarget>
The piece to notice above is that I've wrapped the ListBox in a ListBoxDragDropTarget and I've made sure to enable the horizontal and vertical content alignment properties to stretch the ListBox to the bounds of the drop target. This is a common complaint that I've seen posted online, where the ListBox doesn't take up the 'whole space' that it used to before the drop target was added. Also, you'll notice that I've added a trigger on the ItemDroppedOnSource method to handle the dropping of the item on the ListBox itself.
The ViewModel Code
private DispatcherTimer _reorderTimer;
public ViewModel()
{
// ... other constructor bits
// setup reorder timer
_reorderTimer = new DispatcherTimer();
_reorderTimer.Interval = new TimeSpan(0, 0, 0, 0, 300); // 300ms
_reorderTimer.Tick += (s, e) =>
{
_reorderTimer.Stop();
DoReorderList();
};
}
public void ReorderList()
{
//***NOTE: This is a HACK, but it seems to allow the first
// drag-drop reorder to be successful
if(_reorderTimer.IsEnabled) return; // don't allow multiple calls too quickly
_reorderTimer.Start();
}
private void DoReorderList()
{
foreach (EntityClass item in CurrentContext.EntityClasses)
{
item.Sequence = (byte)ItemsList.IndexOf(item);
}
CurrentContext.SubmitChanges();
}
Here I've just utilized the DispatcherTimer class to create a slight delay between the dropping of the item and the reset of the sequence. As mentioned in the beginning, the item dropped event seems to fire before the actual collection is rearranged, so a possible alternative solution would be to set a flag in this event, and then capture the CollectionChanged event on ItemsList and base our reorder on that flag. I'll update this post if anything comes of that experiment.
UPDATE: Version 2.0
After writing the post and thinking about it a bit more, I began to really feel the ‘code smell’ coming from the solution posted above. The solution works, but it’s a HACK and I don’t like it that way. So, after further review, I’ve come up with what I believe to be a more elegant solution that doesn’t feel like such a hack and appears to work just as well. I’ve outlined ‘Version 2.0’ below.
The XAML [unchanged]
<toolkit:ListBoxDragDropTarget AllowDrop="True"
HorizontalContentAlignment="Stretch"
VerticalContentAlignment="Stretch">
<i:Interaction.Triggers>
<i:EventTrigger EventName="ItemDroppedOnSource">
<ia:CallMethodAction MethodName="ReorderList" TargetObject="{Binding}" />
</i:EventTrigger>
</i:Interaction.Triggers>
<ListBox ItemsSource="{Binding Path=ItemsList}">
<ListBox.ItemTemplate>
<DataTemplate>
<TextBlock Text="{Binding Path=CustomerName}" />
</DataTemplate>
</ListBox.ItemTemplate>
<ListBox.ItemsPanel>
<ItemsPanelTemplate>
<StackPanel />
</ItemsPanelTemplate>
</ListBox.ItemsPanel>
</ListBox>
</toolkit:ListBoxDragDropTarget>
That's right, no XAML changes necessary here, at least I got one thing right :).
The ViewModel Code
#region Private Fields
private bool _reorderList = false;
#endregion
#region Constructors
public EntityClassListViewModel()
: base()
{
ItemsList = new ObservableCollection<entityclass>();
ItemsList.CollectionChanged += (s, e) =>
{
if (_reorderList && e.Action == NotifyCollectionChangedAction.Add)
{
_reorderList = false;
DoReorderList();
}
};
// ... other constructor code ...
}
#endregion
#region Public Methods
public void ReorderList()
{
// Set the reorder flag to allow collection changed to pick it up
_reorderList = true;
}
#endregion
#region Private Methods
private void DoReorderList()
{
foreach (EntityClass item in CurrentContext.EntityClasses)
{
item.Sequence = (byte)ItemsList.IndexOf(item);
}
CurrentContext.SubmitChanges();
}
#endregion</entityclass>
Something very similar to the code above was working 'almost' right out of the gate. However, nothing seemed to be actually saving when I moved things around. Then I discovered the problem. The events that are fired look something like this:
- ItemDroppedOnSource
- CollectionChanged
- CollectionChanged
Of course, the problem lies in the double-collection changed event. The ListBoxDragDropTarget code first removes the item from the collection, then inserts it into the appropriate place in the collection. It makes perfect sense when you take a moment to think about it, but in the late night/early morning, things don’t come quite so easy :). The fix was, as you can see above, to only actually reorder the list when the item is added back into the list. Now, in my scenario, I don’t need to make sure my sequences are nice and tight [no gaps], so I don’t need to worry about deletions and whatnot, but if you did, you could easily rework the above code to keep a ‘no gaps’ sequence list.
With this ‘Version 2.0’ solution, things feel much less dirty and my mind is at ease :)
UPDATE [08/17/2010]: Version 3.0
After some time has passed and implementations have come and gone, the amount of code required to ‘delay’ the reordering and SubmitChanges() calls still seemed like a HACK. It also doesn’t properly handle multi-select ListBox instances where multiple items are drag/dropped. It turns out, in researching my TreeView MVVM article I discovered a much simpler and cleaner solution, the ItemDragCompleted event. The updated solution is below:
The XAML
<toolkit:ListBoxDragDropTarget AllowDrop="True"
HorizontalContentAlignment="Stretch"
VerticalContentAlignment="Stretch">
<i:Interaction.Triggers>
<i:EventTrigger EventName="ItemDragCompleted">
<ia:CallMethodAction MethodName="ReorderList" TargetObject="{Binding}" />
</i:EventTrigger>
</i:Interaction.Triggers>
<ListBox ItemsSource="{Binding Path=ItemsList}">
<ListBox.ItemTemplate>
<DataTemplate>
<TextBlock Text="{Binding Path=CustomerName}" />
</DataTemplate>
</ListBox.ItemTemplate>
<ListBox.ItemsPanel>
<ItemsPanelTemplate>
<StackPanel />
</ItemsPanelTemplate>
</ListBox.ItemsPanel>
</ListBox>
</toolkit:ListBoxDragDropTarget></pre>
The only change here is binding the ReorderList method to the ItemDragCompleted event rather than ItemDroppedOnSource.
The ViewModel Code
#region Constructors
public EntityClassListViewModel()
: base()
{
ItemsList = new ObservableCollection<EntityClass>();
// ... other constructor code ...
}
#endregion
#region Public Methods
public void ReorderList()
{
foreach (EntityClass item in ItemsList)
{
item.Sequence = (byte)ItemsList.IndexOf(item);
}
CurrentContext.SubmitChanges();
}
#endregion
Here, you can see the code has been cleaned up significantly, and doesn't rely on the CollectionChanged events any longer. In retrospect, clearly I got too far 'down in the weeds' and solved the wrong problem with my Version 1.0/2.0 solutions, but hey, that’s what’s great about being a developer, always learning new and better ways to do things!
Thanks for reading!