实现可悬浮拖动控件的附加属性类

做项目的时候,发现各种东西都要做一个类似悬浮控件或者悬浮球一样的控件,但是光写一个控件太麻烦了,各种项目都要用,而且控件的样式又不统一,所有我干脆做一个可重用的拖动行为附加属性类,支持 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 时
    1. 获取当前的 TT 偏移量(例如 x=10, y=10)。
    2. 反向计算出控件的 “原始布局基准点”

      基准点=当前绝对位置−当前TT偏移量基准点=当前绝对位置当前TT偏移量

  • MouseMove 时
    1. 计算鼠标带动下的新目标绝对位置
    2. 计算新的 TT 值:

      新TT值=新目标绝对位置−原始布局基准点TT=新目标绝对位置原始布局基准点

    原理:始终基于控件“最初在 Grid 中的位置”来计算增量,从而实现连续拖动。

4. 差异化位置更新

  • Canvas:直接修改 Canvas.Left 和 Canvas.Top(布局坐标)。
  • Grid / StackPanel 等:修改 RenderTransform 中的 TranslateTransform.X/Y(视觉坐标)。

5. 边界钳制 (Clamping)

在应用新位置前,将目标坐标限制在 [0, 0] 到 [BoundaryWidth - ElementWidth, BoundaryHeight - ElementHeight] 之间,确保控件不会移出可视范围。

本作品采用知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议进行许可。欢迎转载、使用、重新发布,但务必保留文章署名[FalyEnd]

posted @ 2025-12-04 14:23  FalyEnd  阅读(0)  评论(0)    收藏  举报