Click here to Skip to main content
65,938 articles
CodeProject is changing. Read more.
Articles
(untagged)

DraggableListView - Adding drag-to-scroll functionality to ListView in WPF

0.00/5 (No votes)
3 Oct 2008 2  
A custom ListView that scrolls when its contents are dragged on screen.

Introduction

This article describes a ListView derived custom control within which users can drag to scroll its content. It is designed to make user interfaces more intuitive, and it could be a good fit for some touch screen applications.

The demo EXE requires either Windows XP or Windows Vista with .NET 3.5 SP1 installed, and the source code was compiled and tested against Visual Studio 2008 SP1.

Background

Here is a brief description of the design goals for DraggableListView:

  • Hide horizontal and vertical scrollbars, and add new functionality to allow scrolling by tracking the PreviewMouseDown, PreviewMouseMove, and PreviewMouseUp mouse events.
  • Add four new Dependency Properties VerticalCurrentPage, VerticalTotalPage, HorizontalCurrentPage, and HorizontalTotalPage to help tracking while scrolling. Since there is no horizontal or vertical scrollbars, it may be hard for users to tell the whereabouts without these four properties.
  • Add algorithm to calculate dragging speed from the PreviewMouseDown event to the PreviewMouseUp event. Based on that speed, scrolling after the MouseUp event should be either stopping immediately, gradually slowing down, or continuing to scroll until it reaches the other end.
  • While scrolling after the MouseUp event, the user should be able to stop scrolling immediately with a single mouse click.
  • Select an item by double-clicking instead of single-clicking; this will prevent selection changes while scrolling.

Using the Code

To use the control, you can simply copy the file DraggableListView.cs into your own project and include the following lines:

xmlns:slv="clr-namespace:DraggableListViewLib"
...
<slv:DraggableListView>
...
</slv:DraggableListView>
...

Also, you can check the demo source code on how to use the control and set its properties.

How it Works

Here, I will briefly describe how this control is actually coded:

1. Access the ScrollViewer inside ListView

In order to programmatically scroll the content of a ListView, we need to have a reference to the ScrollViewer object inside the ListView. I did this in the function OnApplyTemplate() by calling GetVisualChild(), as follows:

public override void OnApplyTemplate()
{
    base.OnApplyTemplate();

    // Setup listViewScrollViewer
    DependencyObject border = GetTemplateChild("Bd");
    listViewScrollViewer = GetVisualChild<ScrollViewer>(border);
    if (listViewScrollViewer != null)
    {
        // Diable the ScrollBars. Setting CanContentScroll to false disables
        // default scrolling behavior which uses a VirtualizingStackPanel.
        listViewScrollViewer.HorizontalScrollBarVisibility = ScrollBarVisibility.Hidden;
        listViewScrollViewer.VerticalScrollBarVisibility = ScrollBarVisibility.Hidden;
        listViewScrollViewer.CanContentScroll = false;

        // Check for page updates if ScrollChanged event fires
        listViewScrollViewer.ScrollChanged += 
          new ScrollChangedEventHandler(listViewScrollViewer_ScrollChanged);
    }
}

From the code above, you can also see that I hide the ScrollBars and set CanContentScroll to false after I have a valid reference to the ScrollViewer object.

2. Logic to calculate the scrolling speed

In order to best estimate the mouse dragging speed, I use the following local variables:

...
private Point mouseDownPoint;
private Point mouseMovePoint1;
private Point mouseMovePoint2;
private Point mouseMovePoint3;
private DateTime mouseDownTime;
private DateTime mouseMoveTime1;
private DateTime mouseMoveTime2;
private DateTime mouseMoveTime3;
...

The variables mouseDownPoint and mouseDownTime are initially saved when the user starts to drag the mouse, and move point 1 to move point 3 are the last three points during a mouse drag movement. If a mouse drag on the control is like a straight line, we can calculate the scrolling speed simply using mouseDownPoint and mouseMovePoint3. But if the user zigzagged on the control, the best estimate for the scrolling speed is to use mouseMovePoint1 and mouseMovePoint3.

Here are the lines of code I use to estimate the scrolling speed:

// Estimate scroll speed
double speed = 0.0, speedX = 0.0, speedY = 0.0, speed1 = 0.0, speed2 = 0.0;
double mouseScrollTime = 0.0;
double mouseScrollDistance = 0.0;
Direction leftOrRight, upOrDown;

if (mouseMoveTime1 != mouseDownTime && mouseMoveTime2 != 
    mouseDownTime && mouseMoveTime3 != mouseDownTime)
{
    // Case 1: estimate speed based on Point1 and Point3
    mouseScrollTime = mouseMoveTime3.Subtract(mouseMoveTime1).TotalSeconds;
    mouseScrollDistance = 
        Math.Sqrt(Math.Pow(mouseMovePoint3.X - mouseMovePoint1.X, 2.0) +
        Math.Pow(mouseMovePoint3.Y - mouseMovePoint1.Y, 2.0));
    if (mouseScrollTime != 0 && mouseScrollDistance != 0)
        speed1 = mouseScrollDistance / mouseScrollTime;
    
    // Case 2: estimate speed based on mouseDownPoint and Point3
    mouseScrollTime = mouseMoveTime3.Subtract(mouseDownTime).TotalSeconds;
    mouseScrollDistance = 
        Math.Sqrt(Math.Pow(mouseMovePoint3.X - mouseDownPoint.X, 2.0) +
        Math.Pow(mouseMovePoint3.Y - mouseDownPoint.Y, 2.0));

    if (mouseScrollTime != 0 && mouseScrollDistance != 0)
        speed2 = mouseScrollDistance / mouseScrollTime;

    // Pick the larger of speed1 and speed2, which is more likely to be correct
    // also, calculate moving direction and speed on X and Y axles
    if (speed1 > speed2)
    {
        speed = speed1;
        leftOrRight = (mouseMovePoint3.X > mouseMovePoint1.X) ? 
                       Direction.Left : Direction.Right;
        upOrDown = (mouseMovePoint3.Y > mouseMovePoint1.Y) ? 
                    Direction.Up : Direction.Down;
        speedX = Math.Abs(mouseMovePoint3.X - mouseMovePoint1.X) / 
                 mouseMoveTime3.Subtract(mouseMoveTime1).TotalSeconds;
        speedY = Math.Abs(mouseMovePoint3.Y - mouseMovePoint1.Y) / 
                 mouseMoveTime3.Subtract(mouseMoveTime1).TotalSeconds;
    }
    else
    {
        speed = speed2;
        leftOrRight = (mouseMovePoint3.X > mouseDownPoint.X) ? 
                       Direction.Left : Direction.Right;
        upOrDown = (mouseMovePoint3.Y > mouseDownPoint.Y) ? 
                    Direction.Up : Direction.Down;
        speedX = Math.Abs(mouseMovePoint3.X - mouseDownPoint.X) / 
                 mouseMoveTime3.Subtract(mouseDownTime).TotalSeconds;
        speedY = Math.Abs(mouseMovePoint3.Y - mouseDownPoint.Y) / 
                 mouseMoveTime3.Subtract(mouseDownTime).TotalSeconds;
    }
...

3. Stop scrolling immediately with a mouse click

Scrolling after the MouseUp event is implemented using a Storyboard object. I added logic in the PreviewMouseDown event to check whether the Storyboard object is still active. If it is, I will first pause the object, save the ScrollViewer's current horizontal and vertical offsets, then clear out the value provided by the animation, and finally, set back the horizontal and vertical offsets.

You can check the relevant code here:

...
if (storyboard != null)
{
    // Pause any storyboard if still active
    if (storyboard.GetCurrentState(this) == ClockState.Active)
    {
        storyboard.Pause(this);
    }
    // Save the current horizontal and vertical offset
    double tempHorizontalOffset = listViewScrollViewer.HorizontalOffset;
    double tempVerticalOffset = listViewScrollViewer.VerticalOffset;
    // Clear out the value provided by the animation
    this.BeginAnimation(DraggableListView.ScrollViewerHorizontalOffsetProperty, null);
    // Set the current horizontal offset back
    listViewScrollViewer.ScrollToHorizontalOffset(tempHorizontalOffset);
    // Clear out the value provided by the animation
    this.BeginAnimation(DraggableListView.ScrollViewerVerticalOffsetProperty, null);
    // Set the current vertical offset back
    listViewScrollViewer.ScrollToVerticalOffset(tempVerticalOffset);

    storyboard = null;
}
...

4. Select an item by double-click

In order to achieve selecting/unselecting an item by double-click instead of single-click, we need to hide the SelectionChanged event for single mouse click from DraggableListView and only fire the event caused by other situations like double mouse click, etc.

I use the local variables selectedItemPrivate and selectedItemsPrivate to keep a copy of the SelectedItem and SelectedItems properties in case they get out of synchronization. I also use a few other boolean variables to keep track of the different situations.

...
private Object selectedItemPrivate = null;
private ArrayList selectedItemsPrivate = null;
private bool isMouseDoubleClick = false;
private bool isMouseDown = false;
private bool mayBeOutOfSync = false;
...

The function OnSelectionChanged() actually decides whether to hide the SelectionChanged event or not:

protected override void OnSelectionChanged(SelectionChangedEventArgs e)
{
    if (isMouseDown == true && isMouseDoubleClick == false)
    {
        // This may be one of the following cases:
        // 1) mouse single click
        // 2) mouse drag from one item to next
        mayBeOutOfSync = true;

        e.Handled = true;
    }
    else
    {
        // This can be one of the following cases:
        // 1) mouse double click (isMouseDown == true and isMouseDoubleClick == true)
        // 2) keyboard navigation or SelectionMode change, etc.
        // (isMouseDown == false and isMouseDoubleClick == false)
        // 3) (isMouseDown == false and isMouseDoubleClick == true) Not possible
        // Sync with the local copy now
        this.selectedItemPrivate = this.SelectedItem;
        if (this.SelectedItems != null)
            this.selectedItemsPrivate = new ArrayList(this.SelectedItems);
        else
            this.selectedItemsPrivate = null;
    }

    base.OnSelectionChanged(e);
}

And finally, I synchronize the local copy of selectedItemPrivate and selectedItemsPrivate back to the SelectedItem and SelectedItems properties in the functions draggableListView_MouseButtonDown(), OnPreviewMouseMove(), and OnPreviewMouseUp(), with the following lines of code.

...
// Sync selected Item(s)
if (mayBeOutOfSync)
{
    if (this.SelectionMode == SelectionMode.Single)
    {
        this.SelectedItem = this.selectedItemPrivate;
    }
    else
        SetSelectedItems(selectedItemsPrivate);

    mayBeOutOfSync = false;
}
...

5. Logic when dragging reaches an edge

When dragging reaches either edge, the content will stop moving while the user continues to drag the mouse. This will cause the previously saved variable initialOffset to be invalid, and we need logic in the function OnPreviewMouseMove() to detect whenever we reach an edge and re-save all the necessary information.

...
// Calculate the delta from mouseDownPoint
Point delta = new Point(this.mouseDownPoint.X - mouseCurrentPoint.X,
      this.mouseDownPoint.Y - mouseCurrentPoint.Y);

// If scrolling reaches either edge, save this as a new starting point
if ((listViewScrollViewer.ScrollableHeight > 0 &&
    ((listViewScrollViewer.VerticalOffset == 0 && delta.Y < 0) ||
    (listViewScrollViewer.VerticalOffset == 
         listViewScrollViewer.ScrollableHeight && delta.Y > 0))) ||
    listViewScrollViewer.ScrollableWidth > 0 &&
    ((listViewScrollViewer.HorizontalOffset == 0 && delta.X < 0) ||
    (listViewScrollViewer.HorizontalOffset == 
         listViewScrollViewer.ScrollableWidth && delta.X > 0)))
{
     // Save the HorizontalOffset and VerticalOffset
     initialOffset.X = listViewScrollViewer.HorizontalOffset;
     initialOffset.Y = listViewScrollViewer.VerticalOffset;

     // Initialize Point1 Point2, and Point3, and set mouseDownPoint
     mouseDownPoint = mouseMovePoint1 = mouseMovePoint2 = 
                      mouseMovePoint3 = e.GetPosition(this);
     mouseDownTime = mouseMoveTime1 = 
                     mouseMoveTime2 = mouseMoveTime3 = DateTime.Now;
}
else
{
     // Scroll the ScrollViewer
     listViewScrollViewer.ScrollToHorizontalOffset(this.initialOffset.X + delta.X);
     listViewScrollViewer.ScrollToVerticalOffset(this.initialOffset.Y + delta.Y);
}
...

Limitation

Quote from MSDN: "If you require physical scrolling instead of logical scrolling... set its CanContentScroll property to false", and so I set CanContentScroll to false in the function OnApplyTemplate(). But setting CanContentScroll to false also turns off virtualizing, which makes this class less useful if you have lots of rows to display. Also because of this, you will notice there is a pause when you switch to the "Editable ListView Sample" for the first time.

Feel free to use the DraggableListView in your WPF applications. I hope you find WPF as cool as I do.

History

  • Oct. 03, 2008 - Initial release.

License

This article has no explicit license attached to it but may contain usage terms in the article text or the download files themselves. If in doubt please contact the author via the discussion board below.

A list of licenses authors might use can be found here