Drag-and-Drop Adorners & Popups

prestsauce

Ben Preston

Posted on July 13, 2023

Drag-and-Drop Adorners & Popups

Part #1: Core Functionality

Introduction

Here at BLT SMRT, we’re fast approaching the open beta release of our Revit addin, Foundry. As such, I’ve had to start tackling myriad small bug fixes and UI improvements that I’ve been avoiding. One of the tools requires some drag-and-drop functionality in which I can drag items between different lists. WPF provides the necessary framework to implement drag-and-drop out of the box. However, in my opinion, the most basic functionality doesn’t provide a great user experience. The intent here is to provide a fully fleshed-out UX. This is the first in a series of articles in which I document the process.

Here are the goals of the series:

  1. Part 1: Setup the core functionality.

  2. Part 2: Setup multi-item dragging.

  3. Part 3: Provide a visual reference as to what items are being dragged.

  4. Part 4: Provide visual feedback as to when a drop action is valid.

  5. Program these in such a way that I can use XAML to handle all of UI elements. More on this later.

One final note before diving in: I don’t use any libraries or MVVM frameworks. I like to tell myself that such packages are just too much *for a tiny application like Foundry, but the truth is that I just haven’t had time or desire to learn *yet another thing. So I’ll be adding the desired visual flair from scratch.

Basic Functionality

The most basic drag-and-drop functionality is quick to implement in WPF. If you’re comfortable with reading through technical documentation, Microsoft’s docs have everything you need to get the basic functionality up and working:

  1. Drag And Drop Overview

  2. Walkthrough: Enabling Drag and Drop on a User Control.

Let’s dive in by building the starter application. Here is the UI markup:

<Window x:Class="DragAndDrop.MainWindow"
            ...>

        <Window.DataContext>
            <viewModels:ViewModel/>
        </Window.DataContext>

        <Window.Resources>
            <DataTemplate x:Key="itemViewModelTemplate">
                <TextBlock Text="{Binding DisplayName}"/>
            </DataTemplate>
        </Window.Resources>

        <Grid Margin="5">
            <Grid.RowDefinitions>
                ...
            </Grid.RowDefinitions>
            <Grid.ColumnDefinitions>
                ...
            </Grid.ColumnDefinitions>


            <TextBlock.../>
            <ListBox
                DataContext="{Binding ColumnA}"
                ItemsSource="{Binding Items}"
                ItemTemplate="{StaticResource itemViewModelTemplate}"
                .../>

            <TextBlock.../>
            <ListBox
                DataContext="{Binding ColumnB}"
                ItemsSource="{Binding Items}"
                ItemTemplate="{StaticResource itemViewModelTemplate}"
                .../>

            <Button .../>
        </Grid>
    </Window>
Enter fullscreen mode Exit fullscreen mode

And the ViewModels:

`class ViewModelBase : INotifyPropertyChanged
{
    public event PropertyChangedEventHandler PropertyChanged;
    protected void OnPropertyChanged(string name) =>
        PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
}

class ItemViewModel : ViewModelBase
{
    private string displayName;
    public string DisplayName
    {  
       get => displayName;
       set
       {
        displayName = value;
        OnPropertyChanged(nameof(DisplayName));
       }
    }
}

class ItemCollectionViewModel : ViewModelBase
{
    public ObservableCollection<ItemViewModel> Items { get; set; } =
        new ObservableCollection<ItemViewModel>();


    public bool Add(ItemViewModel itemViewModel)
    {            
        if (Items.Contains(itemViewModel))
            return false;

        Items.Add(itemViewModel);
        return true;
    }

    public bool Remove(ItemViewModel itemViewModel)
    {
        if (!Items.Contains(itemViewModel))
            return false;

        return Items.Remove(itemViewModel);
    }
}

class ViewModel : ViewModelBase
{
    public ItemCollectionViewModel ColumnA { get; set; }
    public ItemCollectionViewModel ColumnB { get; set; }

    public ViewModel()
    {
        this.ColumnA = new ItemCollectionViewModel();
        this.ColumnB = new ItemCollectionViewModel();

        this.ColumnA.Add(new ItemViewModel { DisplayName = "Item 1" });
        this.ColumnA.Add(new ItemViewModel { DisplayName = "Item 2" });
        this.ColumnA.Add(new ItemViewModel { DisplayName = "Item 3" });
        this.ColumnA.Add(new ItemViewModel { DisplayName = "Item 4" });
    }
}`
Enter fullscreen mode Exit fullscreen mode

And the resulting window:

Image description

Basic Drag-And-Drop

Lets add super basic drag-and-drop functionality. For the sake of brevity, I’m temporarily foregoing an MVVM or MVC architecture.

Let’s amend the code-behind (.xaml.cs) to include some handlers:

public partial class MainWindow : Window
    {
        private ListBox sourceBox;

        public MainWindow()
        {
            InitializeComponent();
        }

        // Called when the mouse moves over one of the 
        // TextBlocks displaying our items
        private void TextBlock_MouseMove(object sender, MouseEventArgs e)
        {
            // If the mousebutton isn't pressed, return immediately;
            if (e.LeftButton != MouseButtonState.Pressed)
                return;

            // Cast the sender (TextBox) to a FrameworkElement
            // So we can grab the DataContext
            FrameworkElement fe = sender as FrameworkElement;
            if (fe == null) 
                return;

            // Get a reference to the 
            sourceBox = fe.FindVisualAncestor<ListBox>();
            if (sourceBox == null)
                return;


            // Wrap the data.
                DataObject data = new DataObject();
                data.SetData(fe.DataContext.GetType(), fe.DataContext);

            // Initiate the drag-and-drop operation.
            DragDrop.DoDragDrop(sourceBox, data, DragDropEffects.Move);
        }


        private void ListBox_Drop(object sender, DragEventArgs e) 
        {
            // Cast the sender (ListBox) to a FrameworkElement
            // So we can grab the DataContext
            FrameworkElement frameworkElement = sender as FrameworkElement;
            if (frameworkElement == null)
                return;

            // Grab a reference to the targetCollection
            ItemCollectionViewModel targetCollection = frameworkElement.DataContext as ItemCollectionViewModel;
            if (targetCollection == null) 
                return;

            // Get the data from the DragDrop event
            var item = e.Data.GetData(typeof(ItemViewModel)) as ItemViewModel;
            if (item == null)
                return;

            // Grab a reference to the source collection
            // Compare the source collection to the target collection.
            // If the same, we don't continue.
            ItemCollectionViewModel sourceCollection = 
                sourceBox.DataContext as ItemCollectionViewModel;
            if (sourceCollection == null || sourceCollection == targetCollection)
                return;

            if (sourceCollection.Remove(item))
                targetCollection.Add(item);
        }
    }

Enter fullscreen mode Exit fullscreen mode

And let’s update the markup to make use of these handlers:

...

    <Window.Resources>
        <DataTemplate x:Key="itemViewModelTemplate">
            <TextBlock 
                Text="{Binding DisplayName}"
                MouseMove="TextBlock_MouseMove"/>
        </DataTemplate>
    </Window.Resources>

    ...

    <ListBox
        DataContext="{Binding ColumnA}"
        ItemsSource="{Binding Items}"
        ItemTemplate="{StaticResource itemViewModelTemplate}"
        AllowDrop="True"
        Drop="ListBox_Drop"/>

    <ListBox
        DataContext="{Binding ColumnB}"
        ItemsSource="{Binding Items}"
        ItemTemplate="{StaticResource itemViewModelTemplate}"
        AllowDrop="True"
        Drop="ListBox_Drop"/>

    ...
Enter fullscreen mode Exit fullscreen mode

For those comfortable with MVVM or MVC, there’s clearly a lot of shortcuts being taken here. The main issue is that this code isn’t reusable in other parts of our application (other windows, controls, etc.). This approach also heavily couples the View to the implemented types of the ViewModels. We’ll address this in a moment. The result:

Image description

As shown above, the out-of-the-box drag-and-drop provides very little visual feedback. The only adornment of any kind comes from the ability to set DragDropEffects during the drag event handling. Above, I set the effect to Move, which creates the small dotted rectangle below the cursor when it hovers over the receiving element. This is the only visual indication that the dragged item is ready to be dropped on a valid receiver. Additionally, there’s no indication as to *what *is being dragged.

Generalizing the Solution

Let’s continue by generalizing the code so that it can work elsewhere in our application. The core architecture of this solution comes from this article on codeproject by DotNetLead.com:
WPF Drag and Drop using Behavior

If you’re interested in the original approach, I encourage you to read through the linked article. However, we will be modifying and expanding upon this approach significantly to address additional needs.

The first thing to fix is the coupling issue. Drag-and-drop should be made available for data that doesn’t inherit from the ViewModelBase class.

Interfaces to the rescue:

// Describes the object being dragged
    interface IDragable
    {
        // Specifies the originating parent
        IDropable Source { get; set; }
    }


    // Describes the behavior of an object that accepts drops from the user.
    interface IDropable 
    {
        bool Add(object data);
        bool Remove(object data);   
    }
Enter fullscreen mode Exit fullscreen mode

And the modifications to our ViewModels to implement the above interfaces:

 class ItemViewModel : ViewModelBase, IDragable
    {
        ...

        public IDropable Source { get; set; }

        ...
    }

    class ItemCollectionViewModel : ViewModelBase, IDropable
    {
          public bool Add(object item)
          {
              if (item is ItemViewModel itemViewModel)
              {

                  if (Items.Contains(itemViewModel))
                      return false;

                  Items.Add(itemViewModel);
                  return true;
              }

              return false;
          }

          public bool Remove(object item)
          {
              if (item is ItemViewModel itemViewModel)
              {
                  if (!Items.Contains(itemViewModel))
                      return false;

                  return Items.Remove(itemViewModel);
              }

              return false;
          }
    }
Enter fullscreen mode Exit fullscreen mode

At this point, we could update the code-behind to use these interfaces instead of the implemented types. The functionality wouldn’t change at all, but at least we would have removed references to the implemented ViewModel types. However, the intent is to completely remove the functionality from the code-behind. So let’s do that instead.

To do this, we’re going to encapsulate the functionality in a Behavior. Per the MS documentation, behaviors allow us to “encapsulate state information and zero or more ICommands into an attachable object”. It’s OK if that doesn’t make sense immediately. Lets take a look at the code and see the benefits in action.

To work with Behaviors, we need to add a reference to the Microsoft.Xaml.Behaviors namespace. Install the Microsoft.Xaml.Behaviors.Wpf nuget package to do so.

We’ll create two behaviors that we can attach directly to our view controls in our XAML documents. This will completely remove the need to define functionality in code-behind files.

First, the FrameworkElementDragBehavior. This will be attached to elements that we want to be dragable.

// We attach this class to ui elements and controls inside our XAML documents

class FrameworkElementDragBehavior : Behavior<FrameworkElement>
{
    private bool isMouseClicked = false;
    private Selector parent;
    private IDropable parentSource;

    // Called automatically during Initialization
    protected override void OnAttached()
    {
        base.OnAttached();

        // Add listeners to keep track of user mouse input
        this.AssociatedObject.MouseLeftButtonDown +=
             new MouseButtonEventHandler(AssociatedObject_MouseLeftButtonDown);
        this.AssociatedObject.MouseLeftButtonUp +=
             new MouseButtonEventHandler(AssociatedObject_MouseLeftButtonUp);
        this.AssociatedObject.MouseLeave +=
             new MouseEventHandler(AssociatedObject_MouseLeave);


        // We iterate through the visual tree to find the Selector element that houses this element
        parent = AssociatedObject.FindVisualAncestor<Selector>();

        // If the Source DataContext isn't IDropable, we don't expect to be able to drag from it.
        parentSource = parent.DataContext as IDropable;
    }


    void AssociatedObject_MouseLeftButtonDown(object sender, MouseButtonEventArgs e)
    {
        isMouseClicked = true;

    }

    void AssociatedObject_MouseLeftButtonUp(object sender, MouseButtonEventArgs e)
    {
        isMouseClicked = false;

    }

    void AssociatedObject_MouseLeave(object sender, MouseEventArgs e)
    {
        if (isMouseClicked == false || parent == null || parentSource == null)
            return;

        // Set the item's DataContext as the data to be transferred
        IDragable dragObject = this.AssociatedObject.DataContext as IDragable;
        dragObject.Source = parentSource;

        if (dragObject == null)
            return;

        // Set the DataObject
        DataObject data = new DataObject();
        data.SetData(typeof(IDragable), dragObject);


        // System.Windows implementation
        DragDrop.DoDragDrop(this.AssociatedObject, data, DragDropEffects.Move);

        isMouseClicked = false;
    }
}
Enter fullscreen mode Exit fullscreen mode

Second, the FrameworkElementDropBehavior. This will be attached to elements like ListBoxes that receive dragged elements.

class FrameworkElementDropBehavior : Behavior<FrameworkElement>
{
    private IDropable dataContextAsDropable;

    protected override void OnAttached()
    {
        base.OnAttached();

        // Attach listeners to keep track of user mouse input
        this.AssociatedObject.AllowDrop = true;
        this.AssociatedObject.PreviewDragEnter += new DragEventHandler(AssociatedObject_DragEnter);
        this.AssociatedObject.PreviewDragOver += new DragEventHandler(AssociatedObject_DragOver);
        this.AssociatedObject.PreviewDragLeave += new DragEventHandler(AssociatedObject_DragLeave);
        this.AssociatedObject.Drop += new DragEventHandler(AssociatedObject_Drop);
    }


    // Called when an item is dropped onto the associated object
    void AssociatedObject_Drop(object sender, DragEventArgs e)
    {
        e.Handled = true;

        // Return early if the current dataContext doesn't implement IDropable
        if (dataContextAsDropable == null)
            return;

        // Check the dropped item
        IDragable dragable = e.Data.GetData(typeof(IDragable)) as IDragable;
        if (dragable == null || dataContextAsDropable == dragable.Source)
            return;

        // Move the item from the source list to the target list
        if (dataContextAsDropable.Add(dragable))
            dragable.Source.Remove(dragable);
    }

    // Called when an item being dragged leaves the associated object
    void AssociatedObject_DragLeave(object sender, DragEventArgs e)
    {
        e.Handled = true;
    }

    // Called when a dragged item is currently over the associated object
    void AssociatedObject_DragOver(object sender, DragEventArgs e)
    {
        e.Handled = true;
        e.Effects = DragDropEffects.None;
        IDropable target = this.AssociatedObject.DataContext as IDropable;

        // Check if the item can be dropped
        if (e.Data.GetDataPresent(typeof(IDragable)) == false)
            return;

        // Set the effects if the object implements IDragable.
        // Side note: we'll handle type-checking validation later in the series.
        var data = e.Data.GetData(typeof(IDragable));
        if (data is IDragable dragable && dragable.Source != target)
            e.Effects = DragDropEffects.Move;
    }


    // Called when a dragged item is initially dragged over the associated object
    void AssociatedObject_DragEnter(object sender, DragEventArgs e)
    {
        // If no dataContext is set, return early
        if (this.AssociatedObject.DataContext == null)
            return;

        // Get a reference to the dataContext casted as an IDropable
        dataContextAsDropable = this.AssociatedObject.DataContext as IDropable;
        if (dataContextAsDropable == null)
            return;

        e.Handled = true;
    }
}
Enter fullscreen mode Exit fullscreen mode

We can completely remove the functionality from our code-behind:

public partial class MainWindow : Window
{
    public MainWindow()
    {
        InitializeComponent();
    }
}
Enter fullscreen mode Exit fullscreen mode

Finally, we can update our markup. Below is the complete final markup.

<Window x:Class="DragAndDrop.MainWindow"
      xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
      xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
      xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
      xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
      xmlns:i="http://schemas.microsoft.com/xaml/behaviors"
      xmlns:b="clr-namespace:DragAndDrop.Behaviors"
      xmlns:viewModels="clr-namespace:DragAndDrop.ViewModels"
      mc:Ignorable="d"
      Title="MainWindow" 
      Height="300" 
      Width="500"
      ResizeMode="NoResize"
      WindowStartupLocation="CenterScreen">

  <Window.DataContext>
      <viewModels:ViewModel/>
  </Window.DataContext>

  <Window.Resources>
      <DataTemplate x:Key="itemViewModelTemplate">
          <TextBlock Text="{Binding DisplayName}">
           <i:Interaction.Behaviors>
               <!-- HERE IS OUR DRAG BEHAVIOR.
                    THIS INDICATES THAT THE TEXTBLOCK CAN BE DRAGGED -->
               <b:FrameworkElementDragBehavior />
           </i:Interaction.Behaviors>
          </TextBlock>
      </DataTemplate>
  </Window.Resources>

  <Grid 
      Margin="5">
      <Grid.RowDefinitions>
          <RowDefinition Height="30"/>
          <RowDefinition Height="*"/>
          <RowDefinition Height="30"/>
      </Grid.RowDefinitions>
      <Grid.ColumnDefinitions>
          <ColumnDefinition />
          <ColumnDefinition Width="10"/>
          <ColumnDefinition />
      </Grid.ColumnDefinitions>


      <TextBlock
          Text="Column A" 
          FontWeight="Bold"
          Grid.Row="0"
          Grid.Column="0"
          VerticalAlignment="Bottom"/>

      <ListBox
          DataContext="{Binding ColumnA}"
          ItemsSource="{Binding Items}"
          ItemTemplate="{StaticResource itemViewModelTemplate}"
          Grid.Row="1"
          Grid.Column="0"
          x:Name="lb1"
          VerticalAlignment="Stretch">

          <i:Interaction.Behaviors>
              <!-- HERE IS OUR DROP BEHAVIOR.
                   THIS INDICATES THAT THE LISTBOX ACCEPTS DRAGGED ITEMS -->
              <b:FrameworkElementDropBehavior/>
          </i:Interaction.Behaviors>

      </ListBox>

      <TextBlock
          Text="Column B" 
          FontWeight="Bold"
          Grid.Row="0"
          Grid.Column="2"
          VerticalAlignment="Bottom"/>

      <ListBox
          DataContext="{Binding ColumnB}"
          ItemsSource="{Binding Items}"
          ItemTemplate="{StaticResource itemViewModelTemplate}"
          Grid.Row="1"
          Grid.Column="2"
          x:Name="lb2"
          VerticalAlignment="Stretch">
          <i:Interaction.Behaviors>
              <!-- HERE IS OUR DROP BEHAVIOR.
                   THIS INDICATES THAT THE LISTBOX ACCEPTS DRAGGED ITEMS -->
              <b:FrameworkElementDropBehavior/>
          </i:Interaction.Behaviors>
      </ListBox>

      <Button
          x:Name="closeButton"
          Grid.Column="2"
          Grid.Row="2"
          Width="60"
          Height="20"
          Content="Close"
          HorizontalAlignment="Right"
          Command="Close"/>

  </Grid>
</Window>
Enter fullscreen mode Exit fullscreen mode

Closing

We now have the ability to implement drag-and-drop throughout our application simply by adding behaviors to items that we wish to be dragable and that we wish to be able to receive dragged objects. No additional C# code is required.

<i:Interaction.Behaviors>
     <b:FrameworkElementDragBehavior />
 </i:Interaction.Behaviors>

<i:Interaction.Behaviors>
    <b:FrameworkElementDropBehavior/>
</i:Interaction.Behaviors>
Enter fullscreen mode Exit fullscreen mode

I hope you found this useful. In the following articles, we’ll address multi-item drag-and-drop, popups, and adorners.

You can find the complete code for the solution here:
GitHub - bltsmrt/WPFDragAndDrop at part-1

Thanks for reading!

💖 💪 🙅 🚩
prestsauce
Ben Preston

Posted on July 13, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related