实现可悬浮拖动控件的附加属性类
做项目的时候,发现各种东西都要做一个类似悬浮控件或者悬浮球一样的控件,但是光写一个控件太麻烦了,各种项目都要用,而且控件的样式又不统一,所有我干脆做一个可重用的拖动行为附加属性类,支持 Canvas 和 Grid 等多种父容器,并进行边界钳制。
也不说什么很高深的原理,自己进去对着特性和类一个一个看过就懂了,直接上代码。
首先建立一个静态类 ,这个静态类包含一个附加属性 IsDraggable,当它被设置为 true 时,它会挂接控件的鼠标事件来实现拖动逻辑。
namespace TestNamespace.Append // 请保持与 XAML 文件中的命名空间一致 { /// <summary> /// 可重用的拖动行为附加属性类,支持 Canvas 和 Grid 等多种父容器,并进行边界钳制。 /// </summary> public static class DraggableBehavior { #region 附加属性定义 // IsDraggable 属性 (启用/禁用拖动功能) public static readonly DependencyProperty IsDraggableProperty = DependencyProperty.RegisterAttached( "IsDraggable", typeof(bool), typeof(DraggableBehavior), new PropertyMetadata(false, OnIsDraggableChanged)); public static bool GetIsDraggable(DependencyObject obj) => (bool)obj.GetValue(IsDraggableProperty); public static void SetIsDraggable(DependencyObject obj, bool value) => obj.SetValue(IsDraggableProperty, value); // BoundaryElement 属性 (指定拖动的边界容器,如果未设置,则使用父容器) public static readonly DependencyProperty BoundaryElementProperty = DependencyProperty.RegisterAttached( "BoundaryElement", typeof(FrameworkElement), typeof(DraggableBehavior), new PropertyMetadata(null)); public static FrameworkElement GetBoundaryElement(DependencyObject obj) => (FrameworkElement)obj.GetValue(BoundaryElementProperty); public static void SetBoundaryElement(DependencyObject obj, FrameworkElement value) => obj.SetValue(BoundaryElementProperty, value); #endregion #region 拖动状态存储 private static readonly DependencyProperty DragStateProperty = DependencyProperty.RegisterAttached("DragState", typeof(DragState), typeof(DraggableBehavior), new PropertyMetadata(null)); private class DragState { public bool IsDragging { get; set; } // 鼠标点击点相对于被拖动元素左上角的偏移量 public Point MouseOffsetFromElementTopLeft { get; set; } // 鼠标按下时,鼠标相对于边界元素的绝对位置 public Point InitialMousePosInBoundary { get; set; } // 拖动开始时,TranslateTransform 的当前值 (用于非 Canvas 累加偏移) public Point InitialTranslationOffset { get; set; } // 元素未平移时的原始基准位置 (相对于边界) - 用于边界检查 public Point OriginalUntranslatedPositionInBoundary { get; set; } } private static DragState GetDragState(DependencyObject obj) { var state = (DragState)obj.GetValue(DragStateProperty); if (state == null) { state = new DragState(); obj.SetValue(DragStateProperty, state); } return state; } #endregion #region 事件和行为挂接 private static void OnIsDraggableChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) { if (d is UIElement element) { if ((bool)e.NewValue) { element.MouseLeftButtonDown += Element_MouseLeftButtonDown; } else { element.MouseLeftButtonDown -= Element_MouseLeftButtonDown; element.MouseMove -= Element_MouseMove; element.MouseLeftButtonUp -= Element_MouseLeftButtonUp; } } } private static void Element_MouseLeftButtonDown(object sender, MouseButtonEventArgs e) { if (sender is FrameworkElement element) // 使用 FrameworkElement 才能获取 ActualWidth 等 { var dragState = GetDragState(element); // 优先使用指定的边界元素,否则使用可视化树上的父元素 var boundaryElement = GetBoundaryElement(element) ?? (VisualTreeHelper.GetParent(element) as FrameworkElement); if (boundaryElement == null) { // 无法确定坐标系或边界,不允许拖动 return; } // 1. 设置拖动状态 dragState.IsDragging = true; element.CaptureMouse(); e.Handled = true; // 2. 存储初始位置和偏移量 dragState.MouseOffsetFromElementTopLeft = e.GetPosition(element); dragState.InitialMousePosInBoundary = e.GetPosition(boundaryElement); var parent = VisualTreeHelper.GetParent(element); if (parent is Canvas) { // Canvas 容器:直接使用 Canvas.Left/Top // 无需 RenderTransform } else if (parent != null) { // Grid/StackPanel 等容器:需要 TranslateTransform // 确保 RenderTransform 存在 if (!(element.RenderTransform is TranslateTransform tt)) { tt = new TranslateTransform(); element.RenderTransform = tt; } // 记录当前已有的 TT 偏移量 (解决跳动问题的关键) dragState.InitialTranslationOffset = new Point(tt.X, tt.Y); // 记录元素未平移时的绝对位置 (用于边界钳制) // 临时重置 TT 来获取原始布局位置是危险的,这里我们使用一个更安全但更复杂的计算方式。 // 元素的当前绝对位置 (包含 TT) Point currentElementPosInBoundary = element.TranslatePoint(new Point(0, 0), boundaryElement); // 元素未平移的基准绝对位置 = 当前绝对位置 - 当前 TT 偏移 dragState.OriginalUntranslatedPositionInBoundary = new Point( currentElementPosInBoundary.X - dragState.InitialTranslationOffset.X, currentElementPosInBoundary.Y - dragState.InitialTranslationOffset.Y ); } // 3. 挂接 Move 和 Up 事件 element.MouseMove += Element_MouseMove; element.MouseLeftButtonUp += Element_MouseLeftButtonUp; } } private static void Element_MouseMove(object sender, MouseEventArgs e) { if (sender is FrameworkElement element && GetDragState(element).IsDragging) { var dragState = GetDragState(element); var boundaryElement = GetBoundaryElement(element) ?? (VisualTreeHelper.GetParent(element) as FrameworkElement); if (boundaryElement == null) return; Point currentMousePosInBoundary = e.GetPosition(boundaryElement); var parent = VisualTreeHelper.GetParent(element); // 1. 计算元素目标左上角相对于边界元素的绝对位置 (无论 Canvas 还是 Grid) double targetX = currentMousePosInBoundary.X - dragState.MouseOffsetFromElementTopLeft.X; double targetY = currentMousePosInBoundary.Y - dragState.MouseOffsetFromElementTopLeft.Y; // 2. 定义边界限制 double minX = 0; double minY = 0; // 最大位置 = 边界宽度 - 元素宽度 (必须使用 ActualWidth/Height) double maxX = boundaryElement.ActualWidth - element.ActualWidth; double maxY = boundaryElement.ActualHeight - element.ActualHeight; maxX = Math.Max(minX, maxX); // 确保不会是负数 maxY = Math.Max(minY, maxY); // 3. 边界检查和钳制 (Clamping) targetX = Math.Max(minX, Math.Min(maxX, targetX)); targetY = Math.Max(minY, Math.Min(maxY, targetY)); // 4. 应用新位置 if (parent is Canvas) { // Canvas: 直接设置 Canvas.Left/Top Canvas.SetLeft(element, targetX); Canvas.SetTop(element, targetY); } else if (parent != null) { // Grid/StackPanel/etc.:使用 TranslateTransform 累加偏移 if (element.RenderTransform is TranslateTransform tt) { // 目标 TT 偏移量 = 目标绝对位置 - 元素未平移的基准绝对位置 tt.X = targetX - dragState.OriginalUntranslatedPositionInBoundary.X; tt.Y = targetY - dragState.OriginalUntranslatedPositionInBoundary.Y; } } } } private static void Element_MouseLeftButtonUp(object sender, MouseButtonEventArgs e) { if (sender is UIElement element) { element.ReleaseMouseCapture(); GetDragState(element).IsDragging = false; element.MouseMove -= Element_MouseMove; element.MouseLeftButtonUp -= Element_MouseLeftButtonUp; e.Handled = true; } } #endregion } }
XAML 中的使用
在 XAML 中使用这个附加属性非常简单。您只需将 Append:DraggableBehavior.IsDraggable="True" 附加到任何想要拖动的控件上即可。
<Window x:Class="TestNamespace.MainWindow" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:append="clr-namespace:TestNamespace.Append" Title="Draggable Demo" Height="450" Width="800"> <Grid> <Border Background="Red" Width="100" Height="50" Canvas.Left="50" Canvas.Top="50" append:DraggableBehavior.IsDraggable="True"> <TextBlock Text="Drag Me" Foreground="White" HorizontalAlignment="Center" VerticalAlignment="Center"/> </Border> <Button Content="Drag Button" Width="120" Height="40" Canvas.Left="200" Canvas.Top="150" append:DraggableBehavior.IsDraggable="True"/> <Ellipse Fill="Blue" Width="80" Height="80" Canvas.Left="350" Canvas.Top="80"/> </Grid> </Window>
以下是该通用拖动行为的简明实现原理概述:
1. 核心机制:附加属性与事件钩子
利用 附加属性 (Attached Property) (IsDraggable) 将逻辑注入到任意 UIElement 中。
- 启用时:挂接
MouseLeftButtonDown。 - 拖动中:在
Down事件中挂接MouseMove和MouseLeftButtonUp,并调用CaptureMouse锁定鼠标焦点。
2. 坐标统一:以边界为参考系
无论父容器是什么,统一将鼠标坐标和控件坐标映射到 BoundaryElement(指定的边界或父容器)的坐标系中进行计算。
3. 解决“回弹/跳动”的关键算法 (Core Fix)
在非 Canvas 容器(如 Grid)中,拖动依赖于 TranslateTransform (TT),这只是视觉偏移,不改变布局位置。
- MouseDown 时:
- 获取当前的 TT 偏移量(例如
x=10, y=10)。 - 反向计算出控件的 “原始布局基准点”:
基准点=当前绝对位置−当前TT偏移量基准点=当前绝对位置−当前TT偏移量
- 获取当前的 TT 偏移量(例如
- MouseMove 时:
- 计算鼠标带动下的新目标绝对位置。
- 计算新的 TT 值:
新TT值=新目标绝对位置−原始布局基准点新TT值=新目标绝对位置−原始布局基准点
4. 差异化位置更新
- Canvas:直接修改
Canvas.Left和Canvas.Top(布局坐标)。 - Grid / StackPanel 等:修改
RenderTransform中的TranslateTransform.X/Y(视觉坐标)。
5. 边界钳制 (Clamping)
在应用新位置前,将目标坐标限制在 [0, 0] 到 [BoundaryWidth - ElementWidth, BoundaryHeight - ElementHeight] 之间,确保控件不会移出可视范围。
本作品采用知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议进行许可。欢迎转载、使用、重新发布,但务必保留文章署名[FalyEnd]

浙公网安备 33010602011771号