拖动 Canvas 中的元素






4.98/5 (92投票s)
讨论了一个类,该类可在 WPF Canvas 中自动拖动元素。
引言
最近有人问我如何创建一个 WPF 应用程序,让用户可以在屏幕上拖动一个蓝色的矩形。我解决了这个难题,但一直在泛化解决方案,直到能够拖动任何 UIElement
(包括 Button
、Image
、ComboBox
、Grid 等)。本文介绍了一个名为 DragCanvas
的类,它派生自 Canvas
。它允许用户拖动放置在其内部的对象。DragCanvas
类还支持修改其中元素的 Z 顺序(例如,“置于顶层”和“置于底层”操作)。
此类可能的实际应用包括类似 Visio 的便笺簿工作区,用户可以完全自由地决定视觉对象之间的相对位置。自定义视觉设计器场景也可以受益于此功能。
此代码是针对 .NET Framework 3.0 的 2006 年 6 月 CTP 版本编写和测试的。
背景
虽然本文无意评测 WPF 布局系统,但回顾标准 Canvas
类公开的内容及其用法非常重要。WPF 中的大多数面板都提供自动布局支持,例如 DockPanel
的停靠行为,或 WrapPanel
的项换行行为。这种功能对许多 WinForms 和 MFC 开发人员来说是个福音,因为他们过去在自动布局管理方面的支持非常有限。
Canvas
是 WPF 布局系统中唯一不提供任何自动布局支持的面板。Canvas
面板的目的是提供绝对定位,类似于将控件放在 WinForms Panel
上。Canvas
中的元素永远不会被 Canvas
移动或调整大小。开发人员发现 Canvas
在视觉对象的确切大小和位置不应因窗口大小调整等因素而改变的情况下很有用。
Canvas
中的元素可以通过四个附加属性指定其与 Canvas
边缘的偏移量:Left
、Right
、Top
和 Bottom
。如果您不熟悉附加属性,可能需要阅读我讨论它们的详细博客文章。这些属性的值表示元素相对于 Canvas
两条边的位置。例如,以下 XAML 声明了一个 Button
,它距离其包含的 Canvas
的左边缘 10 个逻辑像素,距离顶部边缘 20 个逻辑像素。
<Button Canvas.Left="10" Canvas.Top="20">Click Me</Button>
如果元素的 P
属性设置为 N,则该元素将始终距离 P
边缘 N 个逻辑像素。请注意,如果同一元素指定了 Top
和 Bottom
或 Left
和 Right
附加属性,则将优先使用 Top
和 Left
值,而忽略 Bottom
和 Right
值。
下图描绘了这四个附加属性如何影响 Canvas
中的元素。下面看到的蓝色框包含在 Canvas
中,它们的位置在其中指定。请注意,如果元素上未指定其中一个附加属性,则默认值为 Double.NaN
。
使用 DragCanvas
使用 DragCanvas
很简单。您只需在 XAML 中创建一个类的实例,然后将元素放置在其内部,就像您为普通 Canvas
所做的那样。
<jas:DragCanvas>
<Button Canvas.Left="25" Canvas.Top="50">Click Me!</Button>
<Rectangle
Fill="Blue"
Width="50" Height="50"
Canvas.Right="30"
Canvas.Bottom="40" />
</jas:DragCanvas>
默认情况下,DragCanvas
管理其 Children
集合中每个元素的拖动。如果您的应用程序要求 DragCanvas
内的元素不可拖动,您可以将 AllowDragging
属性设置为 false
。
// Prevent the user from dragging elements in the DragCanvas.
this.dragCanvas.AllowDragging = false;
如果您想阻止用户拖动 DragCanvas
中的特定元素,您可以使用 CanBeDragged
附加属性来实现此目的。
<jas:DragCanvas>
<Button Canvas.Left="25" Canvas.Top="50">Click Me!</Button>
<Rectangle
jas:DragCanvas.CanBeDragged="False"
Fill="Blue"
Width="50" Height="50"
Canvas.Right="30" Canvas.Bottom="40" />
</jas:DragCanvas>
DragCanvas
会阻止用户将任何元素拖出其可见区域,以免用户意外“丢失”视觉项。如果您的应用程序逻辑要求用户可以将元素拖出 DragCanvas
的可见区域,请将 AllowDragOutOfView
属性设置为 true
。
<jas:DragCanvas AllowDragOutOfView="True">
...
</jas:DragCanvas>
在用户对工作区内项的位置拥有完全控制权的应用程序中,通常需要提供一种方法,可以将工作区内的对象置于 Z 顺序的顶层或底层。DragCanvas
提供了两个可用于完成此任务的方法。
// Bring the Ellipse to the top of the z-order
this.dragCanvas.BringToFront( someEllipse );
// Send the Ellipse to the back of the z-order.
this.dragCanvas.SendToBack( someEllipse );
工作原理
文章的其余部分讨论了 DragCanvas
的工作原理。为了使用该类,无需阅读此部分。
元素拖动逻辑包含三个步骤。
- 当按下鼠标左键时,
DragCanvas
会查找当前鼠标光标位置下的子UIElement
。如果找到,则存储该元素的引用,保存有关元素位置的信息,并缓存光标位置。 - 当鼠标移动时,如果有一个元素正在被拖动(即,在按下鼠标按钮时找到了一个元素),那么该元素将相对于原始元素位置,以旧光标位置和当前光标位置之间的距离进行重新定位。
- 最终,当鼠标按钮释放时,拖动元素引用将被置空,这样当鼠标移动时,将没有元素被重新定位。
这似乎很简单,但细节决定成败!
第一步
第一步的实现如下:
protected override void OnPreviewMouseLeftButtonDown( MouseButtonEventArgs e )
{
base.OnPreviewMouseLeftButtonDown( e );
this.isDragInProgress = false;
// Cache the mouse cursor location.
this.origCursorLocation = e.GetPosition( this );
// Walk up the visual tree from the element that was clicked,
// looking for an element that is a direct child of the Canvas.
this.ElementBeingDragged =this.FindCanvasChild(e.Source as DependencyObject);
if( this.ElementBeingDragged == null )
return;
// Get the element's offsets from the four sides of the Canvas.
double left = Canvas.GetLeft( this.ElementBeingDragged );
double right = Canvas.GetRight( this.ElementBeingDragged );
double top = Canvas.GetTop( this.ElementBeingDragged );
double bottom = Canvas.GetBottom( this.ElementBeingDragged );
// Calculate the offset deltas and determine for which sides
// of the Canvas to adjust the offsets.
this.origHorizOffset = ResolveOffset(left, right, out this.modifyLeftOffset);
this.origVertOffset = ResolveOffset( top, bottom, out this.modifyTopOffset );
// Set the Handled flag so that a control being dragged
// does not react to the mouse input.
e.Handled = true;
this.isDragInProgress = true;
}
这是 FindCanvasChild
方法:
/// <summary>
/// Walks up the visual tree starting with the specified DependencyObject,
/// looking for a UIElement which is a child of the Canvas. If a suitable
/// element is not found, null is returned. If the 'depObj' object is a
/// UIElement in the Canvas's Children collection, it will be returned.
/// </summary>
/// <param name="depObj">
/// A DependencyObject from which the search begins.
/// </param>
public UIElement FindCanvasChild( DependencyObject depObj )
{
while( depObj != null )
{
// If the current object is a UIElement which is a child of the
// Canvas, exit the loop and return it.
UIElement elem = depObj as UIElement;
if( elem != null && base.Children.Contains( elem ) )
break;
// VisualTreeHelper works with objects of type Visual or Visual3D.
// If the current object is not derived from Visual or Visual3D,
// then use the LogicalTreeHelper to find the parent element.
if( depObj is Visual || depObj is Visual3D )
depObj = VisualTreeHelper.GetParent( depObj );
else
depObj = LogicalTreeHelper.GetParent( depObj );
}
return depObj as UIElement;
}
此方法负责遍历视觉和逻辑树,以查找 DragCanvas
的子 UIElement
。由于传递给此方法的对象可能深埋在 DragCanvas
的子项内部,因此有必要遍历参数值的祖先链。例如,用户单击的元素可能是 Hyperlink
中的 Run
,而 Hyperlink
又在 TextBlock
中,TextBlock
在 StackPanel
中,而 StackPanel
又包含在 UniformGrid
(它是 DragCanvas
的子项)中。在这种情况下,我们需要从 Run
对象向上遍历到 UniformGrid
,因为只有 DragCanvas
的直接后代才能被拖动。
ResolveOffset
方法用于确定拖动元素的定位是基于 DragCanvas
的哪些边缘。如本文“背景”部分所述,Canvas
中元素的位置由两个偏移量确定:水平偏移量和垂直偏移量。水平偏移量可以相对于 Canvas
的左边缘或右边缘,垂直偏移量可以相对于顶部边缘或底部边缘。下面显示了确定拖动元素位置相对于 Canvas
哪些边缘的方法。
/// <summary>
/// Determines one component of a UIElement's location
/// within a Canvas (either the horizontal or vertical offset).
/// </summary>
/// <param name="side1">
/// The value of an offset relative to a default side of the
/// Canvas (i.e. top or left).
/// </param>
/// <param name="side2">
/// The value of the offset relative to the other side of the
/// Canvas (i.e. bottom or right).
/// </param>
/// <param name="useSide1">
/// Will be set to true if the returned value should be used
/// for the offset from the side represented by the 'side1'
/// parameter. Otherwise, it will be set to false.
/// </param>
private static double ResolveOffset(
double side1, double side2, out bool useSide1 )
{
// If the Canvas.Left and Canvas.Right attached properties
// are specified for an element, the 'Left' value is honored.
// The 'Top' value is honored if both Canvas.Top and
// Canvas.Bottom are set on the same element. If one
// of those attached properties is not set on an element,
// the default value is Double.NaN.
useSide1 = true;
double result;
if( Double.IsNaN( side1 ) )
{
if( Double.IsNaN( side2 ) )
{
// Both sides have no value, so set the
// first side to a value of zero.
result = 0;
}
else
{
result = side2;
useSide1 = false;
}
}
else
{
result = side1;
}
return result;
}
最后,我们有 ElementBeingDragged
属性。此时,setter 是最主要的。如您所见,当建立拖动元素时,它会获得鼠标捕获。鼠标捕获可确保所有鼠标消息立即定向到拖动元素,这使得在鼠标移动速度非常快时拖动逻辑也能正常工作,并且允许元素在光标离开 DragCanvas
的客户端区域时接收鼠标消息。
/// <summary>
/// Returns the UIElement currently being dragged, or null.
/// </summary>
/// <remarks>
/// Note to inheritors: This property exposes a protected
/// setter which should be used to modify the drag element.
/// </remarks>
public UIElement ElementBeingDragged
{
get
{
if( !this.AllowDragging )
return null;
else
return this.elementBeingDragged;
}
protected set
{
if( this.elementBeingDragged != null )
this.elementBeingDragged.ReleaseMouseCapture();
if( !this.AllowDragging )
this.elementBeingDragged = null;
else
{
if( DragCanvas.GetCanBeDragged( value ) )
{
this.elementBeingDragged = value;
this.elementBeingDragged.CaptureMouse();
}
else
this.elementBeingDragged = null;
}
}
第二步
一旦建立了拖动元素(即 ElementBeingDragged
属性非空),就可以在鼠标移动时移动该对象。以下方法在鼠标移动时移动拖动元素:
protected override void OnPreviewMouseMove( MouseEventArgs e )
{
base.OnPreviewMouseMove( e );
// If no element is being dragged, there is nothing to do.
if( this.ElementBeingDragged == null || !this.isDragInProgress )
return;
// Get the position of the mouse cursor, relative to the Canvas.
Point cursorLocation = e.GetPosition( this );
// These values will store the new offsets of the drag element.
double newHorizontalOffset, newVerticalOffset;
#region Calculate Offsets
// Determine the horizontal offset.
if( this.modifyLeftOffset )
newHorizontalOffset =
this.origHorizOffset + (cursorLocation.X - this.origCursorLocation.X);
else
newHorizontalOffset =
this.origHorizOffset - (cursorLocation.X - this.origCursorLocation.X);
// Determine the vertical offset.
if( this.modifyTopOffset )
newVerticalOffset =
this.origVertOffset + (cursorLocation.Y - this.origCursorLocation.Y);
else
newVerticalOffset =
this.origVertOffset - (cursorLocation.Y - this.origCursorLocation.Y);
#endregion // Calculate Offsets
if( ! this.AllowDragOutOfView )
{
#region Verify Drag Element Location
// Get the bounding rect of the drag element.
Rect elemRect =
this.CalculateDragElementRect( newHorizontalOffset, newVerticalOffset );
//
// If the element is being dragged out of the viewable area,
// determine the ideal rect location, so that the element is
// within the edge(s) of the canvas.
//
bool leftAlign = elemRect.Left < 0;
bool rightAlign = elemRect.Right > this.ActualWidth;
if( leftAlign )
newHorizontalOffset =
modifyLeftOffset ? 0 : this.ActualWidth - elemRect.Width;
else if( rightAlign )
newHorizontalOffset =
modifyLeftOffset ? this.ActualWidth - elemRect.Width : 0;
bool topAlign = elemRect.Top < 0;
bool bottomAlign = elemRect.Bottom > this.ActualHeight;
if( topAlign )
newVerticalOffset =
modifyTopOffset ? 0 : this.ActualHeight - elemRect.Height;
else if( bottomAlign )
newVerticalOffset =
modifyTopOffset ? this.ActualHeight - elemRect.Height : 0;
#endregion // Verify Drag Element Location
}
#region Move Drag Element
if( this.modifyLeftOffset )
Canvas.SetLeft( this.ElementBeingDragged, newHorizontalOffset );
else
Canvas.SetRight( this.ElementBeingDragged, newHorizontalOffset );
if( this.modifyTopOffset )
Canvas.SetTop( this.ElementBeingDragged, newVerticalOffset );
else
Canvas.SetBottom( this.ElementBeingDragged, newVerticalOffset );
#endregion // Move Drag Element
}
该方法首先根据当前鼠标光标的位置计算拖动元素的新偏移量。如果 DragCanvas
不允许元素拖出视图,并且元素的将新位置会使其部分或全部超出视图,则会修改新的偏移量,使拖动元素紧贴 DragCanvas
的边缘。计算出新的偏移量后,通过修改适当附加属性的值来更新元素位置。
第三步
当任一鼠标按钮释放时,拖动元素将被置空。
protected override void OnPreviewMouseUp( MouseButtonEventArgs e )
{
base.OnPreviewMouseUp( e );
// Reset the field whether the left or right mouse button was
// released, in case a context menu was opened on the drag element.
this.ElementBeingDragged = null;
}
Z 顺序方法
如果您有兴趣了解影响 Z 顺序的方法是如何实现的,请查看 DragCanvas
类中的私有 UpdateZOrder
方法。此辅助方法由公共 BringToFront
和 SendToBack
方法使用。完整的源代码以及演示可在本文顶部下载。
可能的改进
也许,公开一些与拖动相关的事件在某些场景下会很有帮助,例如可取消的 BeforeElementDrag
事件、提供有关拖动元素及其位置信息的 ElementDrag
事件,以及 AfterElementDrag
事件。
文章历史记录
- 2006 年 8 月 27 日 – 创建文章。
- 2006 年 9 月 2 日 – 重写了大部分文章,并更改了源代码下载。最初,本文讨论了一个名为
CanvasDragManager
的类,该类附加到Canvas
。在 WPF 论坛的一些专家的帮助下,我克服了一些问题,并将所有拖动逻辑放入了一个派生自Canvas
的类:DragCanvas
。