다음을 통해 공유


How to hack an easy Drag and Drop with Blend

WPF is a pretty amazing thing.  Using it, you can take a lot of fairly complicated graphical effects, and (esp. using Blend) implement them pretty trivially and make an application look cool.  I have to say, after having banged my head up against the issue for a while, that I'm somewhat disappointed that implementing a feature so basic that people are mad when they can't do it - Drag and Drop, is not so trivial.  I wanted to do something fairly simple in my application: allow the user to grab a handful of items from one listbox in my app and drag them to another.  I've never done this before, but I assumed that it couldn't be too bad - it is, after all, such a basic idea.  Life, however, was not to be quite so simple. 

As it has long been my goal in this blog to spell out the details of anything I find confusing or difficult with WPF programming, Blend, or in general any MS tool I find myself using, I have here laid out the steps to create a sample drag&drop implementation using Blend and VS.  It was more painful than I think it ought to be, but it can be made simple if you know what you are doing. 

The application is simple; a window with two listboxes.  Both are databound to different ObservableCollections (see my earlier post on why you should be binding to these instead of Lists) of SampleDataObjects.  I didn't want to bind to lists of strings, even though in this project a SampleDataObject is a dummy class with one field, "Name," that is a string... because I wanted this to be easy to morph into something dragging pictures, files, chickens... you name it.

The key points of confusion, at least to me, come from the fact that MouseDown is not the way to go in a listbox -- clicking an item doesn't fire the MouseDown event... I don't know why.  However, clicking in an area of the listbox that is uninhabited does, and gives you plenty of easy ways to crash your app with NullReferenceExceptions.

My solution... or at least the one that works for me and doesn't crash, may be a bit unconventional.  I find... that using PreviewMouseDown and SelectionChanged at the same time works quite marvelously.  Even more comically, if you allow MouseDown to do your original job, but check for nulls, it makes the behavior better.  Hooray for Hacks.

Once again - this is NOT the greatest coding practice in the world.  Just a hack that worked for me.  I spent a little bit of time talking to the people who implemented D&D functionality in Blend, and though there is a much better way to do this from the perspective of writing good code.... for an app that doesn't need a lot of power, it's an awful lot of mess.

Here comes the science code:

Start with a pretty simple app.  Two listboxes, two labels.  Gray background on the window is for your eyes' benefit only:

Two listboxes. Nothing fancy.

Best. App. Ever.

In order to make the listboxes work the way most of us are used to, we need to set the selection mode of both list boxes.  I'm used to being able to select multiple things by holding CTRL whilst I click, but not if I don't.  This is the "Extended" mode, not the "Multiple" mode.  You can set the SelectionMode of a listbox in the Miscellaneous box in the property inspector.

Moving along, we need to throw some code-behind in here, so I'm going to create another class, which I've called MasterDataHolder.  I've found it to be good practice to create separate data structure (DS) classes and never instantiate them if you intend to use WPF databinding (a.k.a. black magic). 

My DS Class, in its entirety:

------------------------------------------------------

public class MasterDataHolder
{

    private ObservableCollection<SampleDataObject> firstCollection;
    private ObservableCollection<SampleDataObject> secondCollection;

    public MasterDataHolder()
    {
        this.InitializeObjects();

    }

    private void InitializeObjects()
    {
        string[] numbers = { "one", "two", "three", "four", "five", "six", "seven", "eight", "nine", "ten" };
        this.firstCollection = new ObservableCollection<SampleDataObject>();
        this.secondCollection = new ObservableCollection<SampleDataObject>();
        for (int i = 0; i < numbers.Length; i++)
        {
            if (i <= numbers.Length / 2)
            {
                firstCollection.Add(new SampleDataObject(numbers[i]));
            }
            else
            {
                secondCollection.Add(new SampleDataObject(numbers[i]));
            }
        }
    }

    public ObservableCollection<SampleDataObject> FirstCollection
    {
        get { return this.firstCollection; }
        set { this.secondCollection = value; }
    }

    public ObservableCollection<SampleDataObject> SecondCollection
    {
        get { return this.secondCollection; }
        set { this.secondCollection = value; }
    }
}

------------------------------------------------------

Recall that my data objects here are of type "SampleDataObject," a class that has exactly one publicly accessible attribute, a string called "Name."  Just wanted to get a little more abstract than a list of strings.

To allow the window to have access to the ObservableCollections, I need to instantiate the DataSource.  I do this in Blend with the "+ CLR Object" button at the bottom right, under the properties palette:

Go ahead and pick out the MasterDataHolder from the dialog that pops up:

Now be sure to set the DataContext property of the Window to the MasterDataHolder you just created:

You can then expand the MasterDataHolder in the data pane (in the projects tab on the right side of Blend) and grab the FirstCollection and SecondCollection objects and simply DROP them into the listboxes (++ sweetness... sometimes black magic is awesome).  Remember to pick that you want them to be displayed by their names when the dialog appears after you drop the object into the listbox.

 

 

You want to have the itemssource bound to an ExplicitDataContext (the third tab option when you are databinding), and chose the Name attribute of the appropriate ObservableCollection.

One last thing you need to make things happen is to set the "AllowsDrop" property of the second listbox.  (Note: in my sample app, you can only drag and drop one way.  It's trivial to reverse it to allow two-way dragging).

 

The simplest way (to me) to drag back and forth arbitrary data is to create a custom mover, which I have done with this DataMover.cs class:

----------------------------------------------------------------

public class DataMover
{
    private List<SampleDataObject> movingData;
    private bool oneToTwo = false;

    public DataMover()
    {
        this.movingData = new List<SampleDataObject>();
    }

    public void SetData(params SampleDataObject[] sdos)
    {
    }

    public List<SampleDataObject> MovingData
    {
        get { return this.movingData; }
        set { this.movingData = value; }
    }

    public void AddItem(SampleDataObject sam)
    {
        this.movingData.Add(sam);
    }

    public bool OneToTwo
    {
        get { return this.oneToTwo; }
        set { this.oneToTwo = value; }
    }

}

----------------------------------------------------------------

It's nothing more than a container for some data that I'm going to dump in.  The advantage to using this approach is that you can put complicated logic in there if needs be.  Not sure if that's bad coding practice or not, but then again, this whole technique (from here on out), is bad coding practice, so who cares?  Note that the OneToTwo bool is really only useful if you plan on dragging both ways.  It's an easy field to check to make sure you don't try to implement a drop when the user was aborting his drag before getting out of the starting box.

In order to pull off a drag & drop operation (henceforth DD) in C#/WPF, you need to call the DragDrop.DoDragDrop method.  Check out MSDN for details; this implementation is rather simple.

The MouseDown event is a logical candidate for a place to put the code, so I added the following to my Window1 class:

------------------------------------------------------

private void ListBoxOne_MouseDown(object sender, System.Windows.Input.MouseButtonEventArgs e)
        {

            this.dataMover = new DataMover();
            if (ListBoxOne.SelectedItems.Count > 0)
            {

                foreach (object ob in this.ListBoxOne.SelectedItems)
                {
                    this.dataMover.AddItem(ob as SampleDataObject);
                }
                this.dragging = true;
            }   
        }

----------------------------------------------------

Not checking for selection here is an easy way to crash the app.  Anyhow, you also need to implement the MouseMove handler.

------------------------------------------------------

private void ListBoxOne_MouseMove(object sender, System.Windows.Input.MouseEventArgs e)
{
    if (e.LeftButton == System.Windows.Input.MouseButtonState.Pressed && this.dataMover.MovingData.Count > 0 && this.dragging)
    {
        DragDrop.DoDragDrop(this.ListBoxOne, this.dataMover, DragDropEffects.Move);
    }
}

-------------------------------------------------------

Now you also need to implement the Drop event handler for the target listbox:

-------------------------------------------------------

private void ListBoxTwo_Drop(object sender, DragEventArgs e)
{
    try
    {
        e.Data.GetDataPresent(DataFormats.Serializable);
        foreach (SampleDataObject sam in this.dataMover.MovingData)
        {
            if (dataMover.OneToTwo)
            {
                this.dataHolder.FirstCollection.Remove(sam);
                this.dataHolder.SecondCollection.Add(sam);
            }
        }
        this.dataMover.OneToTwo = false;
    }
    catch (Exception ex)
    {
        MessageBox.Show("no dice: " + e.Data.ToString());
    }

}

-----------------------------------------------------

This seems pretty nice.... except that it doesn't work.  Why?  The issue here is that the first listbox doesn't get the MouseDown event.  That event is taken by the selected item, and the listbox itself is none the wiser.

You could implement the exact same code (from MouseDown above) in PreviewMouseDown, which is given to the ListBox, rather than the item.  The issue here is that PreviewMouseDown happens just before the user clicks, so the drag would only grab the items that were selected prior to the click.

Seeing the failings of the PreviewMouseDown event, I was inspired to try the same code in the SelectionChanged event.  This proved almost brilliant.  This failed in the case where you wanted to click and drag the item that was already selected.  Also... if there is only one item in ListBox_One, then you can't change the selection.  That item is stuck.

Here was where the real H4XR brilliance kicked in.  SelectionChanged fails when you want to use the click, but not change the selection (event doesn't fire).  PreviewMouseDown fails precisely when you want to change the selection, but works when you don't.  Hmmm.... SelectionChanged can only be fired after the selection changes, which happens after the click.  PreviewMouseDown happens before the click.  Thus, if both events fired, the SelectionChanged handler would override PreviewMouseDown.  PreviewMouseDown would only be carried out in the cases where SelectionChanged doesn't fire... and those cases just happen to be the ones where PreviewMouseDown works perfectly!  Brilliant!

Only downside to using this combo.  If you click in the empty area below the listbox items and then drag, you still drag over all the selected items (PreviewMouseDown still occurs).  This isn't the end of the world -- there are worse user behaviors and it's an odd thing to do specifically expecting nothing to happen.  You can make the right thing -- nothing -- happen, simply by having the MouseDown handler re-instantiate this.dataMover, clearing anything that PreviewMouseDown gave it (the selection doesn't change, so you don't have to fight that handler).

In summary, both PreviewMouseDown and SelectionChanged on the starting ListBox should read:

     this.dataMover = new DataMover();
         if (ListBoxOne.SelectedItems.Count > 0)
         {

                foreach (object ob in this.ListBoxOne.SelectedItems)
                {
                    this.dataMover.AddItem(ob as SampleDataObject);
                }
                this.dragging = true;
            }   

MouseDown should just read:

this.dataMover = new DataMover();

And the Drop handler should read as above.  Gorgeous.

If this code makes you cringe... well, there's nothing wrong with you.  It probably should do something of the sort.  However, it works, and for a low-power app this might be all you need.

What this can do:

Drag multiple items from one listbox to another in the same window, or at least in the same application.

What this cannot do:

- Drag an item to a specific location in the other listbox - things always show up at the bottom of the list.

- Rearrange items within the same listbox - pretty common functionality, but not universal, and this isn't in any way confusing.

- Drag items out of the application.  I don't really use the parameters of DoDragDrop.

 

Anyhow, if this doesn't offend your sense of decency, it's a neat trick.  You won't find that in the API.  I suspect I might get a pay cut just for having written it, but some things just need to be told.

Incidentally, I promise this code has never, ever, to my knowledge been used in an actual MS product.  I'm fairly certain it never will.  Even I'd buy a Mac if it did... (unless we used it in Mac Office just for kicks).  Enjoy. 

Oh, and go Jints.  Gotta hate the Pats on Sunday.

Comments

  • Anonymous
    June 09, 2012
    Would it be possible to fix the missing images?