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

WPF 中的简单拖动选择

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.89/5 (40投票s)

2011年1月20日

CPOL

11分钟阅读

viewsIcon

162068

downloadIcon

12720

介绍了一种在 WPF 中实现拖放选择的技术(附加:还支持多项拖动)。

引言

这是一篇简短的文章,探讨了在 Canvas 或派生自 ItemsControl 的类中实现多项拖放选择的技术。在本文中,我使用 ListBox 来托管可拖放选择的项目。

此外,我还探讨了如何使选定的项目可拖动。这似乎是自然的补充,因为拖动项目通常是在拖放选择它们之后想要做的事情。

屏幕截图

这是示例应用程序的屏幕截图。它显示了正在进行的拖放选择。透明的蓝色选择矩形已被拖出,因此它完全包含紫色矩形,并部分包含绿色矩形。

假设知识

假设您已经了解 C#,并且对 WPFXAML 有基本了解。我将在文章中提供相关资源和信息的链接。

背景与目标

正如我之前提到的,我正在为用于可视化和编辑图形、流程图和网络的自定义控件编写代码和文章。该控件称为 NetworkView。编写 NetworkView 和相关的 CodeProject 文章变成了一项艰巨的任务。我在创建 NetworkView 时学习了 WPF,而 WPF 的学习曲线很陡峭。此外,我渴望通用化和完善控件的驱动力往往导致进展缓慢。最近,我决定正在进行中的文章已经太大了,我计划将其分解成一些较小的文章,探讨一些可以独立研究的技术。实际上,我已经在我的 前两篇文章中隐式地这样做了。但是,我之前的文章和这篇文章已明确地从未来的 NetworkView 文章中提取出来,以便使该文章更小。

在我的流程图编辑器中,节点放置在 Canvas 的任意位置,用户可以自由地将节点拖动到任意位置。我使用了 ListBox,因为它是呈现可选对象列表的便捷方法。显然,在这些情况下,它看起来并不像普通的 ListBox。这是因为它的 ItemsPanel 已被用来将默认的 StackPanel 替换为 Canvas。这允许每个 ListBoxItem 定位在特定位置。对于像这样的控件,似乎很自然地期望可选项目可以被拖放选择。当多个节点被选中时,可以一次性对所有节点应用单个操作,例如删除节点复制节点。即使是简单的拖动节点的操作,也可以应用于多个选定的节点。

实现拖放选择

在 Visual Studio 中打开 DragSelectionSampleCode.zip 中的 SampleCode.sln(我使用的是 VS2008)。构建并运行示例应用程序。主窗口右侧的“请阅读!”窗口描述了您可以与示例应用程序交互的各种方式。

视图模型和演示逻辑与我之前的文章非常相似,因此我在此不再赘述。本文中我们主要关注的是多项拖放选择。您可以通过在空白区域按住鼠标左键并拖出选择矩形来在示例应用程序中完成此操作。这是一个显而易见的 UI 设备,选择矩形允许您标记要选择的项目,然后释放鼠标左键,这些项目就会被选中。

在开始讲解拖放选择功能的演练之前,先简单介绍一下基本的项目选择。

基本项目选择

我使用 ListBox 来显示颜色矩形,因为我想重用它的项目选择逻辑。如 MainWindow.xaml 中所示,ItemSource 属性已绑定到视图模型中的 Rectangles 属性。

<ListBox
    x:Name="listBox"
    ItemsSource="{Binding Rectangles}"
    SelectionMode="Extended"
    >
    ...
</ListBox>

SelectionMode 设置为 Extended,以允许 ListBox 中的多项选择。多项选择显然是拖放选择的要求。

在示例项目中,我没有添加任何代码来实现标准的项目选择。由于使用了 ListBox,标准的项目选择可以正常工作。例如,您可以单击一个矩形来选择它。您可以按住 Control 键并单击以切换矩形的选定状态。您甚至可以使用 Control + A 选择所有矩形。免费获得东西总是很棒的;)

拖放选择

现在让我们来看看拖放选择

选择矩形的视觉效果在 MainWindow.xaml 中定义。

<!--
This Canvas and Border are used as a very simple way 
to render a drag selection rectangle.
-->
<Canvas
    x:Name="dragSelectionCanvas"
    Visibility="Collapsed"
    >
    <Border 
        x:Name="dragSelectionBorder"
        BorderBrush="Blue"
        BorderThickness="1"
        Background="LightBlue"
        CornerRadius="1"
        Opacity="0.5"
        />
</Canvas>

拖放选择画布包含拖放选择边框,两者都覆盖在 ListBox 之上。VisibilityCanvas 最初设置为 Collapsed,这意味着它默认是不可见的。当开始拖放选择时,Canvas 会变得可见,从而显示拖放选择边框,该边框直观地表示选择矩形。随着用户拖动选择矩形Border 的位置和大小会不断更新。当拖放选择完成时,拖放选择画布会恢复到其默认的不可见状态,并应用所需的选定。

为了实现拖放选择,我们需要挂钩主窗口的鼠标事件。用户按下鼠标按钮时会调用 MainWindow.xaml.cs 中的 Window_MouseDown。在这里,我们只需跟踪鼠标是否被按下,并捕获鼠标,以便将来所有鼠标事件都由主窗口处理。

private void Window_MouseDown(object sender, MouseButtonEventArgs e)
{
    if (e.ChangedButton == MouseButton.Left)
    {
        isLeftMouseButtonDownOnWindow = true;
        origMouseDownPoint = e.GetPosition(this);

        this.CaptureMouse();

        e.Handled = true;
    }
}

当用户将鼠标光标移到主窗口上时,会调用 Window_MouseMove。它负责在按下鼠标左键且鼠标光标移动距离超过阈值时启动拖放选择。它会继续更新用户拖动时的选择矩形

该函数分为两个条件部分。第一部分仅在拖放选择已在进行时执行。第二部分在按下鼠标左键但拖放选择尚未开始时执行。

这里有一个概述

private void Window_MouseMove(object sender, MouseEventArgs e)
{
    if (isDraggingSelectionRect)
    {
        //
        // Drag selection already initiated,
        // handle drag selection in progress.
        //
        ...
    }
    else if (isLeftMouseButtonDownOnWindow)
    {
        //
        // Drag selection not yet initiated,
        // but the left mouse-button is held down.
        //
        ...
    }
}

首先,让我们看一下第二个条件,并检查启动拖放选择的逻辑。当前鼠标点会与按下鼠标左键时记录的点进行比较。当鼠标光标移动距离超过阈值时,定义选择矩形初始区域的两个点将被传递给 InitDragSelectionRect

private void Window_MouseMove(object sender, MouseEventArgs e)
{
    if (isDraggingSelectionRect)
    {
        ...
    }
    else if (isLeftMouseButtonDownOnWindow)
    {
        //
        // The user is left-dragging the mouse,
        // but don't initiate drag selection until
        // they have dragged past the threshold value.
        //
        Point curMouseDownPoint = e.GetPosition(this);
        var dragDelta = curMouseDownPoint - origMouseDownPoint;
        double dragDistance = Math.Abs(dragDelta.Length);
        if (dragDistance > DragThreshold)
        {
            //
            // When the mouse has been dragged more than
            // the threshold value commence drag selection.
            //
            isDraggingSelectionRect = true;

            //
            //  Clear selection immediately
            //  when starting drag selection.
            //
            listBox.SelectedItems.Clear();

            InitDragSelectionRect(origMouseDownPoint, curMouseDownPoint);
        }

        e.Handled = true;
    }
}

InitDragSelectionRect 非常简单,它只是转发到 UpdateDragSelectionRect(我们稍后会看),并且还会使拖放选择画布可见。

private void InitDragSelectionRect(Point pt1, Point pt2)
{
    UpdateDragSelectionRect(pt1, pt2);

    dragSelectionCanvas.Visibility = Visibility.Visible;
}

回到 Window_MouseMove,让我们看一下第一个条件部分。这是在拖放选择进行时更新选择矩形的逻辑。定义选择矩形更新区域的两个点被传递给 UpdateDragSelectionRect

private void Window_MouseMove(object sender, MouseEventArgs e)
{
    if (isDraggingSelectionRect)
    {
        //
        // Drag selection is in progress.
        //
        Point curMouseDownPoint = e.GetPosition(this);
        UpdateDragSelectionRect(origMouseDownPoint, curMouseDownPoint);

        e.Handled = true;
    }
    else if (isLeftMouseButtonDownOnWindow)
    {
        ...
    }
}

UpdateDragSelectionRect 本质上很简单。它计算选择矩形的位置和大小,并更新拖放选择边框

private void UpdateDragSelectionRect(Point pt1, Point pt2)
{
    double x, y, width, height;

    //
    // Determine x,y,width and height
    // of the rect inverting the points if necessary.
    // 
    
    ...

    //
    // Update the coordinates of the rectangle used for drag selection.
    //
    Canvas.SetLeft(dragSelectionBorder, x);
    Canvas.SetTop(dragSelectionBorder, y);
    dragSelectionBorder.Width = width;
    dragSelectionBorder.Height = height;
}

我省略了 UpdateDragSelectionRect 中间用来根据 pt1pt2 的相对位置确定选择矩形的位置和大小的代码块。该代码会根据需要反转 pt1pt2,以防止我们计算出负宽度和高度的选择矩形

我们现在已经了解了拖放选择是如何启动的,以及在用户拖动时选择矩形是如何连续更新的。现在我们将看看拖放选择是如何完成并应用的。

当用户释放鼠标按钮时会调用 Window_MouseUp。如果用户已经拖出了选择矩形,则调用 ApplyDragSelection 来应用选择。它会选择包含在选择矩形内的所有项目。但是,如果用户没有调用拖放选择,例如,如果他们只是单击并释放而没有拖动超过阈值,那么除了释放鼠标捕获并重置状态变量之外,没有其他事情可做。

private void Window_MouseUp(object sender, MouseButtonEventArgs e)
{
    if (e.ChangedButton == MouseButton.Left)
    {
        if (isDraggingSelectionRect)
        {
            //
            // Drag selection has ended, apply the 'selection rectangle'.
            //

            isDraggingSelectionRect = false;
            ApplyDragSelectionRect();

            e.Handled = true;
        }

        if (isLeftMouseButtonDownOnWindow)
        {
            isLeftMouseButtonDownOnWindow = false;
            this.ReleaseMouseCapture();

            e.Handled = true;
        }
    }
}

ApplyDragSelectionRect 是这里的真正工作核心。首先,它隐藏拖放选择画布。这使得选择矩形恢复到其默认的不可见状态。

private void ApplyDragSelectionRect()
{
    dragSelectionCanvas.Visibility = Visibility.Collapsed;

    ...
}

接下来,从拖放选择边框读回选择矩形的位置和大小。

private void ApplyDragSelectionRect()
{
    ...

    double x = Canvas.GetLeft(dragSelectionBorder);
    double y = Canvas.GetTop(dragSelectionBorder);
    double width = dragSelectionBorder.Width;
    double height = dragSelectionBorder.Height;
    Rect dragRect = new Rect(x, y, width, height);

    //
    // Inflate the drag selection-rectangle by 1/10 of its size to 
    // make sure the intended item is selected.
    //
    dragRect.Inflate(width / 10, height / 10);
    
    ...
}

我应该解释一下为什么选择矩形会人为地放大其大小的 1/10。我本可以选择使此逻辑选择与选择矩形重叠的所有矩形。如果我那样做了,那么矩形放大就不需要了。

然而,在尝试了拖放选择之后,我很清楚它的行为应该是只选择位于选择矩形内的矩形,而不仅仅是与之相交。为此,用户必须拖动选择矩形,使其完全包围他们想要选择的矩形。

在测试了这种新逻辑后,我意识到拖出选择矩形但由于几像素的误差而错过选择某个特定矩形可能会令人沮丧。添加矩形放大是为了应对这个问题,并作为一种小小的用户辅助功能来帮助他们进行选择。

现在,为了准备选择新项目,将清除当前选择。

private void ApplyDragSelectionRect()
{
    ...

    listBox.SelectedItems.Clear();

    ...
}

最后,所有矩形都会与选择矩形进行比较。包含在(放大的)矩形内的矩形将被添加到选集中。

private void ApplyDragSelectionRect()
{
    ...

    foreach (RectangleViewModel rectangleViewModel 
                      in this.ViewModel.Rectangles)
    {
        Rect itemRect = new Rect(rectangleViewModel.X, 
                            rectangleViewModel.Y, 
                            rectangleViewModel.Width, 
                            rectangleViewModel.Height);
        if (dragRect.Contains(itemRect))
        {
            listBox.SelectedItems.Add(rectangleViewModel);
        }
    }
}

这完成了我们对如何实现拖放选择的介绍。让我们继续下一个示例项目,看看如何实现拖动多个选定的项目。

实现多项拖动

在 Visual Studio 中打开 DragMultipleSampleCode.zip 中的 SampleCode.sln。构建并运行示例应用程序。与上一个示例项目一样,“请阅读!”窗口描述了示例应用程序支持的功能和交互。

在这个示例中,我们探讨了如何实现拖动多个选定的项目。实现多项拖放选择但未实现多项拖动似乎是浪费机会!

关键在于处理颜色矩形的鼠标事件,这些事件允许用户单击并拖动它们。如果您查看 MainWindow.xamlRectangleViewModel 的数据模板,您可以看到事件是如何被挂钩的。

<Rectangle
    ...
    MouseDown="Rectangle_MouseDown"
    MouseUp="Rectangle_MouseUp"
    MouseMove="Rectangle_MouseMove"
    />

在我们继续之前,让我们先解决一个问题。此时,您可能会想,为什么我不使用 WPF 的 Thumb 控件。Thumb 提供了一个方便的 DragDelta 事件,使得在 WPF 中实现可拖动 UI 元素变得非常简单。这里的问题是 Thumb 实际上过于简单。Thumb 处理自己的鼠标事件,我们无法处理它们。如果您只需要简单的可拖动 UI 元素,那么 Thumb 可以很好地为您完成工作。由于 Thumb 覆盖了其鼠标处理,因此 ListBox 的鼠标处理逻辑被抑制,我们也就无法获得 ListBox 的鼠标单击选择逻辑。为了同时拥有选择逻辑和拖动逻辑,我们需要自己处理鼠标事件。幸运的是,正如我希望您将看到的,实现拖动和选择逻辑并不比仅仅使用 Thumb 难多少。

处理鼠标事件的代码应该对您来说很熟悉。它与主窗口的鼠标事件处理程序非常相似,这些处理程序实现了选择矩形的拖动。Rectangle_MouseDown 记录鼠标按钮是否被按下并捕获鼠标。它还会执行一些选择逻辑,尤其是在未按下 Control 键时。当按下 Control 键时,我们只需跟踪这一点,然后将选择逻辑留给 Window_MouseUp 处理。

private void Rectangle_MouseDown(object sender, MouseButtonEventArgs e)
{
    if (e.ChangedButton != MouseButton.Left)
    {
        return;
    }

    var rectangle = (FrameworkElement)sender;
    var rectangleViewModel = (RectangleViewModel)rectangle.DataContext;

    isLeftMouseDownOnRectangle = true;

    if ((Keyboard.Modifiers & ModifierKeys.Control) != 0)
    {
        //
        // Control key was held down.
        // This means that the rectangle is being added
        // to or removed from the existing selection.
        // Don't do anything yet, we will act on this
        // later in the MouseUp event handler.
        //
        isLeftMouseAndControlDownOnRectangle = true;
    }
    else
    {
        //
        // Control key is not held down.
        //
        isLeftMouseAndControlDownOnRectangle = false;

        if (this.listBox.SelectedItems.Count == 0)
        {
            //
            // Nothing already selected, select the item.
            //
            this.listBox.SelectedItems.Add(rectangleViewModel);
        }
        else if (this.listBox.SelectedItems.Contains(rectangleViewModel))
        {
            // 
            // Item is already selected, do nothing.
            // We will act on this in the MouseUp
            // if there was no drag operation.
            //
        }
        else
        {
            //
            // Item is not selected.
            // Deselect all, and select the item.
            //
            this.listBox.SelectedItems.Clear();
            this.listBox.SelectedItems.Add(rectangleViewModel);
        }
    }

    rectangle.CaptureMouse();
    origMouseDownPoint = e.GetPosition(this);

    e.Handled = true;
}

Rectangle_MouseMove 负责启动拖动操作,在此之后,它会连续更新正在拖动的项目的位置。

Rectangle_MouseMoveWindow_MouseMove 一样,包含两个条件部分。第一部分在拖动已在进行时执行。第二部分在按下鼠标左键但拖动尚未开始时执行。

这里有一个概述

private void Rectangle_MouseMove(object sender, MouseEventArgs e)
{
    if (isDraggingRectangle)
    {
        //
        // Drag-move selected rectangles.
        //
        ...
    }
    else if (isLeftMouseDownOnRectangle)
    {
        //
        // The user is left-dragging the rectangle,
        // but don't initiate the drag operation until
        // the mouse cursor has moved more than the threshold value.
        //
        ...
    }
}

当鼠标光标移动距离超过阈值时,第二个条件部分会启动拖动。

private void Rectangle_MouseMove(object sender, MouseEventArgs e)
{
    if (isDraggingRectangle)
    {
        ...
    }
    else if (isLeftMouseDownOnRectangle)
    {
        //
        // The user is left-dragging the rectangle,
        // but don't initiate the drag operation until
        // the mouse cursor has moved more than the threshold value.
        //
        Point curMouseDownPoint = e.GetPosition(this);
        var dragDelta = curMouseDownPoint - origMouseDownPoint;
        double dragDistance = Math.Abs(dragDelta.Length);
        if (dragDistance > DragThreshold)
        {
            //
            // When the mouse has been dragged more than
            // the threshold value commence dragging the rectangle.
            //
            isDraggingRectangle = true;
        }

        e.Handled = true;
    }
}

第一个条件部分在拖动进行时移动矩形。

private void Rectangle_MouseMove(object sender, MouseEventArgs e)
{
    if (isDraggingRectangle)
    {
        //
        // Drag-move selected rectangles.
        //
        Point curMouseDownPoint = e.GetPosition(this);
        var dragDelta = curMouseDownPoint - origMouseDownPoint;

        origMouseDownPoint = curMouseDownPoint;

        foreach (RectangleViewModel rectangle in this.listBox.SelectedItems)
        {
            rectangle.X += dragDelta.X;
            rectangle.Y += dragDelta.Y;
        }
    }
    else if (isLeftMouseDownOnRectangle)
    {
        ...
    }
}

在每次调用 Rectangle_MouseMove 时,移动每个矩形的偏移量是通过当前调用 Rectangle_MouseMove 的鼠标点与上一次调用的鼠标点之间的计算出的增量距离确定的。每次循环时,origMouseDownPoint 都设置为当前鼠标点,因为我们只关心自上次调用以来的拖动距离。

Rectangle_MouseUp 主要用于释放鼠标捕获和停止拖动操作。一部分选择逻辑也在这里发生,但仅在用户实际上没有拖动任何内容时。此示例项目中使用的多项选择规则源自 Windows Explorer 中使用的多项选择规则。

private void Rectangle_MouseUp(object sender, MouseButtonEventArgs e)
{
    if (isLeftMouseDownOnRectangle)
    {
        var rectangle = (FrameworkElement)sender;
        var rectangleViewModel = (RectangleViewModel)rectangle.DataContext;

        if (!isDraggingRectangle)
        {
            //
            // Execute mouse up selection logic
            // only if there was no drag operation.
            //
            if (isLeftMouseAndControlDownOnRectangle)
            {
                //
                // Control key was held down.
                // Toggle the selection.
                //
                if (this.listBox.SelectedItems.Contains(rectangleViewModel))
                {
                    //
                    // Item was already selected,
                    // control-click removes it from the selection.
                    //
                    this.listBox.SelectedItems.Remove(rectangleViewModel);
                }
                else
                {
                    // 
                    // Item was not already selected,
                    // control-click adds it to the selection.
                    //
                    this.listBox.SelectedItems.Add(rectangleViewModel);
                }
            }
            else
            {
                //
                // Control key was not held down.
                //
                if (this.listBox.SelectedItems.Count == 1 &&
                    this.listBox.SelectedItem == rectangleViewModel)
                {
                    //
                    // The item that was clicked is already the only selected item.
                    // Don't need to do anything.
                    //
                }
                else
                {
                    //
                    // Clear the selection and select
                    // the clicked item as the only selected item.
                    //
                    this.listBox.SelectedItems.Clear();
                    this.listBox.SelectedItems.Add(rectangleViewModel);
                }
            }
        }

        rectangle.ReleaseMouseCapture();
        isLeftMouseDownOnRectangle = false;
        isLeftMouseAndControlDownOnRectangle = false;

        e.Handled = true;
    }

    isDraggingRectangle = false;
}

这是我们对第二个示例项目的回顾的结束,也是本文的结束。我们讨论了多项拖动以及如何通过在 Rectangle 鼠标事件的处理程序中实现选择和拖动逻辑来实现这一点。

结论

在本文中,我介绍了一种简单的方法,我用它来拖放选择 CanvasListBox 中的项目。此外,我还展示了如何实现多项拖动。

更新

  • 20/01/2011 - 文章首次发布。
© . All rights reserved.