WPF 图表设计器 - 第二部分
带缩放框的设计器画布
- 下载源代码 - 285.4 KB (需要 .NET 3.5 SP1)

最后更新
- 缩放框(新)
- 套索选择修饰器(已更新)

引言
在本系列文章的第一篇文章中,我向您展示了如何在画布上移动、调整大小和旋转项目。这一次,我们将添加一个典型的图表设计器所需的功能。
- 设计器画布(可变大小,可滚动)
- 缩放框
- 套索选择
- 按键选择(鼠标左键 + 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
,因此我们可以重用第一篇文章中的 ControlTemplate
。DesignerItem
提供了一个 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 中的类图,如果您单击 Expander
的 ToggleButton
,项目会同时被选中**并** 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>
请注意,WrapPanel
的 ItemHeight
和 ItemWidth
属性绑定到 Toolbox
的 DefaultItemSize
属性。
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