拖动 Canvas 中的元素
这是“拖动 Canvas 中的元素”的替代方案。
引言
在办公室进行 UI 开发期间,我遇到了一个需求,需要实现一个非常简单的 Canvas,能够在其内部及其周围拖动某些元素。
经过一些 Google 搜索,我找到了 John Smith 的文章 在 Canvas 中拖动元素,它的简洁性非常好,所以我决定采用它。正如标题所示,本文是关于自定义 WPF 的 Canvas 类,以便能够在其内拖动元素。
在浏览 DragCanvasDemo 的源代码并尝试使用时,发现一些功能并不完全适合我的目的,例如过多的静态 依赖项属性,一些未完全完成的边缘状态计算,例如如果不允许超出 Canvas 视图,则在元素返回之前,鼠标必须后退,就像溢出一样。最后一件事是,有太多的辅助方法进行了相同和重复的条件检查,因此移动元素的所有功能都在事件中发生,它应该尽可能小。最后,我在源代码中发现了一些用于将元素带到前面和发送到后面的功能,但它们从未在源代码的任何地方使用过。
尽管此 DragCanvas 源代码完全基于 John Smith 的 在 Canvas 中拖动元素,但仍然很难将两者进行比较,因为我做了一些更改。
背景
关于背景,请参阅 John Smith 的 在 Canvas 中拖动元素。
使用代码
像使用常规 Canvas
一样使用 DragCanvas
。唯一的区别是 DragCanvas
有一个附加属性 DraggingMode
,它可以接受值:NoDragging
、AllowDragInView
或 AllowDragOutOfView
,根据 enum
:
//Defines the mode in which the DragCanvas will act
//if set to NoDragging will act a simple Canvas
//if set to AllowDragInView Dragging is enabled but no Draging out of Canvas view is allowed,
//elements will not get out of DragCanvas borders
//if set to AllowDragOutOfView dragging is enabled and elements may be dragged out of the
//DragCanvas borders
public enum DraggingModes
{
NoDragging,
AllowDragInView,
AllowDragOutOfView
}
默认情况下,DragCanvas.Children
中的所有元素都是可拖动的。如果某些元素不能拖动,则将属性 DragCanvas:DragCanvas.CanBeDragged
设置为 False
。请参阅下面的代码中 InfoBox1
和 InfoBox2
之间的区别。
<DragCanvas:DragCanvas DraggingMode="AllowDragInView">
<DragCanvas:InfoBox x:Name="InfoBox1" Canvas.Left="0" Canvas.Top="0"
DragCanvas:DragCanvas.CanBeDragged="True"/>
<DragCanvas:InfoBox x:Name="InfoBox2" Canvas.Right="0" Canvas.Bottom="0"
DragCanvas:DragCanvas.CanBeDragged="False"/>
<DragCanvas:DragCanvas />
在 DragCanvas 源代码的 DragCanvas 命名空间中,有一个 internal enum
类,它定义了元素可以重新定位的方向。enum ModificationDirection
表示如下,并且构建为所有可能方向的 4 位掩码。这可能不是最高效的工作方式,但它确实有助于提高代码的可读性。如果您希望 enum
的所有实例都可以更改为 byte
并以相同方式使用。
//Defines to the DragCanvas Relative to which edges of it the element may be repositioned
internal enum ModificationDirection
{
None = 0,
Left = 1,
Right = 2,
LeftRight = 3,
Top = 4,
LeftTop = 5,
RightTop = 6,
LeftRightTop = 7,
Bottom = 8,
LeftBottom = 9,
RightBottom = 10,
LeftRightBottom = 11,
TopBottom = 12,
LeftTopBottom = 13,
RightTopBottom = 14,
LeftRightTopBottom = 15
}
工作原理
DragCanvas 功能由 3 个阶段构建阶段 1:初始化拖动
此阶段由派生类 Canvas
的重写方法 OnPreviewMouseLeftButtonDown
实现,该方法初始化拖动如下:
1)检查元素是否为 DragCanvas
的子元素。
2)检查 DependencyProperty
"CanBeDragged
" 是否设置为 True
。
3)如果上述 2 个条件为 True
,则将此元素设置为 DraggedElement
。
4)检查相对于 DragCanvas
的哪个侧面放置了该元素(enum
ModificationDirection)并设置元素的初始位置。
5)设置拖动正在进行的标志。
7)将元素带到前面。
protected override void OnPreviewMouseLeftButtonDown( MouseButtonEventArgs e )
{
base.OnPreviewMouseLeftButtonDown( e ); // call the handler of the base first
m_IsDragInProgress = false;
m_InitialCursorLocation = e.GetPosition(this); // Cache the mouse cursor location
//check if the mouse was clicked on the ScrollBar, in which case let the ScrollBar remain its control.
if (null != GetParentOfType<scrollbar>(e.OriginalSource as DependencyObject)) { return; }
// Walk up the visual tree from the element that was clicked,
// looking for an element that is a direct child of the Canvas
DraggedElement = FindCanvasChild(e.Source as DependencyObject);
if(m_DraggedElement == null) { return; }
m_ModificationDirection = ResolveOffset(Canvas.GetLeft(m_DraggedElement), Canvas.GetRight(m _DraggedElement), Canvas.GetTop(m_DraggedElement), Canvas.GetBottom(m_DraggedElement));
// Set the Handled flag so that a control being dragged
// does not react to the mouse input
e.Handled = true;
if(m_ModificationDirection == ModificationDirection.None)
{
DraggedElement = null;
return;
}
BringToFront(m_DraggedElement as UIElement);
m_IsDragInProgress = true;
}
下一个方法 GetParentOfType
是一个辅助方法,它确定 MouseLeftButtonDown
事件的起源是否为 T 类型。它在 OnPreviewMouseLeftButtonDown
方法中使用,以确定鼠标是否单击了 ScrollView
的 ScrollBar
。如果是,则仅忽略该事件,让用户使用滚动条。
public static (DependencyObject current) where T : DependencyObject
{
DependencyObject parent = current;
do
{
if (parent is Visual)
{
parent = VisualTreeHelper.GetParent(parent);
}
else
{
parent = LogicalTreeHelper.GetParent(parent);
}
var result = parent as T;
if (result != null)
{
return result;
}
} while (parent != null);
return null;
}
在方法 ResolveOffset
中实际设置初始偏移量和 DragMode。
/// <summery>
/// Determines one component of a UIElement's location
/// within a Canvas (horizontal or vertical offset)
/// and according to it reletive to which sides it may be relocated
/// </summery>
/// <param name="Left">
/// The value of an offset relative to a Left side of the Canvas
/// </param>
/// <param name="Right">
/// The value of the offset relative to the Right side of the Canvas
/// </param>
/// <param name="Top">
/// The value of the offset relative to the Topside of the Canvas
/// </param>
/// <param name="Bottom">
/// The value of the offset relative to the Bottomside of the Canvas
/// </param>
private ModificationDirection ResolveOffset(double Left, double Right, double Top, double Bottom)
{
// 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.
m_OriginalLefOffset = Left;
m_OriginalRightOffset = Right;
m_OriginalTopOffset = Top;
m_OriginalBottomOffset = Bottom;
ModificationDirection result = ModificationDirection.None;
if (!Double.IsNaN(m_OriginalLefOffset))
{
result |= ModificationDirection.Left;
}
if (!Double.IsNaN(m_OriginalRightOffset))
{
result |= ModificationDirection.Right;
}
if (!Double.IsNaN(m_OriginalTopOffset))
{
result |= ModificationDirection.Top;
}
if (!Double.IsNaN(m_OriginalBottomOffset))
{
result |= ModificationDirection.Bottom;
}
return result;
}
在 FindCanvasChild
中进行单击元素是 DragCanvas
的子项的验证。
/// <summery>
/// 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.
/// </summery>
/// <param name="depObj">
/// A DependencyObject from which the search begins.
/// </param>
private 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;
}
最后,通过方法 BringToFront
将元素带到前面,当然还有相反的方法 SendToBack
,两者都使用相同的辅助方法 UpdateZOrder
。
/// <summery>
/// Assigns the element a z-index which will ensure that
/// it is in front of every other element in the Canvas.
/// The z-index of every element whose z-index is between
/// the element's old and new z-index will have its z-index
/// decremented by one.
/// </summery>
/// <param name="element">
/// The element to be sent to the front of the z-order.
/// </param>
public void BringToFront(UIElement element)
{
UpdateZOrder(element, true);
}
/// <summery>
/// Assigns the element a z-index which will ensure that
/// it is behind every other element in the Canvas.
/// The z-index of every element whose z-index is between
/// the element's old and new z-index will have its z-index
/// incremented by one.
/// </summery>
/// <param name="element">
/// The element to be sent to the back of the z-order.
/// </param>
public void SendToBack(UIElement element)
{
UpdateZOrder(element, fasle);
}
/// <summery>
/// Helper method used by the BringToFront and SendToBack methods.
/// </summery>
/// <param name="element">
/// The element to bring to the front or send to the back.
/// </param>
/// <param name="bringToFront">
/// Pass true if calling from BringToFront, else false.
/// </param>
private void UpdateZOrder(UIElement element, bool bringToFront)
{
#region Safety Check
if (element == null)
throw new ArgumentNullException("element");
if (!base.Children.Contains(element))
throw new ArgumentException("Must be a child element of the Canvas.", "element");
#endregion // Safety Check
#region Calculate Z-Indexes And Offset
// Determine the Z-Index for the target UIElement.
int elementNewZIndex = -1;
if (bringToFront)
{
foreach (UIElement elem in base.Children)
{
if (elem.Visibility != Visibility.Collapsed)
{
++elementNewZIndex;
}
}
}
else
{
elementNewZIndex = 0;
}
// Determine if the other UIElements' Z-Index
// should be raised or lowered by one.
int offset = (elementNewZIndex == 0) ? +1 : -1;
int elementCurrentZIndex = Canvas.GetZIndex(element);
#endregion // Calculate Z-Indici And Offset
#region Update Z-Indexes
// Update the Z-Index of every UIElement in the Canvas.
foreach (UIElement childElement in base.Children)
{
if (childElement == element)
{
Canvas.SetZIndex(element, elementNewZIndex);
}
else
{
int zIndex = Canvas.GetZIndex(childElement);
// Only modify the z-index of an element if it is
// in between the target element's old and new z-index.
if (bringToFront && elementCurrentZIndex < zIndex || !bringToFront && zIndex < elementCurrentZIndex)
{
Canvas.SetZIndex(childElement, zIndex + offset);
}
}
}
#endregion // Update Z-Indexes
}
现在我们已经检查并验证了我们的元素,将其带到前面并注册了初始偏移量,是时候开始移动它了。
阶段 2:计算和更新新的偏移量
所有计算魔术都发生在单个重写方法 OnPreviewMouseMove
中。这里没有太多需要解释的,这只是初中数学,但与 John Smith 的 在 Canvas 中拖动元素 的原始实现相比,这里有一些区别。其中一个非常明显的是,if
语句中存在一些代码重复,并且没有方法
。这些更改是为了防止一些计算开销。CalculateDragElementRect
此外,初始鼠标光标位置每次都会更新,这是为了修复当不允许拖动超出视图时出现的错误。当元素到达 DragCanvas
边界,而鼠标继续移出其边界时,该错误就会出现。在元素再次开始移动之前,需要将鼠标向后移动一段距离。
protected override void OnPreviewMouseMove(MouseEventArgs e)
{
base.OnPreviewMouseMove(e);
// If no element is being dragged, there is nothing to do.
if (m_DraggedElement == null || !m_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 _HorizontalOffset = Double.NaN;
double _VerticalOffset = Double.NaN; ;
#region Calculate Offsets
Size _DraggedElemenetSize = m_DraggedElement.RenderSize;
if ((m_ModificationDirection & ModificationDirection.Left) != 0)
{
_HorizontalOffset = m_OriginalLefOffset + (cursorLocation.X - m_InitialCursorLocation.X);
if (m_DraggingMode == DraggingModes.AllowDragInView)
{
if (_HorizontalOffset < 0)
{
_HorizontalOffset = 0;
}
else if ((_HorizontalOffset + _DraggedElemenetSize.Width) > this.ActualWidth)
{
_HorizontalOffset = this.ActualWidth - _DraggedElemenetSize.Width;
}
}
m_OriginalLefOffset = _HorizontalOffset;
m_OriginalRightOffset = this.ActualWidth + _DraggedElemenetSize.Width - m_OriginalLefOffset;
}
else if ((m_ModificationDirection & ModificationDirection.Right) != 0)
{
_HorizontalOffset = m_OriginalRightOffset - (cursorLocation.X - m_InitialCursorLocation.X);
if (m_DraggingMode == DraggingModes.AllowDragInView)
{
if (_HorizontalOffset < 0)
{
_HorizontalOffset = 0;
}
else if ((_HorizontalOffset + _DraggedElemenetSize.Width) > this.ActualWidth)
{
_HorizontalOffset = this.ActualWidth - _DraggedElemenetSize.Width;
}
}
m_OriginalRightOffset = _HorizontalOffset;
m_OriginalLefOffset = this.ActualWidth + _DraggedElemenetSize.Width - m_OriginalRightOffset;
}
if ((m_ModificationDirection & ModificationDirection.Top) != 0)
{
_VerticalOffset = m_OriginalTopOffset + (cursorLocation.Y - m_InitialCursorLocation.Y);
if (m_DraggingMode == DraggingModes.AllowDragInView)
{
if (_VerticalOffset < 0)
{
_VerticalOffset = 0;
}
else if ((_VerticalOffset + _DraggedElemenetSize.Height) > this.ActualHeight)
{
_VerticalOffset = this.ActualHeight - _DraggedElemenetSize.Height;
}
}
m_OriginalTopOffset = _VerticalOffset;
m_OriginalBottomOffset = this.ActualHeight + _DraggedElemenetSize.Height - m_OriginalTopOffset;
}
else if ((m_ModificationDirection & ModificationDirection.Bottom) != 0)
{
_VerticalOffset = m_OriginalBottomOffset - (cursorLocation.Y - m_InitialCursorLocation.Y);
if (m_DraggingMode == DraggingModes.AllowDragInView)
{
if (_VerticalOffset < 0)
{
_VerticalOffset = 0;
}
else if ((_VerticalOffset + _DraggedElemenetSize.Height) > this.ActualHeight)
{
_VerticalOffset = this.ActualHeight - _DraggedElemenetSize.Height;
}
}
m_OriginalBottomOffset = _VerticalOffset;
m_OriginalTopOffset = this.ActualHeight + _DraggedElemenetSize.Height - m_OriginalBottomOffset;
}
m_InitialCursorLocation = cursorLocation;
#endregion // Calculate Offsets
#region Move Drag Element
if ((m_ModificationDirection & ModificationDirection.Left) != 0)
{
Canvas.SetLeft(m_DraggedElement, _HorizontalOffset);
}
else if ((m_ModificationDirection & ModificationDirection.Right) != 0)
{
Canvas.SetRight(m_DraggedElement, _HorizontalOffset);
}
if ((m_ModificationDirection & ModificationDirection.Top) != 0)
{
Canvas.SetTop(m_DraggedElement, _VerticalOffset);
}
else if ((m_ModificationDirection & ModificationDirection.Bottom) != 0)
{
Canvas.SetBottom(m_DraggedElement, _VerticalOffset);
}
#endregion // Move Drag Element
}
阶段 3:完成拖动
对我们用户来说直观的是,当释放鼠标按钮时,元素就会被固定,现在即使鼠标继续移动,元素也会保持在原位。为了完成这项简单的任务,下一个overriden
方法 OnPreviewMouseUp
可以完成任务,实际上它只是将拖动的元素置空并将标志 m_IsDragInProgress
设置为 false
。
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.
m_IsDragInProgress = false;
DraggedElement = null;
}
可能的改进
与原始文章 在 Canvas 中拖动元素 中建议的 也许,公开一些与拖动相关的事件在某些场景下可能很有用,例如一个可取消的 BeforeElementDrag 事件,一个提供有关拖动元素及其位置信息的 ElementDrag 事件,以及一个 AfterElementDrag 事件。 一样,存在一些问题,例如,当拖动的元素是TextBox
或包含 TextBoxBase
的派生类的 UserControl
时,则无法选择此类元素中的文本。