DragDropListBox

WPF ListBox subclass with added drag/drop support including an Adorner.

Consumers should use the ItemDropOn and ItemDragOver events, which provide the item in the collection the data was dropped on, as well as its index in the collection. There’s also built-in support for reordering items in the ListBox itself by drag and drop. All you have to do is set AllowDragReorder=”True” in the XAML.

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Documents;
using System.Windows.Input;
using System.Windows.Media;

namespace HollowEarthIndustries
{
    public class DragDropListBox : ListBox
    {
        public DragDropListBox()
        {
            AllowDrop = true;
            DragEnter += DragDropListBox_DragEnter;
            DragOver += DragDropListBox_DragOver;
            DragLeave += DragDropListBox_DragLeave;
            Drop += DragDropListBox_Drop;
        }

        /// <summary>
        /// If ItemsSource is in use, you can't insert into Items.
        /// 
        /// If ItemsSource is in use but it's IEnumerable, as far as I know you can't insert into anything,
        /// but maybe some consumer is doing something clever, so we still allow drop in that case. 
        /// </summary>
        /// <returns></returns>
        public System.Collections.IList GetInsertableCollection()
        {
            if (ItemsSource == null)
                return Items;

            return ItemsSource as System.Collections.IList;
        }

        protected override DependencyObject GetContainerForItemOverride()
        {
            //  We configure the ListBoxItems here rather than with a Style because this is the 
            //  simplest way to allow consumers to use ItemContainerStyle without stepping on 
            //  our internals. 

            var itemContainer = new ListBoxItem
            {
                //  TODO: We need to update the ListBoxItems when the ListBox's AllowDrop changes
                //  TODO: What if consumer provides literal ListBoxItem elements in the XAML? Derp.
                AllowDrop = true,
            };

            //  Events for items as object of drag/drop
            itemContainer.DragEnter += ItemContainer_DragEnter;
            itemContainer.DragOver += ItemContainer_DragOver;
            itemContainer.DragLeave += ItemContainer_DragLeave;
            itemContainer.Drop += ItemContainer_Drop;

            //  Events for items as subject of drag/drop
            itemContainer.PreviewMouseLeftButtonDown += ItemContainer_PreviewMouseLeftButtonDown;
            itemContainer.PreviewMouseMove += ItemContainer_PreviewMouseMove;

            return itemContainer;
        }

        #region Events
        #region ItemDropOn event
        public event EventHandler<ItemDragDropEventArgs> ItemDropOn;

        private string ReorderFormat { get; } = typeof(ItemDragData).FullName;

        protected void OnItemDropOn(DragEventArgs e, object targetItem, int targetIndex)
        {
            if (IsDragReorderingSelf(e))
            {
                var data = (ItemDragData)e.Data.GetData(ReorderFormat);

                System.Collections.IList list = GetInsertableCollection();

                if (list != null)
                {
                    if (!e.KeyStates.HasFlag(DragDropKeyStates.ControlKey) || !AllowDragReorderCopy)
                    {
                        if (data.SourceItemIndex < targetIndex)
                        {
                            //  If we're removing an item that precedes the target item,
                            //  adjust targetIndex down to account for the target item's 
                            //  changed position. 
                            --targetIndex;
                        }
                        list.RemoveAt(data.SourceItemIndex);
                    }

                    list.Insert(targetIndex, data.DragItem);

                    return;
                }
            }
            ItemDropOn?.Invoke(this, new ItemDragDropEventArgs(e, targetItem, targetIndex));
        }
        #endregion ItemDropOn event

        #region ItemDragOver event
        public event EventHandler<ItemDragDropEventArgs> ItemDragOver;

        protected bool IsDragReorderingSelf(DragEventArgs e)
        {
            if (AllowDragReorder && e.Data.GetDataPresent(ReorderFormat))
            {
                var data = (ItemDragData)e.Data.GetData(ReorderFormat);

                if (data.Source == this)
                {
                    return true;
                }
            }
            return false;
        }

        protected void OnItemDragOver(DragEventArgs e, object targetItem, int targetIndex)
        {
            if (IsDragReorderingSelf(e))
            {
                if (e.KeyStates.HasFlag(DragDropKeyStates.ControlKey) && AllowDragReorderCopy)
                {
                    e.Effects = DragDropEffects.Copy;
                }
                else
                {
                    e.Effects = DragDropEffects.Move;
                }
            }

            ItemDragOver?.Invoke(this, new ItemDragDropEventArgs(e, targetItem, targetIndex));
        }
        #endregion ItemDropOn event
        #endregion Events

        #region Drag Over Helpers
        protected void SetDragAfterLastItem(bool isDragging)
        {
            if (HasItems)
            {
                UpdateAdorner(GetLastListBoxItem(), isDragging, false);
            }
        }

        protected void UpdateAdorner(ListBoxItem lbi, bool isDragging, bool insertingBefore)
        {
            var adornerLayer = AdornerLayer.GetAdornerLayer(lbi);
            adornerLayer.IsHitTestVisible = false;

            if (isDragging)
            {
                //  It's a nullable bool; adornerLayer.GetAdorners() is old fashioned and may return null. 
                if (adornerLayer.GetAdorners(lbi)?.OfType<DropTargetAdorner>().Any() != true)
                {
                    adornerLayer.Add(/*_adorner = */new DropTargetAdorner(lbi, insertingBefore, TargetMarkerBrush));
                }
            }
            else
            {
                adornerLayer.GetAdorners(lbi)?.OfType<DropTargetAdorner>().ToList().ForEach(a => adornerLayer.Remove(a));
            }
        }

        protected ListBoxItem GetLastListBoxItem()
        {
            if (HasItems)
            {
                return (ListBoxItem)ItemContainerGenerator.ContainerFromIndex(Items.Count - 1);
            }
            return null;
        }

        #endregion Drag Over Helpers

        #region ListBox Event Handlers
        private void DragDropListBox_DragEnter(object sender, DragEventArgs e)
        {
            SetDragAfterLastItem(true);

            OnItemDragOver(e, null, Items.Count);

            e.Handled = true;
        }

        private void DragDropListBox_DragOver(object sender, DragEventArgs e)
        {
            OnItemDragOver(e, null, Items.Count);

            e.Handled = true;
        }

        private void DragDropListBox_DragLeave(object sender, DragEventArgs e)
        {
            SetDragAfterLastItem(false);

            e.Handled = true;
        }

        private void DragDropListBox_Drop(object sender, DragEventArgs e)
        {
            SetDragAfterLastItem(false);

            if (e.Effects != DragDropEffects.None)
            {
                OnItemDropOn(e, null, Items.Count);
            }

            e.Handled = true;
        }
        #endregion ListBox Event Handlers

        #region ListBoxItem event handlers

        #region Drag source event handlers
        Point? _mouseDownPoint = null;
        private void ItemContainer_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e)
        {
            if (AllowItemDrag)
            {
                _mouseDownPoint = Mouse.GetPosition((Control)sender);
            }
        }

        private void ItemContainer_PreviewMouseMove(object sender, MouseEventArgs e)
        {
            //System.Diagnostics.Trace.WriteLine($"ItemContainer_PreviewMouseMove e.LeftButton {e.LeftButton}");

            if (AllowItemDrag && e.LeftButton == MouseButtonState.Pressed && _mouseDownPoint.HasValue)
            {
                var pos = Mouse.GetPosition((Control)sender);

                //System.Diagnostics.Trace.WriteLine($"_mouseDownPoint {_mouseDownPoint}  pos {pos}");

                if (Distance(pos, _mouseDownPoint.Value) > 4)
                {
                    var lbi = (ListBoxItem)sender;

                    var dragData = new ItemDragData
                    {
                        DragItem = lbi.DataContext ?? lbi,
                        SourceItemIndex = ItemContainerGenerator.IndexFromContainer(lbi),
                        Source = this
                    };

                    var effect = DragDrop.DoDragDrop(this, dragData, DragDropEffects.Move | DragDropEffects.Copy);

                    _mouseDownPoint = null;

                    e.Handled = true;
                }
            }
            else
            {
                _mouseDownPoint = null;
            }
        }

        protected static double Distance(Point a, Point b)
        {
            var xdist = Math.Abs(a.X - b.X);
            var ydist = Math.Abs(a.Y - b.Y);

            return Math.Sqrt((xdist * xdist) + (ydist * ydist));
        }

        #endregion Drag source event handlers

        #region Drag target event handlers
        private void ItemContainer_DragEnter(object sender, DragEventArgs e)
        {
            var lbi = (ListBoxItem)sender;

            UpdateAdorner(lbi, true, true);

            int index = ItemContainerGenerator.IndexFromContainer(lbi);

            OnItemDragOver(e, lbi.DataContext ?? lbi, index);

            e.Handled = true;
        }

        private void ItemContainer_DragOver(object sender, DragEventArgs e)
        {
            var lbi = (ListBoxItem)sender;

            int index = ItemContainerGenerator.IndexFromContainer(lbi);

            OnItemDragOver(e, lbi.DataContext ?? lbi, index);

            e.Handled = true;
        }

        private void ItemContainer_DragLeave(object sender, DragEventArgs e)
        {
            UpdateAdorner((ListBoxItem)sender, false, true);

            e.Handled = true;
        }

        private void ItemContainer_Drop(object sender, DragEventArgs e)
        {
            System.Diagnostics.Trace.WriteLine($"ItemContainer_Drop e.Effects {e.Effects}");

            var lbi = (ListBoxItem)sender;

            UpdateAdorner(lbi, false, true);

            if (e.Effects != DragDropEffects.None)
            {
                int index = ItemContainerGenerator.IndexFromContainer(lbi);
                OnItemDropOn(e, lbi.DataContext ?? lbi, index);
            }

            e.Handled = true;
        }
        #endregion Drag target event handlers
        #endregion ListBoxItem event handlers

        #region Dependency Properties
        #region TargetMarkerBrush Property
        public Brush TargetMarkerBrush
        {
            get { return (Brush)GetValue(TargetMarkerBrushProperty); }
            set { SetValue(TargetMarkerBrushProperty, value); }
        }

        public static readonly DependencyProperty TargetMarkerBrushProperty =
            DependencyProperty.Register(nameof(TargetMarkerBrush), typeof(Brush), typeof(DragDropListBox),
                new PropertyMetadata(Brushes.Black));
        #endregion TargetMarkerBrush Property

        #region AllowItemDrag Property
        public bool AllowItemDrag
        {
            get { return (bool)GetValue(AllowItemDragProperty); }
            set { SetValue(AllowItemDragProperty, value); }
        }

        public static readonly DependencyProperty AllowItemDragProperty =
            DependencyProperty.Register(nameof(AllowItemDrag), typeof(bool), typeof(DragDropListBox),
                new PropertyMetadata(false));
        #endregion AllowItemDrag Property

        #region AllowDragReorderCopy Property
        /// <summary>
        /// If true, drag-reorder operation will copy when control key is pressed
        /// </summary>
        public bool AllowDragReorderCopy
        {
            get { return (bool)GetValue(AllowDragReorderCopyProperty); }
            set { SetValue(AllowDragReorderCopyProperty, value); }
        }

        public static readonly DependencyProperty AllowDragReorderCopyProperty =
            DependencyProperty.Register(nameof(AllowDragReorderCopy), typeof(bool), typeof(DragDropListBox),
                new PropertyMetadata(false));
        #endregion AllowDragReorderCopy Property


        #region AllowDragReorder Property
        public bool AllowDragReorder
        {
            get { return (bool)GetValue(AllowDragReorderProperty); }
            set { SetValue(AllowDragReorderProperty, value); }
        }

        public static readonly DependencyProperty AllowDragReorderProperty =
            DependencyProperty.Register(nameof(AllowDragReorder), typeof(bool), typeof(DragDropListBox),
                new FrameworkPropertyMetadata(false,
                                              FrameworkPropertyMetadataOptions.BindsTwoWayByDefault,
                                              AllowDragReorder_PropertyChanged)
                { DefaultUpdateSourceTrigger = System.Windows.Data.UpdateSourceTrigger.PropertyChanged });

        protected static void AllowDragReorder_PropertyChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
        {
            (d as DragDropListBox).OnAllowDragReorderChanged(e.OldValue);
        }

        private void OnAllowDragReorderChanged(object oldValue)
        {
            if (AllowDragReorder)
            {
                AllowItemDrag = true;
            }
        }
        #endregion AllowDragReorder Property


        #endregion Dependency Properties

        #region ItemDragData class
        public class ItemDragData
        {
            public object DragItem { get; set; }
            public int SourceItemIndex { get; set; }

            public DragDropListBox Source { get; set; }
        }
        #endregion ItemDragData class
    }

    #region ItemDragDropEventArgs class
    public class ItemDragDropEventArgs : EventArgs
    {
        public ItemDragDropEventArgs(DragEventArgs e, object targetItem, int targetIndex)
        {
            _e = e;
            TargetItem = targetItem;
            TargetIndex = targetIndex;
        }

        private DragEventArgs _e;

        public int TargetIndex { get; private set; }

        public IDataObject Data => _e.Data;

        public DragDropEffects Effects
        {
            get => _e.Effects;
            set => _e.Effects = value;
        }
        public DragDropKeyStates KeyStates => _e.KeyStates;
        public DragDropEffects AllowedEffects => _e.AllowedEffects;

        /// <summary>
        /// Either ListBoxItem or DataContext of ListBoxItem
        /// If null, user is dragging over the empty space after the last item in the listbox
        /// </summary>
        public Object TargetItem { get; private set; }
    }
    #endregion ItemDragDropEventArgs class

    #region DropTargetAdorner class
    public class DropTargetAdorner : Adorner
    {
        /*
         * AdornerContentPresenter with a DataTemplate would be preferable:
         * 
         * https://stackoverflow.com/a/10034274/424129
         * 
         * */
        public DropTargetAdorner(UIElement adornedElement, bool insertingBefore, Brush targetHighlightBrush = null)
            : base(adornedElement)
        {
            IsHitTestVisible = false;
            _insertingBefore = insertingBefore;
            _targetHighlightBrush = targetHighlightBrush ?? Brushes.Black;

            Border border = new Border();
        }

        protected double _targetBarHeight = 2;
        protected bool _insertingBefore;
        protected Brush _targetHighlightBrush;

        protected override void OnRender(DrawingContext drawingContext)
        {
            var lbi = AdornedElement as ListBoxItem;

            Rect adornedElementRect = new Rect(0, 0, lbi.ActualWidth, lbi.ActualHeight);
            Size targetBarSize = new Size(adornedElementRect.Width, _targetBarHeight);
            Rect targetBarRect = new Rect(adornedElementRect.Location, targetBarSize);

            if (!_insertingBefore)
            {
                targetBarRect.Location = new Point(targetBarRect.Left, adornedElementRect.BottomLeft.Y - _targetBarHeight);
            }

            drawingContext.DrawRectangle(_targetHighlightBrush, null, targetBarRect);
        }
    }
    #endregion DropTargetAdorner class
}