65.9K
CodeProject 正在变化。 阅读更多。
Home

DraggableListView - 为 WPF 中的 ListView 添加拖动滚动功能

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.88/5 (13投票s)

2008年10月3日

CPOL

4分钟阅读

viewsIcon

109367

downloadIcon

4829

一个自定义的 ListView,用户可以通过拖拽屏幕上的内容来滚动它。

引言

本文介绍了一个派生自 ListView 的自定义控件,用户可以通过拖拽来滚动其内容。它的设计目标是使用户界面更加直观,并且可能非常适合某些触摸屏应用。

演示 EXE 需要安装 .NET 3.5 SP1 的 Windows XP 或 Windows Vista,源代码在 Visual Studio 2008 SP1 下编译和测试通过。

背景

以下是 DraggableListView 的设计目标简述:

  • 隐藏水平和垂直滚动条,并增加新功能,通过跟踪 PreviewMouseDownPreviewMouseMovePreviewMouseUp 鼠标事件来实现滚动。
  • 添加四个新的依赖属性 VerticalCurrentPageVerticalTotalPageHorizontalCurrentPageHorizontalTotalPage,以便在滚动时进行跟踪。由于没有水平或垂直滚动条,没有这四个属性用户可能难以判断位置。
  • 添加算法,用于计算从 PreviewMouseDown 事件到 PreviewMouseUp 事件的拖拽速度。根据该速度,MouseUp 事件之后的滚动应该立即停止、逐渐减速,或者一直滚动直到到达另一端。
  • MouseUp 事件之后的滚动过程中,用户应该能够通过单击鼠标一次来立即停止滚动。
  • 通过双击而不是单击来选择一个项目;这将防止在滚动时发生选择更改。

Using the Code

要使用该控件,您可以简单地将文件 DraggableListView.cs 复制到您的项目中,并包含以下几行:

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

您还可以查看演示源代码,了解如何使用该控件及其属性。

工作原理

在此,我将简要介绍该控件的实际编码方式。

1. 访问 ListView 内的 ScrollViewer

为了以编程方式滚动 ListView 的内容,我们需要获取对 ListView 内部 ScrollViewer 对象的引用。我在 OnApplyTemplate() 函数中通过调用 GetVisualChild() 来实现此目的,如下所示:

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);
    }
}

从上面的代码中,您还可以看到,在我获得对 ScrollViewer 对象的有效引用后,我隐藏了 ScrollBar 并将 CanContentScroll 设置为 false

2. 计算滚动速度的逻辑

为了更好地估算鼠标拖拽速度,我使用了以下局部变量:

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

当用户开始拖拽鼠标时,变量 mouseDownPointmouseDownTime 会被初始保存,而移动点 1 到移动点 3 是鼠标拖拽过程中的最后三个点。如果控件上的鼠标拖拽是直线,我们可以简单地使用 mouseDownPointmouseMovePoint3 来计算滚动速度。但如果用户在控件上之字形拖拽,那么滚动速度的最佳估算就是使用 mouseMovePoint1mouseMovePoint3

以下是我用来估算滚动速度的代码:

// 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. 通过单击鼠标立即停止滚动

MouseUp 事件后的滚动是使用 Storyboard 对象实现的。我在 PreviewMouseDown 事件中添加了逻辑,以检查 Storyboard 对象是否仍在活动。如果是,我将首先暂停该对象,保存 ScrollViewer 当前的水平和垂直偏移量,然后清除动画提供的数值,最后,重新设置水平和垂直偏移量。

您可以在此处查看相关代码。

...
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. 通过双击选择项目

为了实现通过双击而不是单击来选择/取消选择项目,我们需要隐藏 DraggableListView 中由单击鼠标触发的 SelectionChanged 事件,而仅在其他情况(如双击鼠标等)下触发该事件。

我使用局部变量 selectedItemPrivateselectedItemsPrivate 来保存 SelectedItemSelectedItems 属性的副本,以防它们不同步。我还使用了一些其他布尔变量来跟踪不同的情况。

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

OnSelectionChanged() 函数实际上决定了是隐藏 SelectionChanged 事件还是不隐藏。

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);
}

最后,我在 draggableListView_MouseButtonDown()OnPreviewMouseMove()OnPreviewMouseUp() 函数中,使用以下代码将局部副本 selectedItemPrivateselectedItemsPrivate 同步回 SelectedItemSelectedItems 属性。

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

    mayBeOutOfSync = false;
}
...

5. 拖拽到达边缘时的逻辑

当拖拽到达任一边缘时,内容将停止移动,而用户会继续拖拽鼠标。这将导致之前保存的变量 initialOffset 无效,我们需要在 OnPreviewMouseMove() 函数中添加逻辑,以检测何时到达边缘并重新保存所有必要的信息。

...
// 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);
}
...

局限性

引用 MSDN 的话:“如果您需要物理滚动而不是逻辑滚动……请将其 CanContentScroll 属性设置为 false”,因此我在 OnApplyTemplate() 函数中将 CanContentScroll 设置为 false。但是,将 CanContentScroll 设置为 false 也会关闭虚拟化,如果您要显示大量行,这将降低此类的可用性。另外,正因为如此,当您第一次切换到“可编辑 ListView 示例”时,您会注意到一个停顿。

欢迎在您的 WPF 应用程序中使用 DraggableListView。希望您觉得 WPF 和我一样酷!

历史

  • 2008 年 10 月 3 日 - 首次发布。
© . All rights reserved.