WPF/C#:如何实现拖拉元素

前言

在Canvas中放置了一些元素,需要能够拖拉这些元素,在WPF Samples中的DragDropObjects项目中告诉了我们如何实现这种效果。

效果如下所示:

拖拉过程中的效果如下所示:

具体实现

xaml页面

我们先来看看xaml:

xaml 复制代码
 <Canvas Name="MyCanvas"
         PreviewMouseLeftButtonDown="MyCanvas_PreviewMouseLeftButtonDown" 
         PreviewMouseMove="MyCanvas_PreviewMouseMove"
         PreviewMouseLeftButtonUp="MyCanvas_PreviewMouseLeftButtonUp">
     <Rectangle Fill="Blue" Height="32" Width="32" Canvas.Top="8" Canvas.Left="8"/>
     <TextBox Text="This is a TextBox. Drag and drop me" Canvas.Top="100" Canvas.Left="100"/>
 </Canvas>

为了实现这个效果,在Canvas上使用了三个隧道事件(预览事件)PreviewMouseLeftButtonDownPreviewMouseMovePreviewMouseLeftButtonUp

而什么是隧道事件(预览事件)呢?

预览事件,也称为隧道事件,是从应用程序根元素向下遍历元素树到引发事件的元素的路由事件。

PreviewMouseLeftButtonDown当用户按下鼠标左键时触发。

PreviewMouseMove当用户移动鼠标时触发。

PreviewMouseLeftButtonUp当用户释放鼠标左键时触发。

再来看看cs:

csharp 复制代码
 private bool _isDown;
 private bool _isDragging;
 private UIElement _originalElement;
 private double _originalLeft;
 private double _originalTop;
 private SimpleCircleAdorner _overlayElement;
 private Point _startPoint;

定义了这几个私有字段。

鼠标左键按下事件处理程序

鼠标左键按下事件处理程序:

csharp 复制代码
 private void MyCanvas_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e)
 {
     if (e.Source == MyCanvas)
     {
     }
     else
     {
         _isDown = true;
         _startPoint = e.GetPosition(MyCanvas);
         _originalElement = e.Source as UIElement;
         MyCanvas.CaptureMouse();
         e.Handled = true;
     }
 }

最开始引发这个事件的是MyCanvas元素,当事件源是Canvas的时候,不做处理,因为我们只想处理发生在MyCanvas子元素上的鼠标左键按下事件。

鼠标移动事件处理程序

现在来看看鼠标移动事件处理程序:

csharp 复制代码
  private void MyCanvas_PreviewMouseMove(object sender, MouseEventArgs e)
  {
      if (_isDown)
      {
          if ((_isDragging == false) &&
              ((Math.Abs(e.GetPosition(MyCanvas).X - _startPoint.X) >
                SystemParameters.MinimumHorizontalDragDistance) ||
               (Math.Abs(e.GetPosition(MyCanvas).Y - _startPoint.Y) >
                SystemParameters.MinimumVerticalDragDistance)))
          {
              DragStarted();
          }
          if (_isDragging)
          {
              DragMoved();
          }
      }
  }

鼠标左键已经按下了,但还没开始移动事,执行DragStarted方法。

创建装饰器

DragStarted方法如下:

csharp 复制代码
 private void DragStarted()
 {
     _isDragging = true;
     _originalLeft = Canvas.GetLeft(_originalElement);
     _originalTop = Canvas.GetTop(_originalElement);

     _overlayElement = new SimpleCircleAdorner(_originalElement);
     var layer = AdornerLayer.GetAdornerLayer(_originalElement);
     layer.Add(_overlayElement);
 }
csharp 复制代码
_overlayElement = new SimpleCircleAdorner(_originalElement);

创建了一个新的装饰器(Adorner)并将其与一个特定的UI元素关联起来。

而WPF中装饰器是什么呢?

装饰器是一种特殊类型的 FrameworkElement,用于向用户提供视觉提示。 装饰器有很多用途,可用来向元素添加功能句柄,或者提供有关某个控件的状态信息。

Adorner 是绑定到 UIElement 的自定义 FrameworkElement。 装饰器在 AdornerLayer 中呈现,它是始终位于装饰元素或装饰元素集合之上的呈现表面。 装饰器的呈现独立于装饰器绑定到的 UIElement 的呈现。 装饰器通常使用位于装饰元素左上部的标准 2D 坐标原点,相对于其绑定到的元素进行定位。

装饰器的常见应用包括:

  • UIElement 添加功能句柄,使用户能够以某种方式操作元素(调整大小、旋转、重新定位等)。
  • 提供视觉反馈以指示各种状态,或者响应各种事件。
  • UIElement 上叠加视觉装饰。
  • 以视觉方式遮盖或覆盖 UIElement 的一部分或全部。

Windows Presentation Foundation (WPF) 为装饰视觉元素提供了一个基本框架。

在这个Demo中装饰器就是移动过程中四个角上出现的小圆以及内部不断闪烁的颜色,如下所示:

这是如何实现的呢?

这个Demo中自定义了一个继承自Adorner的SimpleCircleAdorner,代码如下所示:

csharp 复制代码
using System;
using System.Windows;
using System.Windows.Documents;
using System.Windows.Media;
using System.Windows.Media.Animation;
using System.Windows.Shapes;

namespace DragDropObjects
{
    public class SimpleCircleAdorner : Adorner
    {
        private readonly Rectangle _child;
        private double _leftOffset;
        private double _topOffset;
        // Be sure to call the base class constructor.
        public SimpleCircleAdorner(UIElement adornedElement)
            : base(adornedElement)
        {
            var brush = new VisualBrush(adornedElement);

            _child = new Rectangle
            {
                Width = adornedElement.RenderSize.Width,
                Height = adornedElement.RenderSize.Height
            };


            var animation = new DoubleAnimation(0.3, 1, new Duration(TimeSpan.FromSeconds(1)))
            {
                AutoReverse = true,
                RepeatBehavior = RepeatBehavior.Forever
            };
            brush.BeginAnimation(Brush.OpacityProperty, animation);

            _child.Fill = brush;
        }

        protected override int VisualChildrenCount => 1;

        public double LeftOffset
        {
            get { return _leftOffset; }
            set
            {
                _leftOffset = value;
                UpdatePosition();
            }
        }

        public double TopOffset
        {
            get { return _topOffset; }
            set
            {
                _topOffset = value;
                UpdatePosition();
            }
        }

        // A common way to implement an adorner's rendering behavior is to override the OnRender
        // method, which is called by the layout subsystem as part of a rendering pass.
        protected override void OnRender(DrawingContext drawingContext)
        {
            // Get a rectangle that represents the desired size of the rendered element
            // after the rendering pass.  This will be used to draw at the corners of the 
            // adorned element.
            var adornedElementRect = new Rect(AdornedElement.DesiredSize);

            // Some arbitrary drawing implements.
            var renderBrush = new SolidColorBrush(Colors.Green) {Opacity = 0.2};
            var renderPen = new Pen(new SolidColorBrush(Colors.Navy), 1.5);
            const double renderRadius = 5.0;

            // Just draw a circle at each corner.
            drawingContext.DrawRectangle(renderBrush, renderPen, adornedElementRect);
            drawingContext.DrawEllipse(renderBrush, renderPen, adornedElementRect.TopLeft, renderRadius, renderRadius);
            drawingContext.DrawEllipse(renderBrush, renderPen, adornedElementRect.TopRight, renderRadius, renderRadius);
            drawingContext.DrawEllipse(renderBrush, renderPen, adornedElementRect.BottomLeft, renderRadius, renderRadius);
            drawingContext.DrawEllipse(renderBrush, renderPen, adornedElementRect.BottomRight, renderRadius,
                renderRadius);
        }

        protected override Size MeasureOverride(Size constraint)
        {
            _child.Measure(constraint);
            return _child.DesiredSize;
        }

        protected override Size ArrangeOverride(Size finalSize)
        {
            _child.Arrange(new Rect(finalSize));
            return finalSize;
        }

        protected override Visual GetVisualChild(int index) => _child;

        private void UpdatePosition()
        {
            var adornerLayer = Parent as AdornerLayer;
            adornerLayer?.Update(AdornedElement);
        }

        public override GeneralTransform GetDesiredTransform(GeneralTransform transform)
        {
            var result = new GeneralTransformGroup();
            result.Children.Add(base.GetDesiredTransform(transform));
            result.Children.Add(new TranslateTransform(_leftOffset, _topOffset));
            return result;
        }
    }
}
csharp 复制代码
  var animation = new DoubleAnimation(0.3, 1, new Duration(TimeSpan.FromSeconds(1)))
            {
                AutoReverse = true,
                RepeatBehavior = RepeatBehavior.Forever
            };
            brush.BeginAnimation(Brush.OpacityProperty, animation);

这里在元素内部添加了动画。

csharp 复制代码
 // Just draw a circle at each corner.
            drawingContext.DrawRectangle(renderBrush, renderPen, adornedElementRect);
            drawingContext.DrawEllipse(renderBrush, renderPen, adornedElementRect.TopLeft, renderRadius, renderRadius);
            drawingContext.DrawEllipse(renderBrush, renderPen, adornedElementRect.TopRight, renderRadius, renderRadius);
            drawingContext.DrawEllipse(renderBrush, renderPen, adornedElementRect.BottomLeft, renderRadius, renderRadius);
            drawingContext.DrawEllipse(renderBrush, renderPen, adornedElementRect.BottomRight, renderRadius,
                renderRadius);

这里在元素的四个角画了小圆形。

csharp 复制代码
  var layer = AdornerLayer.GetAdornerLayer(_originalElement);
      layer.Add(_overlayElement);

这段代码的作用是将之前创建的装饰器_overlayElement添加到与特定UI元素_originalElement相关联的装饰器层(AdornerLayer)中。一旦装饰器被添加到装饰器层中,它就会在_originalElement被渲染时显示出来。

AdornerLayer是一个特殊的层,用于在UI元素上绘制装饰器。每个UI元素都有一个与之关联的装饰器层,但并不是所有的UI元素都能直接看到这个层。

GetAdornerLayer方法会返回与_originalElement相关联的装饰器层。

装饰器层会负责管理装饰器的渲染和布局,确保装饰器正确地显示在UI元素上。

再来看看DragMoved方法:

csharp 复制代码
 private void DragMoved()
 {
     var currentPosition = Mouse.GetPosition(MyCanvas);

     _overlayElement.LeftOffset = currentPosition.X - _startPoint.X;
     _overlayElement.TopOffset = currentPosition.Y - _startPoint.Y;
 }

计算元素的偏移。

鼠标左键松开事件处理程序

鼠标左键松开事件处理程序:

csharp 复制代码
  private void MyCanvas_PreviewMouseLeftButtonUp(object sender, MouseButtonEventArgs e)
  {
      if (_isDown)
      {
          DragFinished();
          e.Handled = true;
      }
  }

DragFinished方法如下:

csharp 复制代码
 private void DragFinished(bool cancelled = false)
 {
     Mouse.Capture(null);
     if (_isDragging)
     {
         AdornerLayer.GetAdornerLayer(_overlayElement.AdornedElement).Remove(_overlayElement);

         if (cancelled == false)
         {
             Canvas.SetTop(_originalElement, _originalTop + _overlayElement.TopOffset);
             Canvas.SetLeft(_originalElement, _originalLeft + _overlayElement.LeftOffset);
         }
         _overlayElement = null;
     }
     _isDragging = false;
     _isDown = false;
 }
csharp 复制代码
 AdornerLayer.GetAdornerLayer(_overlayElement.AdornedElement).Remove(_overlayElement);

从与_overlayElement所装饰的UI元素相关联的装饰器层中移除_overlayElement,从而使得装饰器不再显示在UI元素上。这样,当UI元素被渲染时,装饰器将不再影响其外观或行为。

代码来源

[WPF-Samples/Drag and Drop/DragDropObjects at main · microsoft/WPF-Samples (github.com)](https://github.com/microsoft/WPF-Samples/tree/main/Drag and Drop/DragDropObjects)

参考

1、预览事件 - WPF .NET | Microsoft Learn

2、装饰器概述 - WPF .NET Framework | Microsoft Learn

3、[Adorner 类 (System.Windows.Documents) | Microsoft Learn](