DraggableListView - 为 WPF 中的 ListView 添加拖动滚动功能
一个自定义的 ListView,用户可以通过拖拽屏幕上的内容来滚动它。
引言
本文介绍了一个派生自 ListView
的自定义控件,用户可以通过拖拽来滚动其内容。它的设计目标是使用户界面更加直观,并且可能非常适合某些触摸屏应用。
演示 EXE 需要安装 .NET 3.5 SP1 的 Windows XP 或 Windows Vista,源代码在 Visual Studio 2008 SP1 下编译和测试通过。
背景
以下是 DraggableListView
的设计目标简述:
- 隐藏水平和垂直滚动条,并增加新功能,通过跟踪
PreviewMouseDown
、PreviewMouseMove
和PreviewMouseUp
鼠标事件来实现滚动。 - 添加四个新的依赖属性
VerticalCurrentPage
、VerticalTotalPage
、HorizontalCurrentPage
和HorizontalTotalPage
,以便在滚动时进行跟踪。由于没有水平或垂直滚动条,没有这四个属性用户可能难以判断位置。 - 添加算法,用于计算从
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;
...
当用户开始拖拽鼠标时,变量 mouseDownPoint
和 mouseDownTime
会被初始保存,而移动点 1 到移动点 3 是鼠标拖拽过程中的最后三个点。如果控件上的鼠标拖拽是直线,我们可以简单地使用 mouseDownPoint
和 mouseMovePoint3
来计算滚动速度。但如果用户在控件上之字形拖拽,那么滚动速度的最佳估算就是使用 mouseMovePoint1
和 mouseMovePoint3
。
以下是我用来估算滚动速度的代码:
// 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
事件,而仅在其他情况(如双击鼠标等)下触发该事件。
我使用局部变量 selectedItemPrivate
和 selectedItemsPrivate
来保存 SelectedItem
和 SelectedItems
属性的副本,以防它们不同步。我还使用了一些其他布尔变量来跟踪不同的情况。
...
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()
函数中,使用以下代码将局部副本 selectedItemPrivate
和 selectedItemsPrivate
同步回 SelectedItem
和 SelectedItems
属性。
...
// 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 日 - 首次发布。