XAML 与布局:从基础到熟练
上位机主窗口要同时放状态、曲线、配方、报警。这篇只解决「这些区域怎么摆」。控件里面填什么、数据怎么绑,是后面几篇。
熟练的标准:不用拖拽设计器,能用一个Grid把四块区域分成可拉伸的骨架,并且说得清某一块为什么用Auto、*或固定高度。
1. XAML 在写什么
XAML 是界面的声明。一个标签是一个对象,属性写成特性,子标签是它的内容。
<Windowx:Class="Demo.MainWindow"xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"Title="单工位检测"Height="640"Width="960"><Grid><TextBlockText="在线"/></Grid></Window>- 默认命名空间指向 WPF 的控件。
Grid、TextBlock、Button都从这里来。 xmlns:x是 XAML 语言自己的标记,例如x:Name、x:Class。x:Class把这个 XAML 和同名的 C# 分部类连在一起。窗口的构造函数在MainWindow.xaml.cs里,里面要有InitializeComponent()。- 没有
x:Name的控件,code-behind 里就点不到它。MVVM 里多数控件靠绑定,不需要名字。
窗口的内容只能有一个子元素。所以根上放一个Grid或StackPanel,区域都放进这个面板里,而不是把多个控件直接写在Window下面。
2. 基础:两种面板
StackPanel:沿一个方向排
<StackPanelOrientation="Horizontal"Margin="12"><TextBlockText="状态"VerticalAlignment="Center"/><TextBlockText="在线"Margin="12,0,0,0"/><ButtonContent="启动"Margin="12,0,0,0"/></StackPanel>Orientation="Horizontal"从左到右。默认是从上到下。适合状态栏里短的一排:标签、数值、按钮。
它不会把多余空间分给子元素。窗口拉高时,竖着的 StackPanel 下面是空的,子元素高度仍是自己的内容高度。所以主窗口不要只用一个 StackPanel 装着曲线和报警。
Grid:按行和列分
主界面用 Grid。
<Grid><Grid.RowDefinitions><RowDefinitionHeight="Auto"/><RowDefinitionHeight="*"/><RowDefinitionHeight="180"/></Grid.RowDefinitions><Grid.ColumnDefinitions><ColumnDefinitionWidth="2*"/><ColumnDefinitionWidth="*"/></Grid.ColumnDefinitions></Grid>三种尺寸:
| 写法 | 含义 | 上位机里放什么 |
|---|---|---|
Auto | 行高或列宽由内容决定 | 顶上的状态栏,里面就几个文本和按钮 |
* | 吃掉剩余空间。2*和*按 2:1 分 | 曲线区比配方区宽 |
180 | 固定像素 | 底部报警列表,不希望它被曲线挤没 |
子元素用附加属性声明自己落在哪一格。Grid.Row、Grid.Column、Grid.ColumnSpan定义在Grid上,写在子元素标签上,用来告诉 Grid 怎么摆这个子元素。它们不是 TextBlock、Border 自己的属性。XAML 里写成Grid.Row="1"即可。C# 里没有element.Grid.Row这种写法,用静态方法Grid.SetRow(element, 1)。登记一份新的附加属性见10.1-附加属性.md。
<TextBlockGrid.Row="0"Grid.Column="0"Text="状态栏"/><BorderGrid.Row="1"Grid.Column="0"Background="#111"/><BorderGrid.Row="1"Grid.Column="1"Background="#222"/><BorderGrid.Row="2"Grid.Column="0"Grid.ColumnSpan="2"Background="#333"/>不写Grid.Row和Grid.Column时,默认是第 0 行第 0 列。多个元素会叠在同一格,后写的盖住先写的,看起来像控件丢了。每一格只放一个直接子元素,里面再套面板。
Grid.ColumnSpan="2"横跨两列。底部报警条用它,从左到右通栏。Grid.RowSpan是跨行。
行和列都从 0 数。
测量和排列
面板摆孩子分两步。
- Measure:父元素问每个子元素理想要多大,子元素回报
DesiredSize。 - Arrange:父元素按测量结果,分给子元素一块实际矩形。
竖直的 StackPanel 沿垂直方向排列。测量子元素时,这一向给出的可用高度是无穷大(double.PositiveInfinity),子元素就按自己的内容回报高度。另一向仍是父元素给的真实宽度,文字和按钮不会在水平方向拿到无限空间。格子被拉高之后,多出来的高度不会再分给这些子元素,所以曲线区不会跟着变高。水平 StackPanel 则相反:宽度方向无穷大,高度方向是真实可用高度。
Grid 先扣掉Auto和固定尺寸,再把剩下的分给带*的行和列。主窗口骨架用 Grid,就是为了这一步。
XAML 里写出来的这层嵌套是逻辑树。按钮模板展开之后的边框和圆点在视觉树上,这篇还不涉及。差别见14-视觉树与逻辑树.md。
另外三种面板
主骨架不用它们。知道差别,面试问到能对上场景。
| 面板 | 特点 | 上位机里什么时候用 | 不要用在哪 |
|---|---|---|---|
| DockPanel | 子元素靠上、下、左、右。LastChildFill默认 true,最后一个子元素铺满剩下的区域 | 顶上一栏、底下一栏这种简单停靠 | 状态、曲线、配方、报警同时存在的主界面 |
| WrapPanel | 一行或一列排不下就换行 | 一组数量不固定的小按钮 | 主窗口骨架 |
| Canvas | 用Canvas.Left、Canvas.Top绝对定位。窗口拉大时子元素不跟着重新分空间 | 在固定画布上画简单图形 | 会随窗口缩放的业务界面 |
LastChildFill="False"时,最后一个子元素也只停靠在自己的边上,不再铺满剩余区域。
3. 熟练:对齐、边距、格子里再套一层
对齐
格子比内容大时,靠HorizontalAlignment和VerticalAlignment决定内容贴哪边。默认是Stretch,内容被拉满这一格。
控件写了明确的Width时,水平方向的Stretch不再起作用。宽已经是 200,就不会再被格子拉宽。垂直方向没写Height时,VerticalAlignment="Stretch"仍然有效。Height写死则只影响垂直这一向。某一向的Stretch生效,前提是这一向没有写死尺寸。
格子分到的矩形小于子控件想要的尺寸时,子控件会被排进这块更小的区域,显示不下的部分留在区域外面。ClipToBounds默认是 false,格子不会替你把画出界的图形裁掉,曲线有时会叠到旁边的配方区上。要把绘制限制在这一格里,给这格的Border或面板设ClipToBounds="True"。
状态栏里的文字不要拉伸,用Center或Left:
<TextBlockGrid.Row="0"Text="在线"HorizontalAlignment="Left"VerticalAlignment="Center"Margin="12,8"/>Margin="12,8"是左右 12、上下 8。Margin="12,8,12,8"按左、上、右、下。面板和面板之间留白用 Margin,不要用空的行来充数,也不要用很大的 Margin 把控件硬推到某个坐标。窗口一缩放,硬凑的位置就会错。
Margin是控件外面的空白,用来推开旁边的控件。Padding是控件里面的空白,边框到内容的距离。Border、Button有Padding。StackPanel没有Padding,它里面的空隙仍用子元素的Margin。配方区那层Border的Padding="12"是内边距,外面的Margin才是和曲线区之间的缝。
格子里再放面板
Grid 的一格里仍然只能直接放一个子元素。这一格里要横排多个按钮时,放一个 StackPanel:
<StackPanelGrid.Row="0"Grid.ColumnSpan="2"Orientation="Horizontal"Margin="12,8"><TextBlockText="状态"VerticalAlignment="Center"/><TextBlockText="待机"Margin="16,0,0,0"VerticalAlignment="Center"/><ButtonContent="启动"Margin="16,0,0,0"/><ButtonContent="停止"Margin="8,0,0,0"/></StackPanel>曲线那一格先放一个Border占位,后面换成 OxyPlot。配方那一格先放一个竖排 StackPanel。报警那一格先放一个ListBox,后面用数据模板画每一行。
剩余空间不够时
*行会被Auto和固定高度先扣掉。窗口高度 640,状态栏 Auto 约 40,报警 180,曲线区大约是 640 减去这两段,再减去窗口边框。把窗口拉小到比 40+180 还小时,*会被挤到最小,曲线先消失。报警用固定高度就是为了保住列表。若希望报警也可以被压缩,把它改成*,另外给MinHeight。
行和列自己就能限制范围,而且先于子控件生效:
<RowDefinitionHeight="*"MinHeight="120"/><ColumnDefinitionWidth="*"MinWidth="240"MaxWidth="420"/>MinHeight="120"表示这一行再分剩余空间,窗口缩小时至少留下 120。若*算出来的高度已经小于MinHeight,这一行会抬到最小高度,去挤其他*行。多行都设了最小高度,加起来超过窗口时,排不下的部分会被窗口裁掉,而不是各行继续按比例缩小。MaxWidth防止某一列在超宽屏幕上拉得过宽。写在RowDefinition、ColumnDefinition上,比只写在子控件上更稳。
两张互不相干的 Grid 要对齐同一列的宽度时,用SharedSizeGroup。外层设Grid.IsSharedSizeScope="True",两列的ColumnDefinition写同一个SharedSizeGroup名字。上位机里两张表要列对齐时才用,主骨架用不上。
需要操作员拖动曲线和配方的分界时,在两列之间放GridSplitter。它占自己的格子,或贴在某一列的边缘。拖的是列宽,不是用 Margin 模拟出来的位置。分割列时宽度写小,例如Width="4",并且VerticalAlignment="Stretch",分割条才会占满这一行的高度。只写HorizontalAlignment="Right"而不拉满垂直方向,分割条会缩成内容那么高,不好拖。分割行则相反:写Height,并且HorizontalAlignment="Stretch"。主骨架示例不必先加。做法放在练习里。
4. 后期应用:单工位检测的骨架
下面这一份可以直接当主窗口的第一版。没有绑定,没有命令,只证明四块区域的位置。数字和按钮文字是写死的,下一篇再换成控件细节,Binding 那篇再接数据。
<Windowx:Class="Demo.MainWindow"xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"Title="单工位检测"Height="640"Width="960"MinHeight="480"MinWidth="720"><Grid><Grid.RowDefinitions><RowDefinitionHeight="Auto"/><RowDefinitionHeight="*"/><RowDefinitionHeight="180"/></Grid.RowDefinitions><Grid.ColumnDefinitions><ColumnDefinitionWidth="2*"/><ColumnDefinitionWidth="*"MinWidth="240"/></Grid.ColumnDefinitions><StackPanelGrid.Row="0"Grid.ColumnSpan="2"Orientation="Horizontal"Margin="12,8"><TextBlockText="离线"VerticalAlignment="Center"/><TextBlockText="待机"Margin="16,0,0,0"VerticalAlignment="Center"/><TextBlockText="25.0"Margin="16,0,0,0"VerticalAlignment="Center"/><ButtonContent="启动"Margin="16,0,0,0"/><ButtonContent="停止"Margin="8,0,0,0"/></StackPanel><BorderGrid.Row="1"Grid.Column="0"Margin="12,0,6,8"Background="#1E1E1E"><TextBlockText="曲线"Foreground="White"Margin="8"/></Border><BorderGrid.Row="1"Grid.Column="1"Margin="6,0,12,8"Padding="12"><StackPanel><TextBlockText="配方"/><TextBlockText="上限"Margin="0,12,0,4"/><TextBoxText="30"/><TextBlockText="下限"Margin="0,12,0,4"/><TextBoxText="10"/><ButtonContent="保存"Margin="0,12,0,0"HorizontalAlignment="Left"/></StackPanel></Border><BorderGrid.Row="2"Grid.ColumnSpan="2"Margin="12,0,12,12"><TextBlockText="报警列表"Margin="8"/></Border></Grid></Window>拉窗口时的预期:
- 状态栏高度不变,按钮始终在一行。
- 曲线和配方一起变高。配方列有
MinWidth="240",不会被拉成一条缝。 - 报警区高度保持约 180,并且横跨两列。
这就是后期一直沿用的骨架。后面换曲线控件、换报警ListBox,只替换某一格里的子元素,行和列不用推倒。
5. 自测
- 为什么
Window下面不能并列写两个Button? - 状态栏为什么用
Auto,曲线区为什么用*? - 不写
Grid.Row时,元素出现在哪一格? - 报警条要同时占左右两列,写哪个附加属性?
- 配方区里有标签、输入框、保存按钮,这一格的直接子元素是什么?
Margin和Padding有什么差别?- 控件写了
Width="200",又写HorizontalAlignment="Stretch"。它会被格子拉宽吗?
参考答案:
- 窗口内容只能有一个子元素。多个控件要放进 Grid 或 StackPanel。
- 状态栏高度随文字和按钮,不参与瓜分剩余空间。曲线区吃掉扣掉状态栏和报警区之后剩下的高度。
- 第 0 行第 0 列。不写行和列的多个元素会叠在这里。
Grid.ColumnSpan="2"。行已经是第 2 行时,再写Grid.Row="2"。- 一个竖排的 StackPanel。Grid 的一格只有一个直接子元素,里面再排多个控件。
Margin在控件外面,推开周围控件。Padding在控件里面,是边框到内容的距离。Panel没有Padding,所以StackPanel、Grid都没有。Border和Button这类Control才有。- 不会。这一向已经有明确宽度,
Stretch不起作用。
常见错误
Window下面直接放多个控件,没有套 Grid。- 用竖直 StackPanel 做主界面,指望窗口拉高时曲线跟着变高。
- 同一格里放了多个直接子元素,控件叠在一起。
- 用很大的 Margin 把控件推到某个位置,窗口一缩放就错位。
- 写了固定
Width,还指望HorizontalAlignment="Stretch"把控件拉满。 - 把外边距和内边距当成同一个属性。配方区和曲线区之间的缝是
Margin,配方区边框到输入框的距离是Padding。
6. 练习
- 新建 WPF 项目,用第 4 节替换主窗口,运行后拖动窗口四边,对照上面三条预期。
- 把报警区改成
Height="*",再拖矮窗口,看曲线和报警谁被挤扁。然后改回180。 - 在状态栏右边再加一个「保存配方」按钮,不新开一行。做不到时,检查是不是还在用竖排 StackPanel。
- 在曲线和配方两列之间加一条
GridSplitter,拖动时只有这两列的宽度变化,状态栏和报警条的行高不变。分割条落在曲线那一格的右缘:Grid.Row="1"、Grid.Column="0"、HorizontalAlignment="Right"、VerticalAlignment="Stretch"、Width="4"。漏掉VerticalAlignment="Stretch"时,分割条高度不够,不好拖。主骨架示例可以先不加。
面试可能提问
主界面为什么用 Grid,而不是一个 StackPanel 把曲线和报警从上往下排?
答:StackPanel 不把剩余高度分给中间区域。窗口拉高时曲线区不会跟着变高。Grid 用*把扣掉状态栏和报警区之后的高度给曲线。Auto、*和固定像素各在什么时候用?
答:状态栏用Auto,高度由内容决定。曲线用*,吃剩余空间。报警列表用固定高度,避免被曲线挤没。Grid.Row写在子控件上,它是谁的属性?
答:是 Grid 的附加属性,用来声明这个子元素落在第几行。不写时默认第 0 行第 0 列,多个元素会叠在一起。窗口下面为什么不能直接放两个按钮?
答:Window的内容只能有一个子元素。多个控件要放进 Grid 或 StackPanel。某一格里还要再排多个控件时,这一格的直接子元素仍然只有一个面板。DockPanel的LastChildFill做什么?什么时候用GridSplitter?
答:LastChildFill默认是 true,最后一个子元素铺满剩下的区域。设为 false 时,它只停靠在自己的边上。操作员要拖动曲线和配方的分界时,用GridSplitter改列宽。主界面的四块区域仍然先用 Grid 的行和列,不用停靠面板来代替。控件不撑开、不收缩,先看布局的哪一步?
答:先看 Measure 和 Arrange。竖直 StackPanel 在排列方向上用无穷可用高度测量,子元素按内容要高度,多出来的高度不会分给它们;水平方向仍是真实宽度。Grid 才会把剩余空间分给*行。写死Width只让水平Stretch失效,没写Height时垂直方向仍可拉伸。