(三)深入浅出 WPF——系统学习 XAML 语法

7 阅读11分钟

回顾前面的例子,我们已经知道 XAML 是一种专门用于绘制 UI 的语言,借助它可以吧 UI 定义与逻辑分离开来。 XAML 使用标签来定义 UI 元素,每个标签对应 .NET Framework 类库中的一个控件类。通过设置 Attribute ,不但可以对标签所对应控件对象的 Property 进行赋值,还可以做一些额外的事件(如声明命名空间、指定类名等)。

3.1 XAML 文档的树型结构

与传统设计思维不同,XAML 使用树形逻辑结构来描述 UI。下面是用来描述上图的 XAML 代码。

<Window x:Class="WpfApplication1.Window1"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        Title="Window" Height="173" Width="296">
    <StackPanel>
        <TextBox x:Name="textBox1" Margin="5"/>
        <TextBox x:Name="textBox2" Margin="5"/>
        <StackPanel Background="LightBlue">
            <TextBox x:Name="textBox3" Margin="5"/>
            <TextBox x:Name="textBox4" Margin="5"/>
        </StackPanel>
        <Button x:Name="button1" Margin="5">
            <Image Source="p0009.png" Width="23" Height="23"/>
        </Button>
    </StackPanel>
</Window>

针对同一个“看上去一样”的 UI 布局,XAML 代码不一定是唯一的。

XAML 的树型结构对于 WPF 整个体系都具有非常重要的意义,它不但影响着 UI 的布局设计,还深刻影响着 WPF 的属性(Property)子系统和事件(Event)子系统等方方面面。在实践编程中,我们经常要在这棵树上进行按名称查找元素、获取父/子结点等操作,为了方便操作这棵树,WPF 基本类库里为程序员准备了 VisualTreeHelper 和 LogicalTreeHelper 两个助手类,同时还在一些重要的基类里封装了一些专门用于操作这个类的方法。

3.2 XAML 中为对象属性赋值的语法

XAML 是一种声明性语言,XAML 编译器会为每个标签创建一个与之对应的对象。对象创建出来之后要对它的属性进行必要的初始化之后才有使用意义。因为 XAML 语言不能编写程序的运行逻辑,所以一份 XAML 文档中除了使用标签声明对象就是初始化的属性了。

XAML 中为对象属性赋值共有两种语法:

  • 使用字符串进行简单赋值;
  • 使用属性元素(Property Element)进行复杂命名。

3.2.1 使用标签的 Attribute 为对象属性赋值

前面我们已经知道,一个标签的 Attribute 里有一部分与对象的 Property 互相对应,<Rectangle>标签的 Fill 这个 Attribute 就是这样——它与 Rectangle 类对象的 Fill 属性对应。在 MSDN 文档库里可以查到,Rectangle.Fill 的类型是 Brush,是一个抽象类。Brush 的派生类有很多:

  • SolidColorBrush:单色画刷。
  • LinearGradientBrush:线性渐变画刷。
  • RadialGradientBrush:径向渐变画刷。
  • ImageBrush:位图画刷。
  • DrawingBrush:矢量图话术。
  • VisualBrush:可视元素画刷。

假设我们的 Revtangle 只需要填充成单一的蓝色,那么只需要简单地写成:

<Window x:Class="WpfApplicationTree.Window1"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        Title="Window" Height="188" Width="300">
    <Grid VerticalAlignment="Center" HorizontalAignment="Center">
        <Rectangle x:Name="rectangle" Width="200" Height="120" Fill="Blue"/>
    </Grid>
</Window>

运行后可以看到,Blue 这个字符串被翻译成了 SolidColorBrush 对象并赋值给了 rectangle 对象。换成 C# 代码是这样:

SolidColorBrush sBrush = new SolidColorBrush();
sBrush.Color = Colors.Blue;
this.rectangle.Fill = sBrush;

需要注意的是,通过这种 Attribut=Value 语法赋值时,由于 XAML 的语法限制,Value 只可能是一个字符串值。这就出现了两个问题:

  • 如果一个类能使用 XAML 语言进行声明,并允许它的 Property 与 XAML 标签的 Attribute 互相映射,那么就需要为这些 Property 准备适当的转换机制。
  • 由于 Value 是个字符串,所以其格式复杂程度有限。

第一个问题的解决方案是使用 TypeConverter 类的派生类,在派生类里重写 TypeConvert 的一些方法,第二个问题的解决方案就是使用属性元素(Property Element)。

3.2.2 使用 TypeConverter 类将 XAML 标签的 Attribute 与对象的 Property 进行映射

首先,我们准备一个类:

public class Human
{
    public string Name { get; set; }
    public Human Child { get; set; }
}

现在我的期望是,如果在 XAML 里这样写:

<Window.Resources>
    <local:Human x:Key="human" Child="ABC"/>
</Window.Resources>

则能够为 Human 实例的 Child 属性赋一个 Human 类型的值,并且 Child.Name 就是这个字符串的值。

我们先看看直接写行不行。在 UI 上添加一个按钮 button1,并在它的 Click 事件处理器里写上:

private void button1_Click(object sender, RoutedEventArgs e)
{
    Human h = (Human)this.FindResource("human");
    MessageBox.Show(h.Child.Name);
}

编译没有问题,但在单机按钮之后程序抛出异常,告诉 Child不存在,为什么 Child 不存在呢?原因很简单,Human 的 Child 属性是 Human 类型,而 XAML 代码中的 ABC 是个字符串,编译器不知道如何把一个字符串实例转换成一个 Human 实例。解决的办法是使用 TypeConvert 和 TypeConverterAttribute 这两个类。

首先我们要从 Type Converter 类派生出自己的类,并重写它的一个 ConvertFrom 方法,这个方法有一个参数名为 value,就是 XAML 中的那个“字符串”:

public class StringToHumanTypeConverter: TypeConverter
{
    public overrider object ConvertFrom(ITypeDescriptionContext context, System.Globalization.CultureInfo culture, object value)
    {
        if (value is string)
        {
            Human h = new Human();
            h.Name = value as string; // 安全类型转换
            return h;
        }
    }
}

有了这个类还不够,还要使用 TypeConverterAttribute 这个特征类把 StringToHumanTypeConverter 这个类 “粘贴” 到作为目标的 Human 类上。

// [TypeConverterAttriubte(typeof(StringToHumanTypeConverter))]
// 特征类在使用的时候可以省略 Attribute 这个词
[TypeConverter(typeof(StringToHumanTypeConverter))]
public class Human
{
    public string Name { get; set; }
    public Human Child { get; set; }
}

3.2.3 属性元素

标签的内容指的就是夹在起始标签和结束标签之间一些子级标签,每个子级标签都是父级标签内容的一个元素(Element),简称为父级标签的一个元素。顾名思义,属性元素指的是某个标签的一个元素对应这个标签的一个属性,即依元素的形式来表达一个实例的属性。

<ClassName>
    <ClassName.Property>
        <!--以对象形式为属性赋值-->
    </ClassName.Property>
</ClassName>

这样,在这个标签的内部就可以使用对象进行赋值了。

如果把上面的例子用属性标签式语法改写一下,XAML 代码将是这样:

<Grid VerticalAlignment="Center" HorizontalAlignment="Center">
    <Rectangle x:Name="rectangle" Width="200" Height="120">
        <Rectangle.Fill>
            <SolidColorBrush Color="Blue"/>
        </Rectangle.Fill>
    </Rectangle>
</Grid>

效果与先前代码别无二致。所以,对于简单赋值而言属性元素语法并没有什么优势,反而让代码看起来有点冗长。但遇到属性是复杂的对象时这种语法的优势就体现出来了,如使用线性渐变画刷来填充这个矩形。

<Grid VerticalAlignment="Center" HorizontalAlignment="Center">
    <Rectangle x:Name="Rectangle" Width="200" Height="120">
        <Rectangle.Fill>
            <LinearGradientBrush>
                <LinearGradientBrush.StartPoint>
                    <Point X="0" Y="0"/>
                </LinearGradientBrush.StartPoint>
                <LinearGradientBrush.EndPoint>
                    <Point X="1" Y="1"/>
                </LinearGradientBrush.EndPoint>
                <LinearGradientBrush.GradientStops>
                    <GradientStopCollection>
                        <GradientStop Offset="0.2" Color="LightBlue"/>
                        <GradientStop Offset="0.7" Color="Blue"/>
                        <GradientStop Offset="1.0" Color="DarkBlue"/>
                    </GradientStopCollection>
                </LinearGradientBrush.GradientStops>
            </LinearGradientBrush>
            
        </Rectangle.Fill>
    </Rectangle>       
</Grid>

LinearGradientBush 的 GradientStops 属性是一个 GradientStop对象的集合,即一系列的矢量渐变填充点。在这些填充点之间,系统会自动进行插值计算、计算出过度色彩。

古语道:“过犹不及”。上面的代码为了突出属性元素语法将所有属性都展开成属性元素,结果是代码的可读性一落千丈。经过优化,代码变成这样:

<Grid VerticalAlignment="Center" HorizontalAlignment="Center">
    <Rectangle x:Name="Rectangle" Width="200" Height="120">
        <Rectangle.Fill>
            <LinearGradientBrush>
                <LinearGradientBrush.GradientStops>
                    <GradientStop Offset="0.2" Color="LightBlue"/>
                    <GradientStop Offset="0.7" Color="Blue"/>
                    <GradientStop Offset="1.0" Color="DarkBlue"/>
                </LinearGradientBrush.GradientStops>
            </LinearGradientBrush>
        </Rectangle.Fill>
    </Rectangle>       
</Grid>

一般情况下,对于复杂的绘图和动画创作,应该先在 Blend 里进行操作,然后回到 Visual Studio 里进行微调,尽可能地提高代码的可读性和可维护性。

3.2.4 标记扩展(Markup Extensions)

仔细观察 XAML 中为对象属性赋值的语法,你会发现大多数赋值都是为属性生成一个新对象。但有时候需要把同一个对象赋值给两个对象的属性,还有的时候需要给对象的属性赋一个 null 值,WPF 甚至允许将一个对象的属性值依赖在其他对象的某个属性上。当需要为对象的属性进行这些特殊类型赋值时就需要使用标记扩展了。

所谓标记扩展,实际上是一种特殊的 Attribute=Value 语法,其特殊的地方在于 Value 字符串是由一对花括号及其括起来的内容组成, XAML 编译器会对这样的内容做出解析、生成相应的对象。

本章内容重在讲述语法,不必深究下面代码的细节,只需要关注标记扩展的语法即可。

<Window x:Class="XAMLGrammer.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        Title="MainWindow" Height="110" Width="240">
    <StackPanel Background="LightSlateGray">
        <TextBox Text="{ Binding ElementName=slider1, Path=Value, Mode=OneWay }"/>
        <Slider x:Name="Slider" Margin="5"/>
    </StackPanel>
</Window>

其中, Text="{ Binding ElementName=slider1, Path=Value, Mode=OneWay }" 这句就是标记扩展了。我们分析一下这句代码:

  • 当编译器看到这句代码时就会把花括号里的内容解析成相应的对象。
  • 对象的数据类型名是紧邻做花括号的字符串。
  • 对象的属性由一串以逗号连接的字符串负责初始化。

如果使用 C# 3.0 的语法来创建一个 Binding 类的实例,最佳的语法应该是:

Binding binding = new Binding() { Source = slider1, Mode = BindingMode.OneWay };

标记扩展亦是对属性的赋值,所以完全可以使用属性标签的形式来替代标记扩展,只是简洁性使然没人那么做罢了。

尽管标记扩展的语法简洁方便,但并不是所有对象都能用标记扩展的语法来书写,只有 MarkupExtension 类的派生类(直接或间接均可) 才能使用标记扩展语法来创建对象。MarkupEntension 的直接派生类并不多,它们是:

  • System.Windows.ColorConvertedBitmapExtension
  • System.Windows.Data.BindingBase
  • System.Windows.Data.RelativeSource
  • System.Windows.DynamicResourceExtension
  • System.Windows.Markup.ArrayExtension
  • System.Windows.Markup.NullExtension
  • System.Windows.Markup.StaticExtension
  • System.Windows.Markup.TypeExtension
  • System.Windows.ResourceKey
  • System.Windows.StaticResourceExtension
  • System.Windows.TemplateBindingExtension
  • System.Windows.ThemeDictionaryExtension

最后,使用标记扩展时还需要注意以下几点:

  • 标记扩展是可以嵌套的,例如:Text={Binding Source={StaticResource myDataSource}, Path=PersonName}是正确的语法。
  • 标记扩展具由一些简写语法,例如{Binding Value, ...}{Binding Path=Value, ...}是等价的。这两种写法中,前者成为固定位置参数(Positional Parameters),后者成为具名参数(Name Parameters)。
  • 标记扩展类的类名均已单词 Extension 为后缀,在 XAML 使用它们的时候 Extension 后缀可以省略不写,比如写 Text={x:Static ...}与写Text="{x:StaticExtension ...}"是等价的。

3.3 事件处理器与代码后置

当一个 XAML 标签对应着一个对象时,这个标签的一部分 Attribute 会对应这个对象的 Property,还有一部分 Attribute 对应着对象的事件。

在 .NET 事件处理机制中,可以为对象的事件指定一个能与该事件匹配的成员函数,我们把这个函数称为“事件处理器”(Event Handler)。

<ClassName EventName="EventHandlerName" />

当我们为一个 XAML 标签的事件性 Attribute 进行赋值时,XAML 编辑器会自动为我们生成对应的事件处理器。事件处理器的函数声明与用于声明 Button.Click 事件的委托保持类型和参数上的一致,它的名字已经被拷贝到 XAML 代码中。

private void button1_Click(object sender, RoutedEventArgs e)
{

}

由于 C# 支持 partial 类,XAML 标签又可以使用 x:Class 特征指定将由 XAML 代码解析生成的类与哪个类合并,因此,我们完全可以把用于实现代码逻辑的 c# 代码放在一个文件里,把用于描述 UI 的 XAML 代码放在另一个文件里。这种将逻辑代码与 UI 代码分离、隐藏的形式就叫做“代码后置”(Code-Behind)。

最后,再介绍一个有意思的标签——x:Code,使用它可以把原来应该呆在后置代码里的 C# 代码搬到 XAML文件里。x:Code 的内容一定要使用 XML 语言的 <![CDATA[...]]> 转义标签。

<x:Code>
    <![CDATA[
        private void button1_Click(object sneder, RoutedEventArgs e) 
        {
            MessageBox.Show("Bye! Code-Behind!");
        }
        ]]>
</x:Code>

3.4 导入程序集和引用其中的名称空间

大多数情况下,根据架构设计一个程序会被分成若干个相对独立的模块来编写,每个模块可以独立编译、进行版本升级。模块与模块之间有时会有一些依赖关系。.NET 的模块称为程序集(Assembly)。

一般情况下,使用 VS 创建的是解决方案(Solution),一个解决方案就是一个完整的程序。解决方案中会包含若干个项目(Project),每个项目都是可以独立编译的,它的编译结果就是一个程序集。常见的程序集是以 .exe 为扩展名的可执行程序或者以 .dll 为扩展名的动态链接库,大多数情况下,我们说“引用其他程序集”的时候,说的都是动态链接库。因为 .NET 编程接口(Application Programming Interface, API)以类和类级别的单元为主,所以我们又常把引用程序集说成是引用类库

类库中的类一般都会安置在合适的名称空间中,名称空间的作用是避免同名类的冲突。

假设我的类库程序集名为 MyLibrary.dll,其中包含 Common 和 Controls 两个名称空间,而且已经把这个程序集引用进 WPF项目,那么在 XAML 中引用这两个名称空间的语法是:

xmlns:映射名="clr-namespace:类库中名称空间的名字;assembly=类库文件名"

对于 MyLibrary.dll 里的两个名称空间,XAML 中的引用回事:

xmlns:common="clr-namespace:Common;assembly=Mylibrary"
xmlns:controls="clr-namespace:Controls;assembly=Mylibrary"

使用名称空间里的类,语法格式是:

<映射名:类名></映射名:类名>

例如使用 Common 和 Controls 中的类,代码是这样的:

<common:MessagePanel x:Name="window1"/>
<controls:LedButton x:Name="button1"/>

3.5 XAML 的注释

XAML 的注释语法继承自 XML。语法是:

<!--需要备注的内容-->

3.6 小结

至此,我们已经走马观花地了解了 XAML 的基本语法。知识虽然不多,但足以保证拿给我们写出美观的程序。