65.9K
CodeProject 正在变化。 阅读更多。
Home

WPF 图表设计器 - 第二部分

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.97/5 (143投票s)

2008年1月28日

CPOL

5分钟阅读

viewsIcon

413993

downloadIcon

22437

带缩放框的设计器画布

WPF Diagram Designer

最后更新

  • 缩放框(新)
  • 套索选择修饰器(已更新)
WPF Zoombox

引言

在本系列文章的第一篇文章中,我向您展示了如何在画布上移动、调整大小和旋转项目。这一次,我们将添加一个典型的图表设计器所需的功能。

  • 设计器画布(可变大小,可滚动)
  • 缩放框
  • 套索选择
  • 按键选择(鼠标左键 + Ctrl)
  • 工具箱(拖放)
  • 旋转项目(左,右)

设计器画布

在前一篇文章中,您可能已经注意到,当您将一个项目移到 DesignerCanvas 边界之外时,该项目将不再可用。通常,您会期望设计器应用程序提供滚动条,以便您可以轻松滚动到可见画布区域之外的任何项目。为此,我以为我只需要将 DesignerCanvas 包装在 ScrollViewer 中,但这不起作用。我很快就找到了原因;让我通过以下代码片段来解释它。

 <Canvas Width="200"
        Height="200"
        Background="WhiteSmoke">
    <Rectangle Fill="Blue"
               Width="100"
               Height="100"
               Canvas.Left="300"
               Canvas.Top="300" />
</Canvas>

在这里,我在 Canvas 上放置了一个 Rectangle 对象,但将其定位在 Canvas 的边界之外。这会改变 Canvas 的大小吗?当然不会,无论您将项目放置在哪里,Canvas 都会保持其大小。

对于 DesignerCanvas 来说,这意味着即使您将一个项目拖到画布边界之外很远的地方,它的大小也不会改变。现在我们明白了为什么 ScrollViewer 没有帮助:DesignerCanvas 永远不会通知 ScrollViewer 大小更改,仅仅是因为没有发生更改。

解决方案是,我们必须强制 DesignerDanvas 在每次移动或调整项目大小时调整其大小。幸运的是,Canvas 类提供了一个可重写的方法,名为 MeassureOverride,它允许 DesignerCanvas 计算其期望的大小并将其返回给 WPF 布局系统。计算非常简单,您可以在此处看到。

 protected override Size MeasureOverride(Size constraint)
{
    Size size = new Size();
    foreach (UIElement element in base.Children)
    {
        double left = Canvas.GetLeft(element);
        double top = Canvas.GetTop(element);
        left = double.IsNaN(left) ? 0 : left;
        top = double.IsNaN(top) ? 0 : top;

        //measure desired size for each child
        element.Measure(constraint);

        Size desiredSize = element.DesiredSize;
        if (!double.IsNaN(desiredSize.Width) && !double.IsNaN(desiredSize.Height))
        {
            size.Width = Math.Max(size.Width, left + desiredSize.Width);
            size.Height = Math.Max(size.Height, top + desiredSize.Height);
        }
    }
    //for aesthetic reasons add extra points
    size.Width += 10;
    size.Height += 10;
    return size;
}

DesignerItem

DesignerItem 继承自 ContentControl,因此我们可以重用第一篇文章中的 ControlTemplateDesignerItem 提供了一个 IsSelected 属性来指示它是否被选中。

 public class DesignerItem : ContentControl
{
    public bool IsSelected
    {
        get { return (bool)GetValue(IsSelectedProperty); }
        set { SetValue(IsSelectedProperty, value); }
    }
    public static readonly DependencyProperty IsSelectedProperty =
       DependencyProperty.Register("IsSelected", typeof(bool),
                                    typeof(DesignerItem),
                                    new FrameworkPropertyMetadata(false));
       ...

}

然后,我们需要实现 MouseDown 事件的处理程序来支持项目的多选。

 protected override void OnPreviewMouseDown(MouseButtonEventArgs e)
{
    base.OnPreviewMouseDown(e);
    DesignerCanvas designer = VisualTreeHelper.GetParent(this) as DesignerCanvas;

    if (designer != null)
    {
        if ((Keyboard.Modifiers & 
		(ModifierKeys.Shift | ModifierKeys.Control)) != ModifierKeys.None)
        {
            this.IsSelected = !this.IsSelected;
        }
        else
        {
            if (!this.IsSelected)
            {
                designer.DeselectAll();
                this.IsSelected = true;
            }
        }
     }

     e.Handled = false;
}

请注意,我们处理的是 PreviewMouseDown 事件,这是 MouseDown 事件的隧道版本,并且我们将事件标记为未处理。原因是即使 MouseDown 事件的目标是 DesignerItem 内的另一个 Control,我们也希望项目被选中;例如,查看 Visual Studio 中的类图,如果您单击 ExpanderToggleButton,项目会同时被选中**并** Expander 切换其大小。

最后,我们必须更新 DesignerItem 的模板,以便仅当 IsSelected 属性为 true 时,调整大小装饰器才可见,这可以通过简单的 DataTrigger 来处理。

  <Style TargetType="{x:Type s:DesignerItem}">
    <Setter Property="MinHeight" Value="50"/>
    <Setter Property="MinWidth" Value="50"/>
    <Setter Property="SnapsToDevicePixels" Value="true"/>
    <Setter Property="Template">
      <Setter.Value>
        <ControlTemplate TargetType="{x:Type s:DesignerItem}">
          <Grid DataContext="{Binding RelativeSource={RelativeSource TemplatedParent}, 
			  Path=.}">
            <s:MoveThumb
                x:Name="PART_MoveThumb"
                Cursor="SizeAll" 
                Template="{StaticResource MoveThumbTemplate}" />
            <ContentPresenter
                x:Name="PART_ContentPresenter"                
                Content="{TemplateBinding ContentControl.Content}"
                Margin="{TemplateBinding Padding}"/>
            <s:ResizeDecorator x:Name="PART_DesignerItemDecorator"/>
          </Grid>
          <ControlTemplate.Triggers>
            <Trigger Property="IsSelected" Value="True">
              <Setter TargetName="PART_DesignerItemDecorator" 
			Property="ShowDecorator" Value="True"/>
            </Trigger>
          </ControlTemplate.Triggers>
        </ControlTemplate>
      </Setter.Value>
    </Setter>
  </Style>

工具箱

Toolbox 是一个 ItemsControl,它使用 ToolboxItem 类作为默认容器来显示其项目。为此,我们需要重写 GetContainerForItemOverride 方法和 IsItemItsOwnContainerOverride 方法。

public class Toolbox : ItemsControl
{
    private Size defaultItemSize = new Size(65, 65);
    public Size DefaultItemSize
    {
        get { return this.defaultItemSize; }
        set { this.defaultItemSize = value; }
    }

    protected override DependencyObject GetContainerForItemOverride()
    {
        return new ToolboxItem();
    }

    protected override bool IsItemItsOwnContainerOverride(object item)
    {
        return (item is ToolboxItem);
    }
}

此外,我们希望 Toolbox 使用 WrapPanel 来布局其项目。

<Setter Property="ItemsPanel">
   <Setter.Value>
     <ItemsPanelTemplate>
       <WrapPanel Margin="0,5,0,5"
                  ItemHeight="{Binding Path=DefaultItemSize.Height,
                        RelativeSource={RelativeSource AncestorType=s:Toolbox}}"
                  ItemWidth="{Binding Path=DefaultItemSize.Width,
                        RelativeSource={RelativeSource AncestorType=s:Toolbox}}"/>
     </ItemsPanelTemplate>
   </Setter.Value>
</Setter>

请注意,WrapPanelItemHeightItemWidth 属性绑定到 ToolboxDefaultItemSize 属性。

ToolboxItem

如果您想将项目从工具箱拖放到画布上,ToolboxItem 是实际启动拖放操作的地方。拖放本身没有什么神秘之处,但您仍然需要注意如何将项目从拖动源(Toolbox)复制到放置目标(DesignerCanvas)。在我们的案例中,我们使用 XamlWriter.Save 方法将 ToolboxItem 的内容序列化为 XAML,尽管这种序列化在确切序列化的内容方面存在一些显著的限制。在后续的文章中,我们将切换到二进制序列化。

 public class ToolboxItem : ContentControl
 {
     private Point? dragStartPoint = null;

     static ToolboxItem()
     {
         FrameworkElement.DefaultStyleKeyProperty.OverrideMetadata(typeof(ToolboxItem),
                new FrameworkPropertyMetadata(typeof(ToolboxItem)));
     }

     protected override void OnPreviewMouseDown(MouseButtonEventArgs e)
     {
         base.OnPreviewMouseDown(e);
         this.dragStartPoint = new Point?(e.GetPosition(this));
     }

     protected override void OnMouseMove(MouseEventArgs e)
     {
         base.OnMouseMove(e);
         if (e.LeftButton != MouseButtonState.Pressed)
         {
             this.dragStartPoint = null;
         }
         if (this.dragStartPoint.HasValue)
         {
             Point position = e.GetPosition(this);
             if ((SystemParameters.MinimumHorizontalDragDistance <=
                  Math.Abs((double)(position.X - this.dragStartPoint.Value.X))) ||
                  (SystemParameters.MinimumVerticalDragDistance <=
                  Math.Abs((double)(position.Y - this.dragStartPoint.Value.Y))))
             {
                 string xamlString = XamlWriter.Save(this.Content);
                 DataObject dataObject = new DataObject("DESIGNER_ITEM", xamlString);

                 if (dataObject != null)
                 {
                     DragDrop.DoDragDrop(this, dataObject, DragDropEffects.Copy);
                 }
             }
             e.Handled = true;
         }
     }
 }

套索选择

当用户直接在 DesignerCanvas 上发起拖动操作时,将创建一个新的 RubberbandAdorner 实例。

public class DesignerCanvas : Canvas
{
    ...

    protected override void OnMouseMove(MouseEventArgs e)
    {
        base.OnMouseMove(e);

        if (e.LeftButton != MouseButtonState.Pressed)
            this.dragStartPoint = null;

        if (this.dragStartPoint.HasValue)
        {
            AdornerLayer adornerLayer = AdornerLayer.GetAdornerLayer(this);
            if (adornerLayer != null)
            {
                RubberbandAdorner adorner = new RubberbandAdorner(this, dragStartPoint);
                if (adorner != null)
                {
                    adornerLayer.Add(adorner);
                }
            }
            
            e.Handled = true;
        }        
    }
    
    ...
}

一旦创建了 RubberbandAdorner,它就会接管拖动操作并更新套索的绘制和当前项目的选择。这些更新发生在 UpdateRubberband()UpdateSelection() 方法内部。

public class RubberbandAdorner : Adorner
{    
    ....
    
    private Point? startPoint, endPoint;

    protected override void OnMouseMove(MouseEventArgs e)
    {
        if (e.LeftButton == MouseButtonState.Pressed)
        {
            if (!this.IsMouseCaptured)
            {
                this.CaptureMouse();
            }

            this.endPoint = e.GetPosition(this);
            this.UpdateRubberband();
            this.UpdateSelection();
            e.Handled = true;
        }
    }
    
    ...
}

由于实际的套索是 Rectangle 类的实例,因此 UpdateRubberband() 方法只需要更新该 Rectangle 的大小和位置。

private void UpdateRubberband()
{
    double left = Math.Min(this.startPoint.Value.X, this.endPoint.Value.X);
    double top = Math.Min(this.startPoint.Value.Y, this.endPoint.Value.Y);

    double width = Math.Abs(this.startPoint.Value.X - this.endPoint.Value.X);
    double height = Math.Abs(this.startPoint.Value.Y - this.endPoint.Value.Y);

    this.rubberband.Width = width;
    this.rubberband.Height = height;
    Canvas.SetLeft(this.rubberband, left);
    Canvas.SetTop(this.rubberband, top);
}

UpdateSelection() 方法中需要做一些额外的工作。在这里,我们检查每个 DesignerItem 是否包含在当前的套索中。为此,VisualTreeHelper.GetDescendantBounds(item) 方法为我们提供了每个项目的边界矩形。我们将此矩形的坐标转换到 DesignerCanvas 并调用 rubberband.Contains(itemBounds) 方法来决定项目是否被选中!

private void UpdateSelection()
{
    Rect rubberBand = new Rect(this.startPoint.Value, this.endPoint.Value);
    foreach (DesignerItem item in this.designerCanvas.Children)
    {
        Rect itemRect = VisualTreeHelper.GetDescendantBounds(item);
        Rect itemBounds = item.TransformToAncestor
			(designerCanvas).TransformBounds(itemRect);

        if (rubberBand.Contains(itemBounds))
        {
            item.IsSelected = true;
        }
        else
        {
            item.IsSelected = false;
        }
    }
}

请注意,当拖动操作期间触发 MouseMove 事件时,会调用这些更新方法,而且这个频率非常高!相反,您可能希望只在拖动操作结束时,当 MouseUp 事件触发时,才更新选择。

自定义 DragThumb

DragThumb 类的默认样式是一个透明的 Rectangle,但如果您想调整该样式,可以使用一个名为 DesignerItem.DragThumbTemplate 的附加属性。让我通过一个例子来解释用法。假设 DesignerItem 的内容是一个星形,如下所示。

<Path Stroke="Red" StrokeThickness="5" Stretch="Fill" IsHitTestVisible="false"
      Data="M 9,2 11,7 17,7 12,10 14,15 9,12 4,15 6,10 1,7 7,7 Z"/> 

为了说明结果,我已经对默认的 DragThumb 模板进行了着色。

现在尝试以下操作:

 <Path Stroke="Red" StrokeThickness="5" Stretch="Fill" IsHitTestVisible="false"
      Data="M 9,2 11,7 17,7 12,10 14,15 9,12 4,15 6,10 1,7 7,7 Z">
    <s:DesignerItem.DragThumbTemplate>
         <ControlTemplate>
            <Path Data="M 9,2 11,7 17,7 12,10 14,15 9,12 4,15 6,10 1,7 7,7 Z"
                    Fill="Transparent" Stretch="Fill"/>
         </ControlTemplate>
    </s:DesignerItem.DragThumbTemplate>
</Path>

结果是一个比默认的 DragThumb 更合适的 DragThumb

 

历史

  • 2008 年 1 月 28 日 -- 提交原始版本
  • 2008 年 2 月 11 日 -- 添加了 Rubberband 选择
  • 2008 年 10 月 7 日 -- 添加了缩放框,更新了 RubberbandAdorner
© . All rights reserved.