Click here to Skip to main content
13,250,182 members (58,506 online)
Click here to Skip to main content
Add your own
alternative version

Stats

37.3K views
66 bookmarked
Posted 13 Apr 2016

WPF Controls With Drag Selection

, 16 Jan 2017
Rate this:
Please Sign up or sign in to vote.
Add drag selection functionality to ListBoxes, ListViews, DataGrids, & TreeViews.

Introduction

This article shows how to create a custom ItemsControl with support for drag selection functionality. 

Preview

Table Of Contents

Background

Inspired by the article, ListBox Drag Selection, I decided to share my own solution, which behaves similarly, but is MVVM-friendly and supports the ListBoxListView, and DataGrid controls.

This article assumes you are familiar with ItemsControl; the ListBoxListView, and/or DataGrid control(s); class inheritance; and interfaces.

Features

  1. Wraps drag selection logic into a reusable class, separate from the ItemsControl
  2. Fully customizable drag selection rectangle
  3. MVVM-friendly
  4. Doesn't interfere with mouse events on items
  5. Scrolls automatically while dragging (with just a few lines)
  6. Tested and works with <a href="https://github.com/punker76/gong-wpf-dragdrop">GongSolutions.Wpf.DragDrop</a> library [?]
  7. Tested and works with ListBox, ListView, and DataGrid controls [?]

Using the Code

The example used in this article follows the design of an inherited ListView. To extend this functionality to DataGrids, you'd want to create a separate control inheriting DataGrid and design it in a similar way.

Note, ListBox is a specialized version of ListView so you can technically use a ListView in place of every ListBox and call it a day (overhead aside).

Let's first take a look at the source...

CustomListView

Implementation

using System;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;

namespace Controls
{
    public class CustomListView : ListView, IDragSelector
    {
        #region Properties

        public static DependencyProperty DragSelectorProperty = DependencyProperty.Register("DragSelector", typeof(DragSelector), typeof(CustomListView), new FrameworkPropertyMetadata(null, FrameworkPropertyMetadataOptions.BindsTwoWayByDefault));
        public DragSelector DragSelector
        {
            get
            {
                return (DragSelector)GetValue(DragSelectorProperty);
            }
            set
            {
                SetValue(DragSelectorProperty, value);
            }
        }

        public static DependencyProperty IsDragSelectionEnabledProperty = DependencyProperty.Register("IsDragSelectionEnabled", typeof(bool), typeof(CustomListView), new FrameworkPropertyMetadata(true, FrameworkPropertyMetadataOptions.BindsTwoWayByDefault));
        /// <summary>
        /// Enables selecting items while dragging with a rectangular selector.
        /// </summary>
        public bool IsDragSelectionEnabled
        {
            get
            {
                return (bool)GetValue(IsDragSelectionEnabledProperty);
            }
            set
            {
                SetValue(IsDragSelectionEnabledProperty, value);
            }
        }

        public static DependencyProperty ScrollWrapProperty = DependencyProperty.Register("ScrollWrap", typeof(bool), typeof(CustomListView), new FrameworkPropertyMetadata(true, FrameworkPropertyMetadataOptions.BindsTwoWayByDefault));
        /// <summary>
        /// Determines whether or not selections should "wrap" to beginning or end.
        /// </summary>
        public bool ScrollWrap
        {
            get
            {
                return (bool)GetValue(ScrollWrapProperty);
            }
            set
            {
                SetValue(ScrollWrapProperty, value);
            }
        }

        public static DependencyProperty ScrollOffsetProperty = DependencyProperty.Register("ScrollOffset", typeof(double), typeof(CustomListView), new FrameworkPropertyMetadata(10.0, FrameworkPropertyMetadataOptions.BindsTwoWayByDefault));
        /// <summary>
        /// Indicates offset to apply when automatically scrolling.
        /// </summary>
        public double ScrollOffset
        {
            get
            {
                return (double)GetValue(ScrollOffsetProperty);
            }
            set
            {
                SetValue(ScrollOffsetProperty, value);
            }
        }

        public static DependencyProperty ScrollToleranceProperty = DependencyProperty.Register("ScrollTolerance", typeof(double), typeof(CustomListView), new FrameworkPropertyMetadata(5.0, FrameworkPropertyMetadataOptions.BindsTwoWayByDefault));
        /// <summary>
        /// Indicates how far from outer width/height to allow offset application when automatically scrolling.
        /// </summary>
        public double ScrollTolerance
        {
            get
            {
                return (double)GetValue(ScrollToleranceProperty);
            }
            set
            {
                SetValue(ScrollToleranceProperty, value);
            }
        }

        public static DependencyProperty SelectionProperty = DependencyProperty.Register("Selection", typeof(Selection), typeof(CustomListView), new FrameworkPropertyMetadata(null, FrameworkPropertyMetadataOptions.BindsTwoWayByDefault));
        public Selection Selection
        {
            get
            {
                return (Selection)GetValue(SelectionProperty);
            }
            set
            {
                SetValue(SelectionProperty, value);
            }
        }

        #endregion

        #region Methods

        #region Private

        /// <summary>
        /// Moves current selection to the left.
        /// </summary>
        void MoveLeft()
        {
            //If nothing is selected, moving left selects first item.
            if (this.SelectedItems.Count == 0)
                this.Items.MoveCurrentToFirst();
            else if (!this.Items.MoveCurrentToPrevious())
            {
                //If that fails, there is no previous, we are already at first.
                //Attempt to select last (if allowed).
                if (this.ScrollWrap)
                    this.Items.MoveCurrentToLast();
            }
        }

        /// <summary>
        /// Moves current selection to the right.
        /// </summary>
        void MoveRight()
        {
            //If nothing is selected, moving right selects last item.
            if (this.SelectedItems.Count == 0)
                this.Items.MoveCurrentToLast();
            //Otherwise, attempt to select next.
            else if (!this.Items.MoveCurrentToNext())
            {
                //If that fails, there is no next, we are already at last.
                //Attempt to select first (if allowed).
                if (this.ScrollWrap)
                    this.Items.MoveCurrentToFirst();
            }
        }

        /// <summary>
        /// Moves current selection to next row.
        /// </summary>
        void MoveToNextRow(string Direction, int ItemsPerRow, int Offset = 1)
        {
            int? NewIndex = null;
            if (Direction == "Up")
            {
                NewIndex = this.SelectedIndex - ItemsPerRow + Offset;
                if (NewIndex < 0)
                {
                    //NewIndex = this.ScrollWrap ? this.Items.Count + NewIndex : null;
                }
            }
            else if (Direction == "Down")
            {
                NewIndex = this.SelectedIndex + ItemsPerRow - Offset;
                if (NewIndex >= this.Items.Count)
                {
                    //NewIndex = this.ScrollWrap ? NewIndex - this.Items.Count : null;
                }
            }
            //If a new index was successfully calculated
            if (NewIndex != null)
            {
                //Double check it's valid.
                NewIndex = NewIndex < 0 ? 0 : (NewIndex >= this.Items.Count ? this.Items.Count - 1 : NewIndex);
                this.SelectedIndex = NewIndex.Value;
            }
        }

        #endregion

        #region Override

        /// <summary>
        /// Occurs when template is applied; gets references to 
        /// elements contained in template and registers mouse events
        /// for dragging.
        /// </summary>
        public override void OnApplyTemplate()
        {
            base.ApplyTemplate();
            this.DragSelector = new DragSelector(this);
        }

        #endregion

        #region Commands

        /// <summary>
        /// Makes selection based on directional key pressed.
        /// </summary>
        /// <pseudo>
        /// If up or left is clicked and nothing is selected, 
        /// select first. If bottom or right is clicked and 
        /// nothing is selected, select last. If first is 
        /// selected and clicking left or up, select last. 
        /// If last is selected and clicking right or down, 
        /// select first.
        /// </summary>
        public static readonly RoutedUICommand MakeSelection = new RoutedUICommand("MakeSelection", "MakeSelection", typeof(CustomListView));
        void MakeSelection_Executed(object sender, ExecutedRoutedEventArgs e)
        {
            //Can't select nothing so do nothing.
            if (this.Items.Count == 0)
                return;
            string Direction = e.Parameter.ToString();
            switch (Direction)
            {
                case "Up":
                case "Down":
                    double ListViewWidth = this.ActualWidth - this.Padding.Left - this.Padding.Right;
                    double SelectedItemWidth = ((ListViewItem)this.ItemContainerGenerator.ContainerFromItem(this.Items.CurrentItem)).ActualWidth;
                    int ItemsPerRow = Convert.ToInt32(Math.Round(ListViewWidth / SelectedItemWidth));
                    //If there's only one item in each row, 
                    //we don't have to calculate anything.
                    //In this scenario, up corresponds to left, 
                    //and  down corresponds to right.
                    if (ItemsPerRow <= 1)
                    {
                        if (Direction == "Up")
                            this.MoveLeft();
                        else if (Direction == "Down")
                            this.MoveRight();
                        return;
                    }
                    this.MoveToNextRow(Direction, ItemsPerRow);
                    break;
                case "Left":
                    this.MoveLeft();
                    break;
                case "Right":
                    this.MoveRight();
                    break;
            }
        }

        #endregion

        #endregion

        #region CustomListView

        public CustomListView() : base()
        {
            this.DefaultStyleKey = typeof(CustomListView);

            //Enables moving current selection around
            this.IsSynchronizedWithCurrentItem = true;

            this.CommandBindings.Add(new CommandBinding(MakeSelection, this.MakeSelection_Executed));

            this.InputBindings.Add(new KeyBinding(MakeSelection, Key.Up, ModifierKeys.None)
            {
                CommandParameter = "Up"
            });
            this.InputBindings.Add(new KeyBinding(MakeSelection, Key.Down, ModifierKeys.None)
            {
                CommandParameter = "Down"
            });
            this.InputBindings.Add(new KeyBinding(MakeSelection, Key.Left, ModifierKeys.None)
            {
                CommandParameter = "Left"
            });
            this.InputBindings.Add(new KeyBinding(MakeSelection, Key.Right, ModifierKeys.None)
            {
                CommandParameter = "Right"
            });
        }

        #endregion
    }
}
At first, it seems like a lot to take in, but all we really care about in regards to drag selection is:
  • The DragSelector property, which takes care of all the drag selection logic, including assigning all necessary events, getting all required objects, etc. This MUST be initailized in the overrided OnApplyTemplate method, which provides access to the ItemsControl by passing this to constructor.
Additional features have been added to extend CustomListView's functionality, such as:
  • Key bindings for all directional arrow keys, which allow moving the current selection left, right, up, and down.

Note, all ItemsControls you wish to provide drag selection functionality for must implement the interface, IDragSelector, which exposes important properties. 

Template

This is an example template. It should be defined in /Themes/Generic.xaml and can be overridden wherever necessary.

Note, all names defined in the example template must be present in overridden templates so the underlying class knows where everything is.

If you have multiple views and one of them uses GridView, see Known Issues for information on how to accomodate it.

<Style x:Key="{x:Type Controls:CustomListView}" TargetType="{x:Type Controls:CustomListView}">
    <Setter Property="ScrollViewer.HorizontalScrollBarVisibility" Value="Disabled"/>
    <Setter Property="Template">
        <Setter.Value>
            <ControlTemplate TargetType="{x:Type Controls:CustomListView}">
                <ScrollViewer x:Name="PART_ScrollViewer" Margin="{TemplateBinding Margin}">
                    <Grid x:Name="PART_Grid" Background="Transparent" ClipToBounds="True" Margin="{TemplateBinding Padding}">
                        <ItemsPresenter/>
                        <Controls:DragSelection x:Name="PART_DragSelection" Selection="{TemplateBinding Selection}"/>
                    </Grid>
                </ScrollViewer>
            </ControlTemplate>
        </Setter.Value>
    </Setter>
</Style>

IDragSelector

namespace Controls
{
    public interface IDragSelector
    {
        double ScrollOffset
        {
            get; set;
        }

        double ScrollTolerance
        {
            get; set;
        }

        bool IsDragSelectionEnabled
        {
            get; set;
        }

        Selection Selection
        {
            get; set;
        }
    }
}
 
At this point, you may be wondering if IDragSelector must be implemented using dependancy properties: Technically, no, but that is up for you to decide.
 
Now let's take a look at the drag selection logic:

DragSelector

using System;
using System.Collections.Generic;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Controls.Primitives;
using System.Windows.Input;
using System.Windows.Media;

namespace Controls
{
    public sealed class DragSelector : AbstractObject
    {
        #region Properties

        ItemsControl ItemsControl
        {
            get; set;
        }

        IDragSelector IDragSelector
        {
            get; set;
        }

        #region Private

        /// <summary>
        /// Stores reference to previously selected area.
        /// </summary>
        Rect PreviousArea;

        /// <summary>
        /// Indicates if we're currently dragging.
        /// </summary>
        bool IsDragging = false;

        /// <summary>
        /// Point indicating where the drag started.
        /// </summary>
        Point StartDrag;

        /// <summary>
        /// Stores reference to ScrollViewer's style's hash code.
        /// </summary>
        int? Hash = null;

        #endregion

        #region References

        DragSelection DragSelection
        {
            get; set;
        }

        ScrollContentPresenter ScrollContent
        {
            get; set;
        }

        ScrollViewer ScrollViewer
        {
            get; set;
        }

        Grid Grid
        {
            get; set;
        }

        #endregion

        #endregion

        #region Methods

        #region Private

        /// <summary>
        /// Ensures given point values do not exceed canvas bounds.
        /// </summary>
        async Task<Point> BoundPoint(double x, double y)
        {
            return await this.BoundPoint(x, y, new Size(this.ItemsControl.Width, this.ItemsControl.Height), new Size(this.IDragSelector.Selection.Width, this.IDragSelector.Selection.Height));
        }

        /// <summary>
        /// Ensures given point values do not exceed canvas bounds.
        /// </summary>
        async Task<Point> BoundPoint(double x, double y, Size ItemsControlSize, Size SelectionSize)
        {
            Point Result = default(Point);
            await Task.Run(new Action(() =>
            {
                x = x < 0 ? 0 : x;
                y = y < 0 ? 0 : y;
                var NewX = ItemsControlSize.Width - SelectionSize.Width;
                var NewY = ItemsControlSize.Height - SelectionSize.Height;
                x = x > NewX ? NewX : x;
                y = y > NewY ? NewY : y;
                Result = new Point(x, y);
            }));
            return Result;
        }

        /// <summary>
        /// Calculate new size
        /// </summary>
        async Task<Size> GetSize(Point CurrentPosition, Point StartDrag)
        {
            Size Result = default(Size);
            await Task.Run(new Action(() => Result = new Size(Math.Abs(CurrentPosition.X - StartDrag.X), Math.Abs(CurrentPosition.Y - StartDrag.Y))));
            return Result;
        }

        /// <summary>
        /// Ensures given size does not exceed canvas bounds.
        /// </summary>
        async Task<Size> BoundSize(Size Size, Point Point, Size ItemsControlSize)
        {
            Size Result = default(Size);
            await Task.Run(new Action(() =>
            {
                double Width =
                    Size.Width + Point.X > ScrollContentSize.Width
                        ? ScrollContentSize.Width - Point.X
                        : Size.Width;
                double Height =
                    Size.Height + Point.Y > ScrollContentSize.Height
                        ? ScrollContentSize.Height - Point.Y
                        : Size.Height;
                Result = new Size(Width < 0.0 ? 0.0 : Width, Height < 0.0 ? 0.0 : Height);
            }));
            return Result;
        }

        /// <summary>
        /// Finds a child element by type in given element.
        /// </summary>
        static IEnumerable<T> FindVisualChildren<T>(DependencyObject depObj) where T : DependencyObject
        {
            if (depObj != null)
            {
                for (int i = 0; i < VisualTreeHelper.GetChildrenCount(depObj); i++)
                {
                    DependencyObject child = VisualTreeHelper.GetChild(depObj, i);
                    if (child != null && child is T)
                    {
                        yield return (T)child;
                    }

                    foreach (T childOfChild in FindVisualChildren<T>(child))
                    {
                        yield return childOfChild;
                    }
                }
            }
        }

        /// <summary>
        /// Find ScrollContentPresenter element in ScrollViewer template.
        /// </summary>
        void FindScrollContent()
        {
            if (this.ScrollViewer == null)
                return;
            foreach (FrameworkElement t in DragSelector.FindVisualChildren<FrameworkElement>(this.ScrollViewer))
            {
                if (t is ScrollContentPresenter)
                {
                    this.ScrollContent = t as ScrollContentPresenter;
                    this.Hash = this.ScrollViewer.Style.GetHashCode();
                }
            }
        }

        /// <summary>
        /// Checks if either control or shift key is pressed.
        /// </summary>
        bool IsModifierPressed()
        {
            return (Keyboard.Modifiers & ModifierKeys.Control) != 0 || (Keyboard.Modifiers & ModifierKeys.Shift) != 0;
        }

        /// <summary>
        /// Attempts to clear the current selection.
        /// </summary>
        bool TryClearSelected()
        {
            try
            {
                if (this.ItemsControl is ListBox)
                    (this.ItemsControl as ListBox).SelectedItems.Clear();
                else if (this.ItemsControl is DataGrid)
                    (this.ItemsControl as DataGrid).SelectedItems.Clear();
                return true;
            }
            catch
            {
                return false;
            }
        }

        /// <summary>
        /// Attempts to select or deselect a ListViewItem.
        /// </summary>
        bool TrySelect(DependencyObject Item, bool IsSelected)
        {
            try
            {
                Selector.SetIsSelected(Item, IsSelected);
                return true;
            }
            catch
            {
                return false;
            }
        }

        /// <summary>
        /// Updates selection when drag selection is enabled and active.
        /// </summary>
        void UpdateSelected(Rect Area)
        {
            //Check if selection should be appended are replaced. We allow appending when either control or shift key is pressed.
            if (!this.IsModifierPressed())
                this.TryClearSelected();
            for (int i = 0; i < this.ItemsControl.Items.Count; i++)
            {
                //Get reference to visual object from data object
                FrameworkElement Item = this.ItemsControl.ItemContainerGenerator.ContainerFromIndex(i) as FrameworkElement;
                if (Item == null)
                    continue;

                //Get item bounds based on parent.
                Point TopLeft = Item.TranslatePoint(new Point(0, 0), this.Grid);
                Rect ItemBounds = new Rect(TopLeft.X, TopLeft.Y, Item.ActualWidth, Item.ActualHeight);
                
                // Only change the selection if it intersects (or previously intersected) with the given area.
                bool? IsSelected = null;
                if (ItemBounds.IntersectsWith(Area))
                    IsSelected = true;
                else if (ItemBounds.IntersectsWith(this.PreviousArea))
                    IsSelected = false;
                if (IsSelected != null)
                    this.TrySelect(Item, IsSelected.Value);
            }
            this.PreviousArea = Area;
        }

        #endregion

        #region Events

        /// <summary>
        /// Occurs when mouse is down; begins drag.
        /// </summary>
        void OnMouseDown(object sender, MouseButtonEventArgs e)
        {
            if (!this.IDragSelector.IsDragSelectionEnabled || e.ChangedButton != MouseButton.Left)
                return;
            this.Grid.CaptureMouse();
            this.IsDragging = true;
            this.PreviousArea = new Rect();
            this.StartDrag = e.GetPosition(this.Grid);
            this.IDragSelector.Selection.Set(this.StartDrag.X, this.StartDrag.Y, 0, 0);
        }

        /// <summary>
        /// Ocurrs whenever mouse moves; drag is evaluated.
        /// </summary>
        async void OnMouseMove(object sender, MouseEventArgs e)
        {
            //If left mouse button is not pressed or we don't have proper mouse capture, do nothing.
            if (e.LeftButton != MouseButtonState.Pressed || !this.Grid.IsMouseCaptured)
                return;

            Point CurrentPosition = e.GetPosition(this.Grid);

            //Calculate new position
            double 
                x = (this.StartDrag.X < CurrentPosition.X ? this.StartDrag.X : CurrentPosition.X), 
                y = (this.StartDrag.Y < CurrentPosition.Y ? this.StartDrag.Y : CurrentPosition.Y);

            Size NewSize = await this.GetSize(CurrentPosition, this.StartDrag); 
            Point NewPosition = await this.BoundPoint(x, y);
            NewSize = await this.BoundSize(NewSize, NewPosition, new Size(this.ScrollContent.Width, this.ScrollContent.Height));

            //Check again since this method runs asynchronously
            if (e.LeftButton != MouseButtonState.Pressed || !this.Grid.IsMouseCaptured)
                return;

            //Update visual selection
            this.IDragSelector.Selection.Set(NewPosition.X, NewPosition.Y, NewSize.Width, NewSize.Height);
            
            //Update selected items
            this.UpdateSelected(new Rect(this.ScrollContent.TranslatePoint(this.IDragSelector.Selection.TopLeft, this.ItemsControl), this.ScrollContent.TranslatePoint(this.IDragSelector.Selection.BottomRight, this.ItemsControl)));
            
            double VerticalPosition = e.GetPosition(this.ItemsControl).Y;
            //Cursor is at top, scroll up.
            if (VerticalPosition < this.IDragSelector.ScrollTolerance)
                this.ScrollViewer.ScrollToVerticalOffset(this.ScrollViewer.VerticalOffset - this.IDragSelector.ScrollOffset);
            //Cursor is at bottom, scroll down.  
            else if (VerticalPosition > this.ItemsControl.ActualHeight - this.IDragSelector.ScrollTolerance) //Bottom of visible list?
                this.ScrollViewer.ScrollToVerticalOffset(this.ScrollViewer.VerticalOffset + this.IDragSelector.ScrollOffset); 
            //Cursor is at left, scroll left.  
            else if (HorizontalPosition < this.IDragSelector.ScrollTolerance)
                this.ScrollViewer.ScrollToHorizontalOffset(this.ScrollViewer.HorizontalOffset - this.IDragSelector.ScrollOffset);
            //Cursor is at right, scroll right.  
            else if (HorizontalPosition > this.ItemsControl.ActualWidth - this.IDragSelector.ScrollTolerance)
                this.ScrollViewer.ScrollToHorizontalOffset(this.ScrollViewer.HorizontalOffset + this.IDragSelector.ScrollOffset);
        }

        /// <summary>
        /// Occurs when mouse is up; ends drag.
        /// </summary>
        void OnMouseUp(object sender, MouseButtonEventArgs e)
        {
            if (!this.IDragSelector.IsDragSelectionEnabled || e.LeftButton != MouseButtonState.Released || !this.Grid.IsMouseCaptured)
                return;
            this.Grid.ReleaseMouseCapture();
            this.IsDragging = false;
            //Hide selection
            this.IDragSelector.Selection.Width = 0;
            this.IDragSelector.Selection.Height = 0;
            //Check if mouse has moved at all since pressing left button. If not, clear selection. Blank space was clicked.
            Point CurrentPosition = e.GetPosition(this.Grid);
            if (this.StartDrag == CurrentPosition)
                this.TryClearSelected();
        }

        /// <summary>
        /// Occurs when ScrollViewer's layout has updated.
        /// </summary>
        void OnScrollViewerLayoutUpdated(object sender, EventArgs e)
        {
            if (this.Hash == null || this.ScrollViewer.Style.GetHashCode() != this.Hash.Value)
                this.FindScrollContent();
        }

        /// <summary>
        /// Occurs when ScrollViewer has loaded; gets initial reference to ScrollContentPresenter.
        /// </summary>
        void OnScrollViewerLoaded(object sender, RoutedEventArgs e)
        {
            this.FindScrollContent();
        }

        #endregion

        #endregion

        #region DragSelector

        internal DragSelector(ItemsControl ItemsControl)
        {
            this.ItemsControl = ItemsControl;
            if (this.ItemsControl == null || !(this.ItemsControl is IDragSelector))
                throw new InvalidCastException("ItemsControl is either null or not of type IDragSelector.");

            this.IDragSelector = this.ItemsControl as IDragSelector;
            this.IDragSelector.Selection = new Selection(0.0, 0.0, 0.0, 0.0);

            this.Grid = this.ItemsControl.Template.FindName("PART_Grid", this.ItemsControl) as Grid;
            if (this.Grid == null)
                throw new KeyNotFoundException("Grid cannot be null.");
            this.Grid.MouseDown += this.OnMouseDown;
            this.Grid.MouseMove += this.OnMouseMove;
            this.Grid.MouseUp += this.OnMouseUp;

            this.ScrollViewer = this.ItemsControl.Template.FindName("PART_ScrollViewer", this.ItemsControl) as ScrollViewer;
            if (this.ScrollViewer == null)
                throw new KeyNotFoundException("ScrollViewer cannot be null.");
            this.ScrollViewer.LayoutUpdated += OnScrollViewerLayoutUpdated;
            this.ScrollViewer.Loaded += OnScrollViewerLoaded;

            this.DragSelection = this.ItemsControl.Template.FindName("PART_DragSelection", this.ItemsControl) as DragSelection;
            if (this.DragSelection == null)
                throw new KeyNotFoundException("DragSelection cannot be null.");
        }

        #endregion
    }
}
Note, though IDragSelector has a similar name to DragSelector, DragSelector does NOT implement IDragSelector. The ItemsControl you wish to provide drag selection functionality for should implement IDragSelector.
 
Drag selection occurs during three mouse events:
  • MouseDown
    • Starts drag
    • Resets previous selection
    • Records initial mouse location
  • MouseMove
    • Drag is evaluated
    • A new selection is set by calculating new size and position based on mouse location in ScrollContentPresenter
    • The items to select are determined based on previous and current selection
    • The ScrollViewer scrolls based on where the mouse is located (ScrollTolerance) and how much you wish to scroll at a time (ScrollOffset)
  • MouseUp
    • Drag ends
    • Initial mouse location is compared to final mouse location to see if they're different. If they're the same, it is assumed empty space was clicked and all items are deselected
    • Selection's size and position is "reset", which hides the selection element until a new selection is made
All of which are assigned to the appropriate elements in the constructor of DragSelector.  You will also find a litter of private helper methods, which assist in drag selection logic. There are two important reference properties for the ItemsControl defined in DragSelector:
  1. ItemsControl
  2. IDragSelector

Both are used in various scenarios in order to expose properties unique to either; they cannot be used interchangeably, though, both reference the exact same control.

DragSelection

The DragSelection control is the visual representation of the selection. It is a canvas with a partially transparent Border, which uses a TranslateTransform to position it on the canvas. The selection's width and height is bound directly to the Border.

Template

<Style x:Key="{x:Type Controls:DragSelection}" TargetType="{x:Type Controls:DragSelection}">
    <Setter Property="Template">
        <Setter.Value>
            <ControlTemplate TargetType="{x:Type Controls:DragSelection}">
                <Canvas x:Name="PART_Canvas">
                    <Border 
                        x:Name="PART_Rectangle" 
                        DataContext="{Binding RelativeSource={RelativeSource TemplatedParent}}"
                        BorderThickness="1" 
                        BorderBrush="{DynamicResource ListBoxSelectionBorderBrush}" 
                        Background="{DynamicResource ListBoxSelectionBackground}" 
                        CornerRadius="1" 
                        Opacity="0.5" 
                        Width="{Binding Selection.Width}" 
                        Height="{Binding Selection.Height}">
                        <Border.RenderTransform>
                            <TranslateTransform 
                                        X="{Binding Selection.X}" 
                                        Y="{Binding Selection.Y}"/>
                        </Border.RenderTransform>
                    </Border>
                </Canvas>
            </ControlTemplate>
        </Setter.Value>
    </Setter>
</Style>

Implementation

using System.Windows;
using System.Windows.Controls;

namespace Controls
{
    public sealed class DragSelection : UserControl
    {
        public static DependencyProperty SelectionProperty = DependencyProperty.Register("Selection", typeof(Selection), typeof(DragSelection), new FrameworkPropertyMetadata(null, FrameworkPropertyMetadataOptions.BindsTwoWayByDefault));
        public Selection Selection
        {
            get
            {
                return (Selection)GetValue(SelectionProperty);
            }
            set
            {
                SetValue(SelectionProperty, value);
            }
        }

        public DragSelection() : base()
        {
            this.DefaultStyleKey = typeof(DragSelection);
        }
    }
}

Selection

The Selection object lets us keep track of the current drag selection size and position, which we bind to the DragSelection control in CustomListView's template.

public class Selection : AbstractObject
{
    #region Properties

    public event EventHandler<EventArgs> SelectionChanged;

    private double width = 0;
    public double Width
    {
        get
        {
            return width;
        }
        set
        {
            this.width = value;
            OnPropertyChanged("Width");
            if (SelectionChanged != null) SelectionChanged(this, EventArgs.Empty);
        }
    }

    private double height = 0;
    public double Height
    {
        get
        {
            return height;
        }
        set
        {
            this.height = value;
            OnPropertyChanged("Height");
            if (SelectionChanged != null) SelectionChanged(this, EventArgs.Empty);
        }
    }

    private double x = 0;
    public double X
    {
        get
        {
            return x;
        }
        set
        {
            this.x = value;
            OnPropertyChanged("X");
            if (SelectionChanged != null) SelectionChanged(this, EventArgs.Empty);
        }
    }

    private double y = 0;
    public double Y
    {
        get
        {
            return y;
        }
        set
        {
            this.y = value;
            OnPropertyChanged("Y");
            if (SelectionChanged != null) SelectionChanged(this, EventArgs.Empty);
        }
    }

    public Point TopLeft
    {
        get
        {
            return new Point(this.X, this.Y);
        }
    }

    public Point TopRight
    {
        get
        {
            return new Point(this.X + this.Width, this.Y);
        }
    }

    public Point BottomLeft
    {
        get
        {
            return new Point(this.X, this.Y + this.Height);
        }
    }

    public Point BottomRight
    {
        get
        {
            return new Point(this.X + this.Width, this.Y + this.Height);
        }
    }

    #endregion

    #region Methods

    /// <summary>
    /// Sets new selection with given values.
    /// </summary>
    /// <param name="X">X-position of selection.</param>
    /// <param name="Y">Y-position of selection.</param>
    /// <param name="Width">Width of selection.</param>
    /// <param name="Height">Height of selection.</param>
    public void Set(double X, double Y, double Width, double Height)
    {
        this.X = X;
        this.Y = Y;
        this.Width = Width;
        this.Height = Height;
    }

    #endregion

    #region Selection

    /// <summary>
    /// Initializes new instance of Selection.
    /// </summary>
    /// <param name="X">X-position of selection.</param>
    /// <param name="Y">Y-position of selection.</param>
    /// <param name="Width">Width of selection.</param>
    /// <param name="Height">Height of selection.</param>
    public Selection(double X, double Y, double Width, double Height)
    {
        this.X = X;
        this.Y = Y;
        this.Width = Width;
        this.Height = Height;
    }

    #endregion
}

AbstractObject

DragSelector and Selection inherits AbstractObject, which does nothing more than expose INotifyPropertyChanged.

public abstract class AbstractObject : INotifyPropertyChanged
{
    public event PropertyChangedEventHandler PropertyChanged;
    public void OnPropertyChanged(string propertyName)
    {
        if (this.PropertyChanged != null)
        {
            this.PropertyChanged(this, new PropertyChangedEventArgs(propertyName));
        }
    }
    public AbstractObject()
    {
    }
}

Extending Functionality

To understand PRECISELY what gives any ItemsControl the drag selection functionality, let's highlight all of the areas of interest to eliminate confusion.

Note, all logic needed for drag selection functionality is wrapped in DragSelector and uses both IDragSelector and ItemsControl to reference things as needed.

Also note, only ListBoxes, ListViews, and DataGrids have been tested.

ItemsControl

Where the ItemsControl is either an inherited ListBox, ListView, or DataGrid.

Template

The template should follow this format:

<ControlTemplate TargetType="{x:Type Controls:CustomListView}">
    <ScrollViewer x:Name="PART_ScrollViewer" Margin="{TemplateBinding Margin}">
        <Grid x:Name="PART_Grid" Background="Transparent" ClipToBounds="True" Margin="{TemplateBinding Padding}">
            <!-- items to go here; this is usually a StackPanel with IsItemsHost = true or an ItemsPresenter -->
            <Controls:DragSelection x:Name="PART_DragSelection" Selection="{TemplateBinding Selection}"/>
        </Grid>
    </ScrollViewer>
</ControlTemplate>

Where the ScrollViewer, Grid, and DragSelection controls MUST have the names shown in the sample above. You can change them, just make sure you change all references to these names in DragSelector.

Implementation

First off, the ItemsControl will need to implement IDragSelector and to do so, you will need to define the following properties (which may or may not be dependancy-like):

public static DependencyProperty DragSelectorProperty = DependencyProperty.Register("DragSelector", typeof(DragSelector), typeof(CustomListView), new FrameworkPropertyMetadata(null, FrameworkPropertyMetadataOptions.BindsTwoWayByDefault));
public DragSelector DragSelector
{
    get
    {
        return (DragSelector)GetValue(DragSelectorProperty);
    }
    set
    {
        SetValue(DragSelectorProperty, value);
    }
}

public static DependencyProperty IsDragSelectionEnabledProperty = DependencyProperty.Register("IsDragSelectionEnabled", typeof(bool), typeof(CustomListView), new FrameworkPropertyMetadata(true, FrameworkPropertyMetadataOptions.BindsTwoWayByDefault));
public bool IsDragSelectionEnabled
{
    get
    {
        return (bool)GetValue(IsDragSelectionEnabledProperty);
    }
    set
    {
        SetValue(IsDragSelectionEnabledProperty, value);
    }
}

public static DependencyProperty ScrollOffsetProperty = DependencyProperty.Register("ScrollOffset", typeof(double), typeof(CustomListView), new FrameworkPropertyMetadata(10.0, FrameworkPropertyMetadataOptions.BindsTwoWayByDefault));
public double ScrollOffset
{
    get
    {
        return (double)GetValue(ScrollOffsetProperty);
    }
    set
    {
        SetValue(ScrollOffsetProperty, value);
    }
}

public static DependencyProperty ScrollToleranceProperty = DependencyProperty.Register("ScrollTolerance", typeof(double), typeof(CustomListView), new FrameworkPropertyMetadata(5.0, FrameworkPropertyMetadataOptions.BindsTwoWayByDefault));
public double ScrollTolerance
{
    get
    {
        return (double)GetValue(ScrollToleranceProperty);
    }
    set
    {
        SetValue(ScrollToleranceProperty, value);
    }
}

public static DependencyProperty SelectionProperty = DependencyProperty.Register("Selection", typeof(Selection), typeof(CustomListView), new FrameworkPropertyMetadata(null, FrameworkPropertyMetadataOptions.BindsTwoWayByDefault));
public Selection Selection
{
    get
    {
        return (Selection)GetValue(SelectionProperty);
    }
    set
    {
        SetValue(SelectionProperty, value);
    }
}

In addition to overriding this method:

public override void OnApplyTemplate()
{
    base.ApplyTemplate();
    this.DragSelector = new DragSelector(this);
}

For The Sake Of Simplicity...

No further implementation is needed on your part.

ListBox

ListView is a ListBox minus the views and the meat. There are many ways you can go about extending drag selection functionality to the ListBox using my solution, however, I leave that up to you as the ListView already addresses all of my concerns. 

TreeView

Though the drag/drop fix does support TreeViews, the drag selection solution does not. This is because TreeViews do not support multiple selections by default and I have not had time to experiment with one that does.

Known Issues

  • If your ListView uses GridView, you may require changing the style of the ScrollViewer when the view changes. Though this can easily be taken care of using data triggers (as demonstrated in example template), it presents two problems:
    • The ScrollViewer in a GridView requires a DataGridRowPresenter unlike custom views, which may use an ItemsPresenter. If you change the style of the ScrollViewer to accommodate the GridView at runtime, you will notice drag selecting does not select items, even after changing it back. This is because in order to accurately select items, we have to know where the ScrollContentPresenter is. When we swap ScrollViewer styles, we lose our initial reference to it. This can be amended by ensuring you call the public method CustomListView.FindScrollContent() any time the style on the ScrollViewer (or in more complex scenarios, the template of the ListView itself) changes. Ideally, I would like to find a way for the CustomListView to listen for these changes automatically. It does this now!
    • GridView doesn't select items at all, but the selection element still shows. GridView supports drag selecting all items except very first.
  • If items are already selected, drag selecting over them again with the modifier key pressed does not deselect them.
  • If the ItemsControl is a DataGrid, drag selecting works for all items except first; this issue is identical to the one observed for GridView.

Drag & Drop

If using the library, GongSolutions.Wpf.DragDrop, you will run into issues with drag selection. Essentially, the drag/drop behavior interferes with the drag selection behavior, but never vice versa. To fix this, follow these steps:

  1. Download the latest source for GongSolutions.Wpf.DragDrop.
  2. Open the file DragDrop.cs and navigate to the following method:
private static void DragSource_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e);
  1. Replace default implementation with the following (changes are enclosed in commented dashes):

private static void DragSource_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e)
{
    // Ignore the click if clickCount != 1 or the user has clicked on a scrollbar.
    var ElementPosition = e.GetPosition((IInputElement)sender);
    if (e.ClickCount != 1
        || (sender as UIElement).IsDragSourceIgnored()
        || (e.Source as UIElement).IsDragSourceIgnored()
        || (e.OriginalSource as UIElement).IsDragSourceIgnored()
        || (sender is TabControl) && !HitTestUtilities.HitTest4Type<TabPanel>(sender, ElementPosition)
        || HitTestUtilities.HitTest4Type<RangeBase>(sender, ElementPosition)
        || HitTestUtilities.HitTest4Type<ButtonBase>(sender, ElementPosition)
        || HitTestUtilities.HitTest4Type<TextBoxBase>(sender, ElementPosition)
        || HitTestUtilities.HitTest4Type<PasswordBox>(sender, ElementPosition)
        || HitTestUtilities.HitTest4Type<ComboBox>(sender, ElementPosition)
        || HitTestUtilities.HitTest4GridViewColumnHeader(sender, ElementPosition)
        || HitTestUtilities.HitTest4DataGridTypes(sender, ElementPosition)
        || HitTestUtilities.IsNotPartOfSender(sender, e))
    {
        m_DragInfo = null;
        return;
    }

    var ItemsControl = sender as ItemsControl;

    //-----------------------------------------------------------------------
    FrameworkElement Item = null;
    if (ItemsControl is ListBox || ItemsControl is DataGrid || ItemsControl is TreeView)
    {
        if (ItemsControl is ListBox)
            Item = Utility.FindParent<ListBoxItem>(e.OriginalSource as DependencyObject);
        else if (ItemsControl is DataGrid)
            Item = Utility.FindParent<DataGridRow>(e.OriginalSource as DependencyObject);
        else if (ItemsControl is TreeView)
            Item = Utility.FindParent<TreeViewItem>(e.OriginalSource as DependencyObject);

        if (Item == null)
        {
            m_DragInfo = null;
            return;
        }
    }
    //-----------------------------------------------------------------------

    m_DragInfo = new DragInfo(sender, e);

    var DragHandler = TryGetDragHandler(m_DragInfo, sender as UIElement);
    if (!DragHandler.CanStartDrag(m_DragInfo))
    {
        m_DragInfo = null;
        return;
    }

    // If the sender is a list box that allows multiple selections, ensure that clicking on an 
    // already selected item does not change the selection, otherwise dragging multiple items 
    // is made impossible.
    if ((Keyboard.Modifiers & ModifierKeys.Shift) == 0 && 
             (Keyboard.Modifiers & ModifierKeys.Control) == 0 && 
              m_DragInfo.VisualSourceItem != null && ItemsControl != null && 
              ItemsControl.CanSelectMultipleItems())
    {
        var SelectedItems = ItemsControl.GetSelectedItems().OfType<object>().ToList();
        if (SelectedItems.Count > 1 && SelectedItems.Contains(m_DragInfo.SourceItem))
        {
            m_ClickSupressItem = m_DragInfo.SourceItem;
            e.Handled = true;
        }
    }
}

All we do is check if drag source is a ListBox (because ListView inherits ListBox)DataGrid, or TreeView AND if item under mouse is a ListBoxItem (because ListViewItem inherits ListBoxItem), DataGridRow, or TreeViewItem using e.OriginalSource.

To see if the clicked object is a ListViewItem/DataGridRow/TreeViewItem, we have to perform an upward visual search starting with e.OriginalSource. If the result is null, no ListViewItem/DataGridRows/TreeViewItems (or rather children of) were clicked and since we only want drag/drop behavior when clicking an item, we stop it there.

Note, a couple things:

  • Drag/drop evaluation continues when the drag source is a ListBox/DataGrid/TreeView and the item under the mouse is a ListBoxItem/DataGridRow/TreeViewItem.
  • Drag drop evaluation stops if that is not the case.
  • We have to check the item type individually as we need to know the exact type we're looking for when finding the parent; otherwise, results will be unpredicatable and buggy.

To perform an upward visual search, use this method:

public static T FindParent<T>(DependencyObject child) where T : DependencyObject
{
    DependencyObject ParentObject = VisualTreeHelper.GetParent(child);
    if (ParentObject == null)
        return null;
    T Parent = ParentObject as T;
    if (Parent != null)
        return Parent;
    else
        return FindParent<T>(ParentObject);
}

Points of Interest

  • The ScrollViewer must be the parent container in order for scrolling to function properly.
  • The grid captures all of our mouse events. It must have a transparent background in order to capture the mouse events (or another color, but not no color).
  • The canvas positions the selection element, has and must have no background color (in order for the parent Grid to capture mouse events), and sits on top of the ItemsPresenter.
  • Drag selection logic is wrapped in a class separate from the ItemsControl to make life easier.

History

  • 17th April, 2016
    • Initial post
  • 6th July, 2016
    • Changed underlying class from ListBox to ListView. The new control is CustomListView.
    • Enabled ability to translate points from ScrollContentPresenter instead of parent grid. This enables selecting items located anywhere in ScrollViewer.
    • Changed core template.
  • 8th July, 2016
    • Enabled ability to scroll automatically while dragging.
    • Added two dependency properties to CustomListView:
      • ScrollTolerance
        • A double value that indicates how far from width/height of container to apply offset.
      • ScrollOffset
        • A double value that indicates the offset to apply.
  • 10th July, 2016
    • Added two new dependency properties to CustomListView:
      • ScrollWrap
        • If true, allows moving currently selected item to beginning or end of collection when a directional key is pressed (up/down/left/right). E.g., the left arrow key is pressed while the first item is selected. The last item will get selected. Or the right arrow key is pressed while the last item is selected. The first item will get selected.
      • IsDragSelectionEnabled
        • Allows enabling/disabling the selection rectangle when dragging.
    • Added this.IsSynchronizedWithCurrentItem = true; in constructor. This is necessary for the MakeSelection command to work properly.
    • Added command MakeSelection.
      • Executes when any of the directional keyboard keys (top/bottom/left/right) are pressed.
      • If left or right is pressed, moves current selection to previous or next, respectively.
      • If top or bottom is pressed, it attempts to determine how many items are contained in a row (assuming the view uses a WrapPanel) and calculates a new index based on the selected index.
      • ScrollWrap is taken into account here.
      • Uses 3 new private methods:
        • void MoveLeft
          • Moves current selection to the left
        • void MoveRight
          • Moves current selection to the right
        • void MoveToNextRow
          • Moves current selection to the item directly above/below it
          • If there is only 1 item in each row, MoveLeft and MoveRight behavior is used and MoveToNextRow behavior is ignored
          • When calculating the ListView's actual width, subtracting left and right padding seems to give greater accuracy when determining number of items in a row
          • I observed whenever the item width <= 24, accuracy decreases dramatically
  • 12th July, 2016
    • Changed ScrollOver to ScrollWrap
  • 13th July, 2016
    • Added detailed comments to CustomListView class
    • Enabled ability to drag select items when using GridView (not sure how tbh)
      • The very first item, however, still does not select
      • I first observed this behavior after adding support for finding ScrollContentPresenter automatically, so that could very well have something to do with it
    • Enabled ability to automatically look for ScrollContentPresenter when ListView's ScrollViewer's style/template has changed
      • Each time the ScrollContentPresenter is found, we store a reference to the ScrollViewer's style's hash code. Then, when the ScrollViewer's layout changes, we check if the hash value is null (or hasn't been set yet) and if it's different than the ScrollViewer's current style's hash code. If either are true, we look for new ScrollContentPresenter and, if found, store a reference to the new hash value. To check performance, I inserted code that prints a message to the console whenever a new hash is found. As expected, a new presenter is found when the style changes and ONLY when
  • 2nd August, 2016
    • Tested with GongSolutions.Wpf.DragDrop library
  • 17th August, 2016
    • Added ability to extend drag selection functionality to ListBoxes and DataGrids (originally, only ListViews were supported) by wrapping drag selection logic in a separate class.
    • Designed a control to represent the visual drag selection.
  • 18th August, 2016
    • Drag/drop fix supports ListBox/ListView, DataGrid, and TreeView controls.
  • 20th August, 2016
    • Added async/await to some of the helper methods to improve speed a bit.
  • 24th August, 2016
    • Added ability to automatically scroll left and right while dragging; previously, only vertical scrolling was supported.

Future

The code in this article is now part of the open source project, Imagin.NET

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)

Share

About the Author

James J M
Software Developer Imagin
United States United States
No Biography provided

You may also be interested in...

Pro

Comments and Discussions

 
QuestionSeems quite impressive! Pin
Iftikhar Akram7-Sep-16 3:12
memberIftikhar Akram7-Sep-16 3:12 
QuestionGreat Job Pin
netizenk30-Aug-16 7:14
membernetizenk30-Aug-16 7:14 
QuestionDragSelection Canvas Pin
npsao18-Aug-16 1:00
membernpsao18-Aug-16 1:00 
AnswerRe: DragSelection Canvas Pin
James JM18-Aug-16 3:26
memberJames JM18-Aug-16 3:26 
GeneralRe: DragSelection Canvas Pin
npsao24-Aug-16 4:09
membernpsao24-Aug-16 4:09 
GeneralRe: DragSelection Canvas Pin
James JM24-Aug-16 14:06
memberJames JM24-Aug-16 14:06 
QuestionGood article, but... Pin
VIT_AS15-Jul-16 19:21
memberVIT_AS15-Jul-16 19:21 
PraiseGreat, and a strange coincidence Pin
Member 1040369315-Jul-16 10:15
memberMember 1040369315-Jul-16 10:15 
GeneralRe: Great, and a strange coincidence Pin
James JM15-Jul-16 14:02
memberJames JM15-Jul-16 14:02 
QuestionGreat stuff Pin
Sacha Barber14-Jul-16 8:29
mvpSacha Barber14-Jul-16 8:29 
QuestionNot an article Pin
digimanus12-Jul-16 6:37
memberdigimanus12-Jul-16 6:37 
AnswerRe: Not an article Pin
Mika Wendelius12-Jul-16 6:43
mvpMika Wendelius12-Jul-16 6:43 
AnswerRe: Not an article Pin
Vincent Maverick Durano14-Jul-16 5:46
professionalVincent Maverick Durano14-Jul-16 5:46 
GeneralRe: Not an article Pin
James JM14-Jul-16 7:15
memberJames JM14-Jul-16 7:15 
GeneralRe: Not an article Pin
Vincent Maverick Durano14-Jul-16 7:26
professionalVincent Maverick Durano14-Jul-16 7:26 
GeneralRe: Not an article Pin
RandomBlueThing17-Aug-16 23:40
memberRandomBlueThing17-Aug-16 23:40 
Praisegood article Pin
Prasanna Murali7-Jul-16 4:32
memberPrasanna Murali7-Jul-16 4:32 

General General    News News    Suggestion Suggestion    Question Question    Bug Bug    Answer Answer    Joke Joke    Praise Praise    Rant Rant    Admin Admin   

Use Ctrl+Left/Right to switch messages, Ctrl+Up/Down to switch threads, Ctrl+Shift+Left/Right to switch pages.

Permalink | Advertise | Privacy | Terms of Use | Mobile
Web02 | 2.8.171114.1 | Last Updated 16 Jan 2017
Article Copyright 2016 by James J M
Everything else Copyright © CodeProject, 1999-2017
Layout: fixed | fluid