Friday, September 23, 2022

Drag and Drop in WPF TreeView

Today I want to describe the implementation of drag and drop functionality inside WPF TreeView control. It sounds like a simple task, but took surprisingly a lot of time from me. So let's start.

Setting a stage

We'll create a simple WPF application showing a tree view. This tree view will show items from a binding. I'll use the following view model for these items:

public class ItemViewModel
{
    public string Title { get; }

    public ItemViewModel? Parent { get; set; }

    public ItemViewModel(string title)
    {
        Title = title;
        SubItems = new ObservableCollection<ItemViewModel>();
        SubItems.CollectionChanged += OnCollectionChanged;
    }

    private void OnCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e)
    {
        if (e.Action == NotifyCollectionChangedAction.Add)
        {
            foreach (ItemViewModel item in e.NewItems)
            {
                item.Parent = this;
            }
        }
    }

    public ObservableCollection<ItemViewModel> SubItems { get; }
}

As you can see, this is a simple class with the following properties:

  • Title. We'll see it in the tree view.
  • SubItems. This is a collection of nested items.
  • Parent. This is a reference to the parent item. We'll need it later. This property is automatically set when we add an item into the SubItems collection (see the OnCollectionChanged method).
The code of the TreeView control displaying these items looks like this:
<TreeView ItemsSource="{Binding SubItems}">
    <TreeView.Resources>
        <HierarchicalDataTemplate DataType="{x:Type local:ItemViewModel}"
                                  ItemsSource="{Binding SubItems}">
            <TextBlock Text="{Binding Title}"/>
        </HierarchicalDataTemplate>
    </TreeView.Resources>
</TreeView>
The only thing left to do is to set the DataContext of our window to a correct object:
public partial class MainWindow : Window
{
    public MainWindow()
    {
        InitializeComponent();

        SubItems = new ObservableCollection<ItemViewModel>
        {
            new ItemViewModel("A")
            {
                SubItems =
                {
                    new ItemViewModel("D"),
                    new ItemViewModel("E")
                    {
                        SubItems =
                        {
                            new ItemViewModel("G"),
                            new ItemViewModel("H"),
                            new ItemViewModel("I"),
                        }
                    },
                }
            },
            new ItemViewModel("B"),
            new ItemViewModel("C")
            {
                SubItems =
                {
                    new ItemViewModel("F"),
                }
            },
        };

        DataContext = this;
    }

    public ObservableCollection<ItemViewModel> SubItems { get; }
}

Implementing Drag and Drop

Now it is time to implement Drag and Drop functionality. First of all, we have to allow dropping on our TreeView.
<TreeView ItemsSource="{Binding SubItems}"
          AllowDrop="True"
Then we have to initialize dragging. It is done in two event handlers:
<TreeView ItemsSource="{Binding SubItems}"
          AllowDrop="True"
          PreviewMouseLeftButtonDown="OnPreviewMouseLeftButtonDown"
          PreviewMouseMove="OnPreviewMouseMove"
Here is the code of these handlers:
private Point _startLocation;
private ItemViewModel? _selectedItem;

private void OnPreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e)
{
    _startLocation = e.GetPosition(null);

    _selectedItem = GetItemViewModel(e.OriginalSource);
}

private void OnPreviewMouseMove(object sender, MouseEventArgs e)
{
    if (e.LeftButton == MouseButtonState.Pressed && _selectedItem != null)
    {
        var mousePos = e.GetPosition(null);
        var diff = _startLocation - mousePos;

        if (Math.Abs(diff.X) > SystemParameters.MinimumHorizontalDragDistance
            || Math.Abs(diff.Y) > SystemParameters.MinimumVerticalDragDistance)
        {
            var treeView = (TreeView) sender;

            var dragData = new DataObject(_selectedItem);
            DragDrop.DoDragDrop(treeView, dragData, DragDropEffects.Move);
        }
    }

}
In the OnPreviewMouseLeftButtonDown method we remember the position of our mouse and the item on which we click the mouse. This is the most interesting part of all. OriginalSource property of MouseButtonEventArgs gives us some WPF control which initiates the event. But we don't need this control. We need corresponding instance of ItemViewModel. Method GetItemViewModel implements this extraction:
private ItemViewModel? GetItemViewModel(object uiElement)
{
    var depElement = uiElement as DependencyObject;

    while (true)
    {
        var frElement = depElement as FrameworkElement;
        if(frElement == null) break;

        var item = frElement.DataContext as ItemViewModel;
        if (item != null)
        {
            return item;
        }

        depElement = VisualTreeHelper.GetParent(frElement);
    }

    return null;
}
In the OnPreviewMouseMove method we actually start Drag and Drop operation by calling DragDrop.DoDragDrop. Protection logic prevents us from starting dragging without the instance of ItemViewModel or on a simple mouse click.
Now our operation is initiated. When we drag our item over other items we need to tell if it is allowed to drop it here or not. In order to do it we must specify handlers of another three events:
<TreeView ItemsSource="{Binding SubItems}"
          AllowDrop="True"
          PreviewMouseLeftButtonDown="OnPreviewMouseLeftButtonDown"
          PreviewMouseMove="OnPreviewMouseMove"
          DragEnter="OnCheckDrag"
          DragOver="OnCheckDrag"
          DragLeave="OnCheckDrag"
As you can see, I use the same event handler OnCheckDrag for all three events. Here it its code:
private void OnCheckDrag(object sender, DragEventArgs e)
{
    e.Handled = true;

    var uiElement = (UIElement)sender;

    var element = uiElement.InputHitTest(e.GetPosition(uiElement));

    var itemUnderMouse = GetItemViewModel(element);

    if (itemUnderMouse == null)
    {
        e.Effects = DragDropEffects.None;
        return;
    }

    if(IsChild(itemUnderMouse))
    {
        e.Effects = DragDropEffects.None;
        return;
    }

    e.Effects = DragDropEffects.Move;
}

private bool IsChild(ItemViewModel? item)
{
    while (true)
    {
        if (item == null) return false;

        if (ReferenceEquals(item, _selectedItem)) return true;

        item = item.Parent;
    }
}
Here we again get ItemViewModel under our mouse. We do it using standard WPF InputHitTest method and our GetItemViewModel method, which we have already described.Now we have both the item we drag (source item) an the item we drag over (target item). At this moment we can execute any checks we want. Here I only check that the target item is not a child of the source item. But you can add any login you want. If we allow to drop the source item here, we set the Effects property of DragEventArgs to Move. Otherwise, we set it to None.
And here is the final touch. Logic of dropping is implemented in the Drop event handler:
<TreeView ItemsSource="{Binding SubItems}"
          AllowDrop="True"
          PreviewMouseLeftButtonDown="OnPreviewMouseLeftButtonDown"
          PreviewMouseMove="OnPreviewMouseMove"
          DragEnter="OnCheckDrag"
          DragOver="OnCheckDrag"
          DragLeave="OnCheckDrag"
          Drop="OnDrop">
Here is the code:
private void OnDrop(object sender, DragEventArgs e)
{
    e.Handled = true;

    var uiElement = (UIElement)sender;

    var element = uiElement.InputHitTest(e.GetPosition(uiElement));

    var newParentItem = GetItemViewModel(element);

    _selectedItem!.Parent!.SubItems.Remove(_selectedItem);
    newParentItem!.SubItems.Add(_selectedItem);
}
Here we again get item under the mouse pointer. Then we apply any business logic we want.

Conclusion

It takes surprisingly a lot of code to implement such a standard operation. I hope this small article will be helpful to you if you have the same task.

No comments:

Post a Comment