5.4 UI 布局(Layout)
在开始学习这些布局元素之前,首先提醒大家一句:设计静态布局的时候不能一味的追求简单,如果各静态布局间还有动画作为联系,就还需要考虑与动画设计的兼容性。
5.4.1 布局元素
传统的 Windows Form 或 ASP.NET 开发中,一般是把窗体或页面当做一个以左上角为原点的坐标系。窗体或页面上的控件依靠着坐标系来布局,布局的办法就是调整控件在这个坐标系中的横纵坐标值。这样一来,控件与控件的关系要么就是相邻要么就是叠压。
WPF 的控件有了 Content 概念,所以控件与控件之间又多出了一种关系——包含。也正是这种以窗体为根的包含关系,整个 WPF 的 UI 才形成树形结构,我们称之为可视化树(Visual Tree)
布局元素属于 Panel 族,这一族元素的内容属性是 Children,即可以接受多个控件作为自己的内容并对这些控件进行布局控制。WPF 的布局理念就是把一个布局元素作为 ContentControl 或 HeaderedContentControl 族控件的 Content,再在布局元素里添加要被布局的子级控件,如果 UI 局部需要更复杂的布局,那就在这个区域放置一个子级的布局元素,形成布局元素的嵌套。
WPF 的布局元素有如下几个:
- Grid(网格):可以自定义行和列并通过行列的数量、行高和列宽来调整控件的布局。近似于 HTML 中的 Table。
- StackPanel(栈式面板):可将包含的元素在竖直或水平方向上排成一条直线,当移出一个元素后,后面的元素会自动向前移动以填充空缺。
- Canvas(画布):内部元素可以使用像素为单位的绝对坐标进行定位,类似于 Windows Form 编程的布局方式。
- DockPanel(泊靠式面板):内部元素可以选择泊靠方向。
- WrapPanel(自动折行面板):内部元素在排满一行后能够自动折行,类似于 HTML 中的流式布局。
5.4.2 Grid
Grid 的特点如下:
- 可以定义任意数量的行和列,非常灵活;
- 行的高度和列的宽度可以使用绝对数值、相对比例或自动调整的方式进行精确设定,并可设置最大和最小值。
- 内部元素可以设置自己所在的行和列,还可以设置自己的纵向跨几行、横向跨几列。
- 可以设置 Children 元素的对齐方式。
基于这些特点,Grid 适用的场合有:
- UI 布局的大框架设计。
- 大量 UI 元素需要成行或成列对齐的情况。
- UI 整理尺寸改变时,元素需要保持固有的高度或宽度比例。
- UI 后期可能有较大变更或扩展。
- 定义 Grid 的行和列
Grid 类局域 ColumnDefinitions 和 RowDefinitions 两个属性,它们分别是 ColumnDefinition 和 RowDefinition 的集合,表示 Grid定义了多少列、多少行。例如下面的代码:
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition />
<ColumnDefinition />
<ColumnDefinition />
<ColumnDefinition />
<ColumnDefinition />
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition />
<RowDefinition />
<RowDefinition />
</Grid.RowDefinitions>
</Grid>
如果需要动态地调整 Grid 的布局,可以在 C# 完成对行和列的定义。假设窗体包含一个名为 gridMain 的 Grid 元素,我们为这个窗体的 Loaded 事件准备了如下的处理器:
private void MainWindow_OnLoaded(object sender, RoutedEventArgs e)
{
// Add 4 columns
this.GridMain.ColumnDefinitions.Add(new ColumnDefinition());
this.GridMain.ColumnDefinitions.Add(new ColumnDefinition());
this.GridMain.ColumnDefinitions.Add(new ColumnDefinition());
this.GridMain.ColumnDefinitions.Add(new ColumnDefinition());
// Add 3 rows
this.GridMain.RowDefinitions.Add(new RowDefinition());
this.GridMain.RowDefinitions.Add(new RowDefinition());
this.GridMain.RowDefinitions.Add(new RowDefinition());
this.GridMain.ShowGridLines = true;
}
只定义行和列的个数还远远不够,我们还需要设置行的高度和列的宽度才能形成有意义的布局。这就引出两个问题:
- 宽度和高度的单位是什么。
- 宽度和高度可以取什么值。
第一个问题,计算机图形设计的标准单位是像素(Pixel),所以 Grid 的宽度和高度的单位就是像素。除了可以使用像素单位,Grid 还接受英寸(Inch)、厘米(Centimeter)和点(Point)作为单位。
<Grid x:Name="GridMain">
<Grid.RowDefinitions>
<RowDefinition Height="30px" />
<RowDefinition Height="30" />
<RowDefinition Height="0.5in" />
<RowDefinition Height="1cm" />
<RowDefinition Height="30pt" />
</Grid.RowDefinitions>
</Grid>
上面的代码有几点值得注意的地方:
- 属性的值为 double 类型。
- 因为像素是默认单位,所以 px 可以省略。
- 其他单位也会被转换成像素并显示在 Grid 的边缘处。
第二个问题,对于 Grid 的行高和列宽,可以设置三类值:
- 绝对值:double 数值加单位后缀。
- 比例值:double 数值后面加一个星号。
- 自动值:字符串 auto。
- 使用 Grid 进行布局
让我们通过一个实例来学习用 Grid 布局。
在开始使用 Grid 之前,先说一个初学者常见的错误——滥用 Margin。Margin 即留白,指可视元素四周距离其容器的距离。对于简单的布局,Height+Width+Margin 尚能应付,但对于结构复杂的布局这种方式就吃不消了。代码中满篇都是对 Height、Width、Margin,不光读起来费劲,而且有时一个小小的改动都可能导致大量的代码修改,甚至导致整个 UI 布局的崩溃,着实令人抓狂。
拿上面的这个设计来说,如果使用 Height+Width+Margin 的方式来设计,代码会是这样:
<Window x:Class="ControlAndLayout.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:local="clr-namespace:ControlAndLayout"
mc:Ignorable="d"
Title="留言板" Height="240" Width="400">
<Grid>
<TextBox Text="请选择您的部门并留言:" Margin="10,10,0,0" Width="140" VerticalAlignment="Top" HorizontalAlignment="Left"/>
<ComboBox Height="25" Width="210" VerticalAlignment="Top" Margin="0,10,10,10" HorizontalAlignment="Right"/>
<TextBox BorderBrush="Black" Margin="10, 40, 10, 40"/>
<Button Content="提交" Height="25" Width="80" VerticalAlignment="Bottom" HorizontalAlignment="Right" Margin="0,0,96,10"/>
<Button Content="清除" Height="25" Width="80" VerticalAlignment="Bottom" HorizontalAlignment="Right" Margin="0,0,10,10"/>
</Grid>
</Window>
这样的代码,简直称得上是凌乱不堪的典范!更重要的是,它没能忠实地重现设计师的需求——要求静态文本的宽度由内容决定。这就意味着,如果有一天这个程序进行国际化修改时,如果不是出现字符串被截断就是留下大量空白。进而,如果设计师要求把空间距离窗体的距离由原来的 10px 修改为 12px,则几乎所有控件的 Height、Width 和 Margin 都需要修改。若是在程序员完成修改,设计师感觉还是 10px 比较好、要求再改回去,估计一场邮件大战在所难免。
正确的办法是使用 Grid 来进行布局:
<Grid Margin="10" ShowGridLines="True">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" MinWidth="120"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="80"/>
<ColumnDefinition Width="4"/>
<ColumnDefinition Width="80"/>
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="25"/>
<RowDefinition Height="4"/>
<RowDefinition Height="*"/>
<RowDefinition Height="4"/>
<RowDefinition Height="25"/>
</Grid.RowDefinitions>
</Grid>
使用 MinWidth="120" 保证这一列不会小于 120px(目的是在设计期看到这一列存在)。
最后,我们把控件填进去,同时为了保证布局美观,限定窗体的高度和宽度的范围:
<Window x:Class="ControlAndLayout.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:local="clr-namespace:ControlAndLayout"
mc:Ignorable="d"
Title="留言板" Height="240" Width="400"
MinHeight="200" MinWidth="340" MaxHeight="400" MaxWidth="600">
<Grid ShowGridLines="True" Margin="10">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="80"/>
<ColumnDefinition Width="4"/>
<ColumnDefinition Width="80"/>
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="25"/>
<RowDefinition Height="4"/>
<RowDefinition Height="*"/>
<RowDefinition Height="4"/>
<RowDefinition Height="25"/>
</Grid.RowDefinitions>
<TextBox Text="请选择您的部门并留言:" Grid.Column="0" Grid.Row="0" VerticalAlignment="Center"/>
<ComboBox Grid.Column="1" Grid.Row="0" Grid.ColumnSpan="4" />
<TextBox Grid.Column="0" Grid.Row="2" Grid.ColumnSpan="5" BorderBrush="Black" />
<Button Content="提交" Grid.Column="2" Grid.Row="4"/>
<Button Content="取消" Grid.Column="4" Grid.Row="4"/>
</Grid>
</Window>
注意,为控件指定行和列遵循以下规则:
- 行和列都是从 0 开始计数。
- 指定控件在某行,使用 Grid.Row="行编号" 这个 Attribute。列用 Grid.Column="列编号"。
- 若控件需要跨多行或列,请使用 Grid.RowSpan="行数" 和 Grid.ColumnSpan="列数"
注意,如果把两个元素放在 Grid 的同一个单元格内,则代码中后书写的元素将盖在先书写的元素之上。如果想让盖在后面的元素显示出来,可以把上面元素的 Visibility 设置为 Hidden 或 Collapsed,也可以把上面元素的 Opacity 属性设置为0
5.4.3 StackPanel
StackPanel 可以把内部元素在纵向或横向上紧凑排列、形成栈式布局。基于这个特点,StackPanel 适合的场合有:
- 同类元素需要紧凑排列。
- 移除其中的元素后能够自动补缺的布局或动画。
StackPanel 使用 3 个属性来控制内部元素的布局,具体如下表所示。
| 标题 | 数据类型 | 可取值 | 描述 |
|---|---|---|---|
| Orientation | Orientation 枚举 | Horizontal Vertical | 决定内部元素是横向累积还是纵向累积 |
| HorizontalAlignment | HorizontalAlignment 枚举 | Left Center Right Stretch | 决定内部元素水平方向上的对齐方式 |
| VerticalAlignment | VerticalAlignment 枚举 | Left Center Right Stretch | 决定内部元素竖直方向上的对齐方式 |
<Window x:Class="ControlAndLayout.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="选择题" Height="190" Width="300">
<Grid Header="请选择没有错别字的成语" BorderBrush="Black" Margin="5">
<StackPanel>
<CheckBox Content="A. 迫不及待"/>
<CheckBox Content="B. 首曲一指"/>
<CheckBox Content="C. 陈词烂调"/>
<CheckBox Content="D. 哀声叹气"/>
<CheckBox Content="E. 不可礼喻"/>
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right">
<Button Content="清空" Width="60" Margin="5" />
<Button Content="确定" Width="60" Margin="5" />
</StackPanel>
</StackPanel>
</Grid>
</Window>
5.4.4 Canvas
Canvas 译成中文就是“画布。使用 Canvas 布局与在 Windows Form 窗体上布局基本上是一样的,只是在 Windows Form 开发时可以通过设置控件的 Left 和 Top 等属性来确定控件在窗体上的位置,而 WPF 的控件没有 Left 和 Top 等属性,当控件被放置在 Canvas 里时就会被附加上 Canvas.X 和 Canvas.Y 属性。
Canvas 很容易被从 Windows Form 迁移过来的程序员所滥用,实际上大多数时候我们都可以使用 Grid 或 StackPanel 等布局元素产生更简洁的布局。Canvas 适用的场合包括:
- 一经设计基本上不会再有改动的小型布局(如图标)。
- 艺术性布局比较强的布局。
- 需要大量使用横纵坐标进行绝对定位的布局。
- 依赖于横纵坐标的动画。
下面的代码是一个使用 Canvas 代替 Grid 设计的登录窗口,除非你确定这个窗口的布局以后不会改变而且窗体尺寸固定,不然还是用 Grid 进行布局弹性会更好。
<Window x:Class="ControlAndLayout.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="登陆" Height="140" Width="300">
<Canvas>
<TextBlock Text="用户名:" Canvas.Left="12" Canvas.Top="12" />
<TextBlock Height="23" Width="200" BorderBrush="Black" Canvas.Left="66" Canvas.Top="9" />
<TextBlock Text="密码:" Canvas.Left="12" Canvas.Top="40.72" Height="16" Width="36"/>
<TextBlock Height="23" Width="200" BorderBrush="Black" Canvas.Left="66" Canvas.Top="9" />
<Button Content="确定" Height="22" Width="80" Canvas.Left="100" Canvas.Top="67" />
<Button Content="清楚" Height="22" Width="80" Canvas.Left="186" Canvas.Top="67" />
</Canvas>
</Window>
5.4.5 DockPanel
DockPanel 内的元素会被附加上 DockPanel.Dock 这个属性,这个属性的数据类型为 Dock 枚举。Dock 枚举可取 Left、Top、Right 和 Bottom 四个值。根据 Dock 属性值,DockPanel 内的元素会向指定方向累积、切分 DockPanel 内部的剩余可用空间。
DockPanel 还有一个重要属性——bool 类型的 LastChildFill,它的默认值为 True。当该值为 True 时,DockPanel 内最后一个元素的 Dock 属性会被忽略,并把 DockPanel 内部所有剩余的空间充满。
<Window x:Class="ControlAndLayout.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="登陆" Height="140" Width="300">
<Grid>
<DockPanel>
<TextBox DockPanel.Dock="Top" Height="25" BorderBrush="Black"/>
<TextBox DockPanel.DOck="Left" BorderBrush="Black" />
<TextBox BorderBrush="Black" />
</DockPanel>
</Grid>
</Window>
看到这个效果图,很自然让人想到能不能在下部两个 TextBox 之间加上一个可拖拽的分隔栏,让用户能调整调整 TextBox 的宽度。可惜,DockPanel 不具备这样的功能,我们只能使用 Grid 和 GridSplitter 来实现这个需求(GridSplitter会改变Grid初始设置的行高或列宽)。
5.4.6 WrapPanel
WrapPanel 内部采用的是流式布局。WraoPanel 使用 Orientation 属性来控制流延伸的方向,使用 HorizontalAlignment 和 VerticalAlignment 两个属性控制内部空间的对齐。在流延伸的方向上,WrapPanel 会排列尽可能多的控件,排不下的控件将会新起一列或一行继续排列。
<Window x:Class="ControlAndLayout.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="Window1" Height="300" Width="400"
MinHeight="200" MinWidth="340" MaxHeight="400" MaxWidth="600">
<WrapPanel>
<Button Width="50" Height="50" Content="OK"/>
<Button Width="50" Height="50" Content="OK"/>
<Button Width="50" Height="50" Content="OK"/>
<Button Width="50" Height="50" Content="OK"/>
<Button Width="50" Height="50" Content="OK"/>
<Button Width="50" Height="50" Content="OK"/>
<Button Width="50" Height="50" Content="OK"/>
<Button Width="50" Height="50" Content="OK"/>
<Button Width="50" Height="50" Content="OK"/>
</WrapPanel>
</Window>