WPF - Schema Diagrammer 第 I 部分






4.72/5 (15投票s)
以编程方式将UI元素放置在Canvas上。
(很棒,不是吗???)
引言
这是我第一次真正接触WPF,因为我有一个小型(个人)项目,我想将其实现为WPF以“试水”。
这项工作大量基于CPian“sukram”关于WPF架构设计器的作品(参见参考文献)。然而,我之所以写这篇文章,是因为在尝试遵循他的作品(以及其他所有人的作品)时,我仍然在WPF中感到不知所措。我意识到WPF需要许多不同级别的知识——有我称之为“元级别”的,其中应用了样式、模板等。有“类-属性级别”的知识,这基本上是我来自MyXaml等声明式项目的背景。还有“工作流程级别”,这是如何使用Visual Studio WPF设计器的知识。还有其他知识领域,例如,知道某些元素属于哪个集合,知道如何进行数据绑定和事件连接,知道何时派生一个类,或对其进行样式设置,或对其进行模板化,或在不派生的情况下连接事件。总的来说,这些对我来说仍然是谜。
本次练习的目的是真正从头开始——我想做什么,我需要WPF做什么来实现我的目标,以及为什么它不起作用?所以,对于所有WPF老手来说,这将非常乏味。如果您是WPF的绝对新手,从未读过WPF书籍(就像我一样),并且正在试图理解Code Project上精彩的文章(就像我一样),那么希望本文适合您。
目标
我试图实现的是图示SQL 2005/2008表之间的关系。我一直在查看各种图表工具,例如Northwood的Go.Diagram,但脑海中一直萦绕着使用WPF作为图表工具的想法。Sukram的WPF架构设计器将我推向了那个方向。
要求
我现在只讨论视觉要求(不讨论数据或架构要求)。视觉要求是
- 以编程方式将对象放置在显示表面上
- 将一些文本(表名)附加到对象上
- 允许用户移动对象(目前,我计划不实现任何智能表布局)
- 在对象之间绘制线条(带箭头)以指示外键关系;最初,这些是直线,而不是自动绕过对象的“路由”线
- 序列化/反序列化(保存和加载)图
- 提供缩放级别
- 根据缩放级别提供自动滚动
- 提供一个几乎无限的显示表面,用于放置和移动对象
- 指定表关系的线条在移动线条的头部或尾部对象时自动调整其中心
从某些方面来说,这是一个艰巨的任务;但是,它代表了我认为的最小应用程序。当然,我可以从sukram出色的WPF架构设计器工作开始,但我发现自己完全迷失在理解WPF的过程中,尽管在很多方面我都在重复,甚至在视觉呈现方面降低了标准,但我确实想从头开始理解,如何用WPF编写上述需求。
文章格式
我选择了一种格式,即我将针对每个要求(不一定按我原来的顺序)进行说明,并演示我如何满足该要求(其中常常会发现子要求),然后简要评论我在实现该要求时学到的东西。
免责声明
我绝不是试图创建一个全面而完整的XAML和WPF讨论。这些是我学到的东西,更重要的是,我*选择*了学习的深度。学习新技术时,总需要在学习技术本身和学习如何应用技术来完成工作之间取得平衡。我发现每个人的平衡点都不同,您将在本文中隐含地遇到我的平衡点。
以编程方式将对象放置在显示表面上
要做到这一点,我首先需要了解对象是如何放置在显示表面上的。
步骤1:创建WPF项目
创建一个WPF项目:启动VS2008,选择“文件”->“新建”->“项目”,选择“WPF应用程序”,并将其命名为“Lesson1”。
Visual Studio会为您创建一些起始XAML
<Window x:Class="Lesson1Redux.Window1"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="Window1" Height="300" Width="300">
<Grid>
</Grid>
</Window>
现在怎么办?
什么是Window?
System.Windows.Window
类是管理*内容*的容器。它继承自ContentControl
,后者本身继承自Control
并实现IAddChild
。
IAddChild
接口很有趣,因为MSDN写道:“为了建立或定义内容属性或内容模型,IAddChild
已过时。”好吧,随便吧。IAddChild
要求实现者提供AddChild
和AddText
方法。
同样来自MSDN:“内容模型:Window
是一个ContentControl
,这意味着Window
可以包含文本、图像或面板等内容。此外,Window
是根元素,因此不能是另一个元素的内容的一部分。”
好的,所以Window
提供了两样东西
- 应用程序的标准框架(标题栏、系统菜单、边框、大小调整柄、最小化、最大化和关闭按钮)
- 它是一个根元素,所有事物开始的地方
接下来,我们将看到Window
还提供了一个非常有用的Content
属性。
什么是Grid以及我需要它吗?
例如,我可以删除Grid
标签,并使用工具箱将一个Ellipse
拖到窗口上,这将生成
<Window x:Class="Lesson1.Window1"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="Window1" Height="300" Width="300">
<Ellipse Height="100" Name="ellipse1" Stroke="Black" Width="200" />
</Window>
但是,现在,让我们尝试将另一个项目拖到窗口上。不起作用!查看Window
类,注意到它没有管理子控件的属性。它有一个“Content
”属性。相反,如果我们查看Grid
控件的属性,它有一个Children
属性。
啊。查看Content
属性表明它确实初始化为Ellipse
实例。所以,我在这里学到了一点——与System.Windows.Forms
命名空间不同,Window
不直接支持子控件——相反,它提供了一个单个属性(Content
),如果我想要窗口中有多个控件,我必须使用一个“内容控件”,该控件允许我将多个子控件放入/放在其中。
题外话
现在,请允许我暂时离题,批评一下WPF的作者。我们如何知道
Window
类的子元素设置了Content
属性?我们如何知道(正如我们稍后将看到的)Canvas
类的子元素将自己添加到Children
集合属性?您需要仔细查看MSDN文档,以及“XAML对象元素用法”部分。如果您看到类似这样的内容
然后是关于内容模型的讨论,特别是“*...强制执行强内容模型用于...*”这句话,那么您就有我认为的指示,即在未提供属性时,默认行为是在XAML对象元素用法中指示的子元素。是的,我花了点时间才弄清楚。
无论如何,我认为这导致了一种混乱、模糊、坦率地说是不正确的声明式语法。是的,我说得如此之深,是因为声明式代码的行为变成*隐含的*,这是由解析器如何编写或模型元数据的某个属性决定的,而不是*显式地*以一种不依赖于解析器的方式提供所有信息来解码标记。
回到正题
Grid
控件继承自Panel
,并且来自MSDN:“Panel
元素是控制元素渲染的组件”。啊。“渲染元素”,复数。专门设计为根布局提供程序的派生Panel
控件(意味着具有UI的应用程序)是
画布
DockPanel
Grid
StackPanel
VirtualizingStackPanel
WrapPanel
我怀疑,对于我的要求,最适合的面板是Canvas
,因为我不需要换行、堆叠、停靠或对齐到网格,我只需要自由布局。
所以,通过更改XAML
<Window x:Class="Lesson1.Window1"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="Window1" Height="300" Width="300">
<Canvas>
<Ellipse Height="100" Name="ellipse1" Stroke="Black" Width="200" />
</Canvas>
</Window>
我注意到Window
的Content
属性被设置为Canvas
实例,而Canvas.Children
属性确实有一个Ellipse
实例
定位
要将椭圆放置在Canvas
上,我假设其他控件也是如此,我必须使用一个*附加属性*,在这种情况下是Canvas.Top
和Canvas.Left
。顺便说一句,如果您同时定义了Canvas.Bottom
和Canvas.Right
,那么Top
和Left
属性将优先。所以现在,我Canvas
块内的标记看起来像
<Ellipse Canvas.Left="108" Canvas.Top="122" Height="78"
Name="ellipse1" Stroke="Black" Width="116" />
<Ellipse Canvas.Left="27" Canvas.Top="24" Height="57"
Name="ellipse2" Stroke="Black" Width="91" />
注意,我添加了另一个椭圆。现在,我发现我的眼睛在阅读附加属性时开始变得模糊,尤其是在它们与依赖项属性和整个WPF模型纠缠在一起时。所以,我将不再深入研究附加属性。我希望对设置对象位置的了解足够,以便在不深入了解附加属性的情况下,也能勉强完成其余部分。
步骤2:以编程方式放置对象
现在我对通过拖放工具箱中的对象在设计器中绘制对象有了一些想法,我该如何以编程方式做到这一点?似乎有两种选择——像这样编写所有代码(命令式)
public Window1()
{
InitializeComponent();
// Get my canvas.
Canvas canvas = (Canvas)Content;
// My markup was:
// <Ellipse Height="78" Name="ellipse1" Stroke="Black"
// Width="116" Canvas.Left="108" Canvas.Top="122" />
Ellipse ellipse = new Ellipse();
ellipse.Height = 78;
ellipse.Name = "ellipse1";
ellipse.Stroke = Brushes.Black;
ellipse.Width = 116;
// Set the attached properties Left and Top.
Canvas.SetLeft(ellipse, 108);
Canvas.SetTop(ellipse, 122);
// Add the child to the canvas.
canvas.Children.Add(ellipse);
}
但是,如果我想使用XAML字符串呢?在我看来,XAML唯一有用的就是创建窗口,这很糟糕。我不能以编程方式但声明式地使用XAML吗?嗯,有两个类,XamlReader
和XamlWriter
,似乎可用于此目的,尽管MSDN中有几条关于这些类具有“一系列限制”的注释,当然,这些限制并未描述。我想我会在过程中发现它们是什么。
所以,如果我想使用XAML,我可以这样做
public Window1()
{
InitializeComponent();
// Get my canvas.
Canvas canvas = (Canvas)Content;
string xamlEllipse = "<Ellipse Height='78' Name='ellipse1' Stroke='Black' Width='116'
Canvas.Left='108' Canvas.Top='122'
xmlns="[text that won't render correctly on the article page]";
StringReader sr = new StringReader(xamlEllipse);
XmlReader xr = XmlReader.Create(sr);
Ellipse ellipse = (Ellipse)XamlReader.Load(xr);
canvas.Children.Add(ellipse);
}
这需要添加命名空间
using System.IO;
using System.Windows.Markup;
using System.Xml;
另请注意xmlns
属性,它是一个“http”命名空间,在这里被省略了,因为它会导致文章文本出现问题。我通过首先序列化前面示例中创建的Ellipse
并检查生成的字符串,弄清楚了*xmlns*文本应该是什么。如果我省略*xmlns*,应用程序将在运行时崩溃并出现错误
XML namespace prefix does not map to a namespace URI,
so cannot resolve property 'Height'. Line '1' Position '10'.
好吧,我猜错误消息解释了为什么我们需要xmlns
属性。
还请注意,我将双引号改为单引号,以便更轻松地将XAML作为字符串阅读。
难道没有更好的方法吗?
从我的角度来看,我实际上并没有在很大程度上改善情况。难道没有更好的方法以编程方式放置XAML控件,而无需命令式代码或嵌入的XAML字符串吗?我打算看看sukram的WPF架构设计器,看看他是如何做的。不幸的是,他专注于连接器的问题,而不是如何将一个在XAML中明确定义的、当用户将其从工具箱拖到Canvas
时(有效地以编程方式)放置在Canvas
上的对象。
如果您查看sukram的OnDrop
事件在*DesignerCanvas.cs*中,您会注意到他也使用了XmlReader
类。沿着这条路径回溯,在*ToolboxItem.cs*文件中,很明显,XAML字符串是对工具箱项的序列化,而工具箱项本身正在被拖放到设计器画布上。XAML本身包含工具箱的标记,因此该过程是自反的,其中工具箱项在工具箱中已呈现,会(由于序列化)在设计器表面上放置其自身的副本。
这并不是我想要的,因为我没有一个起始工具箱。然而,仅仅通过查看XAML代码,人们很快就会意识到sukram创建了一个非常好的模板化、样式化和资源驱动的环境,并且正是ResourceDictionary
对象的使用使我们能够将对象的标记保存在XAML中,希望ResourceDictionary
能够以编程方式访问。
步骤3:ResourceDictionary
所以,我们先来解决更难的问题——资源字典如何以编程方式访问?嗯,我们首先在XAML中定义ResourceDictionary
<Window x:Class="Lesson1Redux.Window1"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="Window1" Height="300" Width="300">
<Window.Resources>
<ResourceDictionary></ResourceDictionary>
</Window.Resources>
<Canvas>
</Canvas>
</Window>
注意“Window.Resources
”元素。XAML中有一个我个人不喜欢的行为,那就是子元素有一个默认的、隐含的属性,它被分配给它,或者如果属性是一个集合,它被添加到其中。正如我上面所说的,Window
只能有一个Content
实例,并且因为Content
是一个对象,它可以是任何东西——当然解析器不能使用类型信息来说“嗯,ResourceDictionary
不应该是Content
”。因此,结合隐含的属性/集合赋值,我不能简单地写
<Window x:Class="Lesson1Redux.Window1"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="Window1" Height="300" Width="300">
<ResourceDictionary/>
<Canvas/>
</Window>
不。这会导致错误“不能多次设置Content属性”。所以,我必须明确告诉XAML解析器我想设置哪个属性,甚至更糟糕(在我看来)是通过引用基类类型。顺便说一下,这个
<Window x:Class="Lesson1Redux.Window1"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="Window1" Height="300" Width="300">
<Window.Resources>
<ResourceDictionary/>
</Window.Resources>
<Window.Content>
<Canvas/>
</Window.Content>
</Window>
也是可以接受的。但这个
<Window x:Class="Lesson1Redux.Window1"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="Window1" Height="300" Width="300">
<Resources>
<ResourceDictionary/>
</Resources>
<Content>
<Canvas/>
</Content>
</Window>
则不行,会导致错误“找不到Resources类型”和“找不到Content类型”。所以,这给了我们一些关于属性作为子元素如何设置的线索——使用“点表示法”引用基类类型和所需的属性。
那么,我们如何添加XAML字符串呢?嗯,不是字符串!以下标记描述了资源“Table
”
<Window.Resources>
<ResourceDictionary>
<Ellipse x:Key="Table" Height='78' Stroke='Black'
Width='116' Canvas.Left='108' Canvas.Top='122'/>
</ResourceDictionary>
</Window.Resources>
上面,您会注意到“x:Key
”属性。当父容器实现IDictionary
时,您可以应用此属性来创建键控集合。
在代码中,我们可以像这样将其添加到Canvas
的Children
中
public Window1()
{
InitializeComponent();
// Get my canvas.
Canvas canvas = (Canvas)Content;
// Add my table resource.
canvas.Children.Add((UIElement)Resources["Table"]);
}
现在,这里有些奇怪的事情。如果您注意到,上面的Ellipse
元素缺少“Name
”属性。如果我包含此属性
<Ellipse x:Key="Table" Name="Ellipse1" Height='78' Stroke='Black' Width='116'
Canvas.Left='108' Canvas.Top='122'/>
并在调试器中运行程序,我得到这个错误
如果我*单步*执行程序,它就可以工作。
我已经成功执行了上述语句。
我们完成了吗?
当然不是。ResourceDictionary
只创建一个实例,如果我们想在Canvas
上绘制多个“Table
”对象,我们实际上需要克隆这个东西。所以,回到XamlReader
和XamlWriter
!但首先,让我们看看当我尝试添加相同实例时WPF会发生什么
唉。多么悲惨的状况。调试器甚至没有中断。我收到上述消息。如果我点击“确定”按钮,我得到
同样没有信息量。幸运的是我比WPF聪明,并且*预料到了这个问题*,尽管我没有预料到WPF会以如此*愚蠢*的方式处理这个问题。所以,编写一个简单的克隆器
public Window1()
{
InitializeComponent();
// Get my canvas.
Canvas canvas = (Canvas)Content;
// Add my table resource.
canvas.Children.Add(Clone((UIElement)Resources["Table"]));
canvas.Children.Add(Clone((UIElement)Resources["Table"]));
Canvas.SetTop(canvas.Children[1], 20);
}
protected UIElement Clone(UIElement elem)
{
string str = XamlWriter.Save(elem);
StringReader sr = new StringReader(str);
XmlReader xr = XmlReader.Create(sr);
UIElement ret = (UIElement)XamlReader.Load(xr);
return ret;
}
应用程序运行了。哦,它看起来怎么样?我想你永远不会问
注意我是如何以编程方式更改“第二个”椭圆的Canvas.Top
属性的。
现在,可以对ResourceDictionary
进行一些改进,例如引用外部文件并利用ResourceDictionary.MergedDictionaries
属性。
评论
Window
需要一个内容管理器来支持多个控件,但如果窗口中只有一个控件要放置,则提供一个控件实际上是可以接受的。
有几种内容提供程序,默认的Grid
类可能不是我想要的。
我发现Window.Content
属性的类型为“object
”令人恼火。了解属性如何使用的一种方法是提供一个类型而不是“object
”,即使它只是一个接口。使用“object
”阻碍了我的学习过程。
WPF在解析XAML方面的行为有些古怪,至少在一个实例中,关于执行与单步执行程序。如果这种情况比我上面发现的情况更普遍,那么调试WPF/XAML应用程序肯定会非常困难。
一个(基于一件坏事发生)的通用结论:WPF是否缺乏我期望的System.Windows.Forms
命名空间类在异常和错误管理方面的“润色”?
使用Visual Studio 2008 XAML设计器,请注意它很糟糕。
将文本附加到对象上
在我看来,这个过程的几乎每一步,而我只处理了第一个要求,都极其痛苦。我今天早上八点左右开始写这篇文章(在花了几个小时玩sukram的作品之后)。除了早餐、午餐和去杂货店,现在已经是下午4:30了。而且,我只完成了第一个要求!
现在,我想为我的椭圆添加文本,我又一次陷入困境,再次搜索Google,阅读CodeProject文章、博客、MSDN等。我发现WPF有一些不连贯的逻辑。事情并不符合逻辑。在这样高级的环境中您期望的东西不存在。而您*不*期望在这样高级的环境中出现的东西,猜猜怎么着,它们确实存在。在我学习框架的这些年里,这不能仅仅归结为“这是一个新框架”。我有一种深刻而令人失望的感觉,这是因为,说到底,WPF是一个相当糟糕的框架。然而,我看到人们用它做着惊人的事情,所以我将继续摸索,也许有一天,我会重读这篇文章,并嘲笑我的无知,甚至可以说是愚蠢。我猜读到这一部分的WPF人士正在地上打滚,认为我是个白痴。就这样吧。
那么,如何为我的椭圆添加或关联文本?没有“Text”属性。我尝试的一个可能的解决方案是创建一个TextBlock
作为子元素
<Canvas>
<Ellipse Height='78' Stroke='Black' Width='116' Canvas.Left='5' Canvas.Top='122'>
<TextBlock>Foobar</TextBlock>
</Ellipse>
</Canvas>
可惜不行,标记抱怨“'Ellipse'类型不支持直接内容”。嗯,这是一个线索。我想要“直接内容”。经过一番摸索,我想,嗯,也许TextBlock
可以托管一个Ellipse
,果然
<Canvas>
<TextBlock Height='78' Width='116' Canvas.Left='5' Canvas.Top='122'>Foobar
<Ellipse Stroke="Black" Height="50" Width="50"/>
</TextBlock>
</Canvas>
导致
但是,这也不是我想要的。能够将文本相对于椭圆或其他对象定位会很好。也许,我想要文本居中,或者顶部或底部居中。在我看来,我的整个方法都是错误的,我可能想要的是一个复合控件,它由文本、FrameworkElement
对象(如Ellipse
)和一个描述文本位置的枚举组成。
好吧,让我们暂时妥协。如果我取消将文本放置在中间的要求(这个要求本来就价值不大),那么我可以使用StackPanel
将文本放置在椭圆的上方或下方。如果我用XAML编写
<Canvas>
<StackPanel Canvas.Left="10" Canvas.Top="10" >
<TextBlock TextAlignment="Center">Foobar</TextBlock>
<Ellipse Stroke="Black" Height="50" Width="100"/>
</StackPanel>
</Canvas>
我得到
请注意,我不必为StackPanel
提供宽度和高度——它的默认行为是拉伸到子元素的范围。
但是,我在StackPanel
的属性中找不到任何内容来说明,是将此从上到下堆叠还是从下到上堆叠!您会发现StackPanel
在设置FlowDirection
属性为Horizontal
时提供从右到左和从左到右的选项,这会让您觉得很有趣。我猜在WPF设计者的脑海中,“堆叠”的概念意味着从上到下,即使在现实世界中东西是从下往上堆叠的。所以,暂时,我完全让步,并将文本放在底部。
现在,我还要做的另一件事是将StackPanel
标记移到我的ResourceDictionary
中。
评论
我希望能够
- 以某种方式移动文本相对于
FrameworkElement
对象,而不是StackPanel
提供的 - 以“整洁”的方式描述对象的尺寸
- 以“整洁”的方式设置文本
我所说的“整洁”,是指通过直接轻松地设置属性,而无需深入到Canvas
和StackPanel
子集合等等。换句话说,我想能够将模型实现与为常见模型属性(如位置、大小和标签)分配值分离。
完整代码
对于一篇这么长的文章来说,这很不起眼,因此没有为本文提供代码下载。
XAML代码
<Window x:Class="Lesson1Redux.Window1"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="Window1" Height="300" Width="300">
<Window.Resources>
<ResourceDictionary>
<StackPanel x:Key="Table" Canvas.Left="10" Canvas.Top="10">
<Ellipse Stroke="Black" Height="50" Width="100"/>
<TextBlock TextAlignment="Center">Foobar</TextBlock>
</StackPanel>
</ResourceDictionary>
</Window.Resources>
<Canvas/>
</Window>
以及以编程方式创建两个这种堆栈的命令式代码
public Window1()
{
InitializeComponent();
// Get my canvas.
Canvas canvas = (Canvas)Content;
// Add my table resource.
canvas.Children.Add(Clone((UIElement)Resources["Table"]));
Canvas.SetTop(canvas.Children[0], 10);
canvas.Children.Add(Clone((UIElement)Resources["Table"]));
Canvas.SetTop(canvas.Children[1], 100);
}
protected UIElement Clone(UIElement elem)
{
string str = XamlWriter.Save(elem);
StringReader sr = new StringReader(str);
XmlReader xr = XmlReader.Create(sr);
UIElement ret = (UIElement)XamlReader.Load(xr);
return ret;
}
结果是
结论
我只完成了我的要求的前两项,这篇文章就已经足够长了。我清楚地认识到,撰写关于WPF的文章通常会产生长篇大论,现在我明白了原因!事实上,这两项要求都没有得到我的满意实现,因此我将在了解更多之后重新审视它们。
另外,显然,这只是第一部分,因为我只完成了我的要求的头两项任务,而且我对第二项任务的妥协方式并不完全满意。
所以,所有WPF专家们——告诉我如何做得更好!
参考文献
- XAML中的控制反转(IoC)
- WPF架构设计器(第1-4部分)
- 依赖属性