WPF/Silverlight Layout 系统概述——Measure
前言
在WPF/Silverlight当中,如果已经存在的Element无法满足你特殊的需求,你可能想自定义Element,那么就有可能会面临重写MeasureOverride和ArrangeOverride两个方法,而这两个方法是WPF/SL的Layout系统提供给用户的自定义接口,因此,理解Layout系统的工作机制,对自定义Element是非常有必要的。那么,究竟WPF/SL的Layout系统是怎么工作的呢?接下来,我简单的描述一下,然后,在后面的章节具体分析。
简单来说,WPF的Layout系统是一个递归系统,他有两个子过程,总是以调用父元素的Measure方法开始,以调用Ararnge方法结束,而进入每个子过程之后,父元素又会调用孩子元素的Measure,完成后,又调用孩子元素的Arrange方法,这样一直递归下去。而对两个子过程的一次调用,可以看作是一次会话,可以理解为下图所示:
这个会话可以用下面一段话描述:
子过程1: 父根据自己的策略给孩子一个availableSize,并发起对话,通过调用孩子的Measure(availableSize)方法,询问孩子:你想要多大的空间显示自己?孩子接到询问后,根据父给的availableSize以及自己的一些限制,比如Margin,Width,等等,孩子回答:我想要XXX大小的空间。父拿到孩子给的期望的空间大小后,根据自己的策略开始真正给孩子分配空间,就进入第二个子过程。
子过程2: 父拿到孩子的期望空间后,再根据自己的情况,决定给孩子分配finalRect大小的矩形区域,然后他发起对话,调用孩子的Arrange(finalRect)给孩子说:我给你了finalRect这么大的空间。孩子拿到这个大小后,会去布置它的内容,并且布置完成后,会告诉父:其实我用了XXX大小的空间来绘制我自己的内容。父知道后,什么也没说,还是按照分配给他的finalRect去安置孩子,如果孩子最终绘制的区域大于这个区域,就被父裁剪了。Layout过程完成。
通过上面两个子过程的理解,或多或少对WPF的Layout系统有个初步的了解,接下来的章节,我具体描述Measure过程和Arrange过程具体做了哪些事情,帮助你跟深入的理解Layout系统。
预设条件
通过下面的一个预设场景,我们来展开Layout系统的讲解。
假定:我们需要自定义一个Panel,类型为 *MyPanel* ,MyPanel的父为 *MyPanelParent* ,也是一个Panel;MyPanel的孩子为 *MyPanelChild* ,也是一个Panel。
切入点1:重写MyPanelParent的MeasureOverride()和ArrangeOverride(),研究父如何影响孩子MyPanel的Layout;
切入点2:重写MyPanel.MeasureOverride()和ArrangeOverride方法,研究自身有哪些属性影响MyPanel的Layout,以及重写这两个方法时应该注意的点;
注意:后面的研究,我只基于Element的Width,也就是水平方向的维度,所有的数据都是只设置水平方向的,垂直方向设置的跟水平方向一致,但不做描述。
Measure过程概述
1. 普通基类属性对Measure过程的影响
请看下面的一些设置:
<Window x:Class="WpfApplication1.MainWindow" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" Title="MainWindow" Height="522" Width="594" Loaded="Window_Loaded" xmlns:my="clr-namespace:WpfApplication1"> <Canvas> <my:MyPanelParent x:Name="myPanelParent1" Height="400" Width="400" Background="Green" Canvas.Left="10" Canvas.Top="10"> <my:MyPanel Margin="10" x:Name="myPanel1" Background="Red" MinWidth="150" Width="200" MaxWidth="250"/> <my:MyPanel Margin="10" x:Name="myPanel2" Background="Red" MinWidth="150" Width="200" MaxWidth="250"/> </my:MyPanelParent> </Canvas> </Window>
public class MyPanelParent:Panel { protected override System.Windows.Size MeasureOverride(System.Windows.Size availableSize) { foreach (UIElement item in this.InternalChildren) { item.Measure(new Size(120, 120));//这里是入口 } return availableSize; } protected override System.Windows.Size ArrangeOverride(System.Windows.Size finalSize) { double x = 0; foreach (UIElement item in this.InternalChildren) { item.Arrange(new Rect(x, 0, item.DesiredSize.Width, item.DesiredSize.Height)); x += item.DesiredSize.Width; } return finalSize; } } public class MyPanel : Panel { protected override System.Windows.Size MeasureOverride(System.Windows.Size availableSize) { foreach (UIElement item in this.InternalChildren) { item.Measure(availableSize); } return new Size(50, 50);//MyPanel 返回它期望的大小 } protected override System.Windows.Size ArrangeOverride(System.Windows.Size finalSize) { double xCordinate = 0; foreach (UIElement item in this.InternalChildren) { item.Arrange(new Rect(new Point(xCordinate, 0), item.DesiredSize)); xCordinate += item.DesiredSize.Width; } return finalSize; } }
在上面的设置之后,应用程序运行起来之后,Window的表现为:
分析一下设置:
MyPanel1.Width = 200, MyPanel1.MinWidth = 150, MyPanel1.MaxWidth = 250, MyPanel1.Margin = Thickness(10)
MyPanel1.Measure()传入的参数为120*120,MyPanel1.MeasureOverride返回的参数为50*50
分析一下结果:
MyPanel1实际的画出来的大小(红色部分)是100*50
从结果可以看出,红色的部分受多个因素的影响,有人要问,我已经设置了MyPanel.Width=200,可是怎么画出来的Width却是100;MyPanel.Height没设置,可是画出来的却是50,为什么不是其他值。接下来我通过Measure的流程图说明一下这个结果是怎么来的:
看了上图,有些人可能会看出一些端倪,也可能还不是很清晰,我按照自己的理解总结一下Measure过程究竟想干什么?
1. 第一点很清晰,MyPanelParent调用MyPanel.Measure的过程是想得到MyPanel.DesiredSize,MyPanelParent需要在Arrange孩子MyPanel时,参考孩子的DesiredSize,决定将孩子MyPanel安置多大的空间。
2. MyPanel.DesiredSize是包含Margin以及内容的大小空间
3. MyPanel.MeasureOverride传入的参数constrainedSize,是基类的实现刨去Margin的大小,然后按照MyPanel对MinWidth,MaxWidth,Width的设置计算的一个MyPanel想要的值,我们自定义时在MeasureOverride当中不需要关心自己的Margin,以及其他基类上影响Layout的属性,只要考虑在给定参数的范围类安排自己的内容区域;MyPanel.MinWidth,Width, MaxWidth的设定都是针对内容区域的,不含Margin部分
4. 如果不设定Width,那么可以在MeasureOverride返回的时候返回一个期望的内容区域大小,它会被MinWidth和MaxWidth再调整一下,调整后,还有待于MyPanelParent的衡量(旁白:别瞎折腾,也别玩Layout系统,都设置MinWidth,MaxWidth,就乖乖的呆在这个范围内。)
5. 不论MyPanel怎么设置自己的Width,MinWidth,MaxWidth,以及在MeasureOverride返回一个大小,来表明自己期望多大的空间显示自己的内容,但这些都仅仅是期望的,期望是美好的,现实是残酷的,这一切还必须限定在MyPanel.Measure开始时传入的参数availableSize刨去MyPanel.Margin后的范围内,小于这个范围就满足,大于这个范围就被裁断。(可怜呀,总是受制于父)
6. 影响Measure过程的参数和属性存在一个优先级的,大概如下所示:
Measure方法参数availableSize>MinWidth,Width,MaxWidth > MeasureOverride返回值
2. Transform对Measure过程的影响
通过上面的过程,我们已经大概了解了Measure过程的工作方式,以及各个属性是如何影响的。但是还有一个属性我们没有提及,但它对Measure的过程也影响甚大,这就是LayoutTransform。通过下面的两段分析,你会看到这个属性的具体表现。
设置1:
<Window x:Class="WpfApplication1.MainWindow" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" Title="MainWindow" Height="522" Width="594" Loaded="Window_Loaded" xmlns:my="clr-namespace:WpfApplication1"> <Canvas> <my:MyPanelParent x:Name="myPanelParent1" Height="400" Width="400" Background="Lime" Canvas.Left="10" Canvas.Top="10"> <my:MyPanel Margin="10" x:Name="myPanel1" Background="Red" Width="200"> <my:MyPanel.LayoutTransform> <RotateTransform Angle="90"/> </my:MyPanel.LayoutTransform> </my:MyPanel> <my:MyPanel Margin="10" x:Name="myPanel2" Background="Red" MinWidth="150" MaxWidth="250"/> </my:MyPanelParent> </Canvas> </Window>
public class MyPanelParent:Panel { protected override System.Windows.Size MeasureOverride(System.Windows.Size availableSize) { foreach (UIElement item in this.InternalChildren) { item.Measure(new Size(1000, 800)); } return availableSize; } protected override System.Windows.Size ArrangeOverride(System.Windows.Size finalSize) { double x = 0; foreach (UIElement item in this.InternalChildren) { item.Arrange(new Rect(x, 0, item.DesiredSize.Width, item.DesiredSize.Height)); x += item.DesiredSize.Width; } return finalSize; }
public class MyPanel : Panel { protected override System.Windows.Size MeasureOverride(System.Windows.Size availableSize) { foreach (UIElement item in this.InternalChildren) { item.Measure(availableSize); } return new Size(80, 50); } protected override System.Windows.Size ArrangeOverride(System.Windows.Size finalSize) { double xCordinate = 0; foreach (UIElement item in this.InternalChildren) { item.Arrange(new Rect(new Point(xCordinate, 0), item.DesiredSize)); xCordinate += item.DesiredSize.Width; } return finalSize; } }
运行的表现为:
分析一下设置:
MyPanel1.LayoutTransform = new RotateTransform(90)//旋转了90度
MyPanel1.Width = 200
MyPanel1.Margin = Thickness(10)
MyPanel1.Measure()传入的参数为1000*800,MyPanel1.MeasureOverride返回的参数为80*50.
分析一下结果:
MyPanel1实际的画出来的大小是50×200,明显是被旋转了90度。
运行起来,你会发现最终的MyPanel1.DesiredSize在Measure过程之后为70×220,也就是说,它是被Transform之后的大小,明显是被旋转过的。另外,观察MyPanel.MeasureOverride传入的参数,为200×980,根据上一节对Measure过程的分析,MeasureOverride传入的参数宽为200是可预知的,因为我们设置了MyPanel1.Width为200,但Height为980,明显是MyPanel.Measure传入的宽1000减去2*10等于980,看来在进入MeasureOverride之前,Layout系统也处理了LayoutTransform对Measure过程的影响,它希望MeasureOverride不要关心自身LayoutTransform的影响。MeasureOverride结束后,返回值为80×50,根据上一节对Measure过程的分析,宽为80被调节为符合自己的设置,为200,由于高没有设置,这个50肯定会保留,因此最后在没有Transform之前的DesiredSize应该是220×70,然而基类会将MeasureOverride返回的大小再进行一次Transform,达到最终的DesiredSize的大小,以便Arrange的时候分配合适的空间来容纳MyPanel的大小。
如果你将上面例子的MyPanel1.LayoutTransform设置成ScaleTransform:
<Window x:Class="WpfApplication1.MainWindow" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" Title="MainWindow" Height="522" Width="594" Loaded="Window_Loaded" xmlns:my="clr-namespace:WpfApplication1"> <Canvas> <my:MyPanelParent x:Name="myPanelParent1" Height="400" Width="400" Background="Lime" Canvas.Left="10" Canvas.Top="10"> <my:MyPanel Margin="10" x:Name="myPanel1" Background="Red" Width="200"> <my:MyPanel.LayoutTransform> <ScaleTransform ScaleX="2" ScaleY="2"/> </my:MyPanel.LayoutTransform> </my:MyPanel> <my:MyPanel Margin="10" x:Name="myPanel2" Background="Red" MinWidth="150" MaxWidth="250"/> </my:MyPanelParent> </Canvas> </Window>
然后再观察myPanel.MeasureOverride传入的参数,为200×390,首先200是可预知的,因为设置了Width属性,而390是怎么回事呢,其实为Measure传入的1000×800的高800减去Margin为20后得到780,然后根据LayoutTransform将高缩小2倍之后得到的390,因此传入的参数就是200×390,可见,Layout系统,在进入MeasureOverride之前,他希望,MeasureOverride只关心内容怎么布置,而不需要关心基类属性的设置对MeasureOverride的影响。由于MeasureOverride的返回值依然是80×50,可推理,80被调节为200,50被保留,没有Transform之前的值应该是200×50。因为基类还要进行Transform,因此,内容区域的真实的大小应该是400×100,再加上Margin之后,最终的DesiredSize肯定为420*120,你可以尝试调试给出的代码。
3. Measure过程的总结
Measure过程的总结
通过上面的过程分析,我相信你或多或少对WPF的Layout系统的Measure过程有了更进一步的了解,其实还有一些因素影响Measure的过程,比如UseLayoutRounding属性,在进入MeasureOverride之前和之后,基类都被将参数根据DPI进行Rounding,这个过程知道就行了,不需要在自己的MeasureOverride里面关心。我们总结一下哪些属性和参数会影响Measure的过程:MyPanel.Measure传入的参数availableSize,MyPanel的MinWidth, Width, MaxWidth,Margin,UseLayoutRounding,LayoutTransform,MeasureOverride的返回值。
Measure过程相关问题解答
Q1:什么是Layout Slot? 什么时候能获取到?在哪里获取?
Layout Slot就是调用Arrange方法的时候,传入的参数finalRect,这是父分配给子的容纳Margin以及内容区域的矩形空间;
当Arrange过程结束后,你可以拿到;
通过调用静态类LayoutInformation.GetLayoutSlot(FrameworkElement element)方法可以拿到。
Q2:什么是Layout Clip?什么时候能获取到?在哪里获取?
Layout Clip 只的是当内容区域要绘制的大小,大于LayoutSlot刨去Margin区域后的大小,这时候,内容区域就会被Clip,超出的部分会被Clip掉,而剩下的可显示的部分就是Layout Clip,他是一个Geometry。
Arrange过程结束后,可以拿到;
通过调用静态类LayoutInformation.GetLayoutClip(FrameworkElement element)方法可以拿到。如果内容区域可以完全显示
在Layout Slot刨去Margin的区域内,LayoutClip为Null。
Q3:在父的MeasureOverride当中调用孩子的Measure方法时,传入的参数有没有什么限制?
有,确保availableSize.Width和Height不是NaN;但可以是Infinity
Q4:在进入自己的MeasureOverride方法后,面对参数我该咋办?
首先,心里应该明白,传入的参数已经是基类刨去自己的Margin,并且考虑了基类影响Measure过程的属性之后的值。
其次,看自身有没有自定义的,并且影响Layout的属性,根据自己的内容要求,或者孩子的情况,调用孩子的Measure方法,并传入希望孩子限定在多大范围内空间。
最后,返回一个自己期望的Size。
这里应该注意的点:
1. 调用孩子的Measure方法时,传入的参数,是你限定孩子的最大空间,用来显示孩子的Margin以及内容区域的,而孩子不管最终期望的大小有多少,都会被你给他的availableSize裁剪。
2. 根据自身的策略返回一个期望的值,这个期望的值应该是在自己的MinWidth,Width,MaxWidth限定的范围呢,如果没有,基类还会强行调整。
3. 基类调整后的值还会被父传入的availableSize再次调整,返回值不能大于父传入的参数减去Margin之后的值
Q5: MeasureOverride的返回值有没有什么限制?
有,除了如Q5所说,返回值会被重新调节之外,必须保证自己定义的MeasureOverride的返回值是一个确定的值,不是NaN,也不是Infinity。如果小于0时,基类会强制调节为0.
Q6:DesiredSize究竟是什么?
DesiredSize是Measure过程结束后确定的一个大小,他是孩子期望父在Arrange的时候给他分配的大小,包含孩子的Margin区域以及内容区域。如果父在ArrangeOverride的时候,需要调用孩子的Arrange方法时,如果根据策略他希望满足孩子的期望大小,那么,调用孩子的Arrange方法应该传入孩子DesiredSize大小的Rect。
Q7:孩子的DesiredSize确定后,是不是最终就可以得到这么大的空间?
不一定。就像Q7答案所讲,根据父的策略而定,如果父期望分配给孩子期望的大小,就在调用孩子的Arrange方法时,传入DesiredSize大小的Rect,比如Canvas,Canvas的孩子的大小就是孩子的DesiredSize那么大;而如果父是根据自身的设置决定,就不会参考孩子的DesiredSize,传入的当然是自己只能分配给孩子的空间,比如UniformGrid,他根据自身的可用大小,根据行数列数均分空间,然后,均分后的空间分配给每个孩子,而不考虑孩子的DesiredSize。给孩子分配空间,这个过程是在Arrange阶段的。
我们在进行WPF/Silverlight开发时,还可以借助一些工具来助力开发过程。ComponentOne Studio Enterprise 是一款专注于企业应用的.NET全功能控件套包,支持WinForms、WPF、UWP、Xamarin、ASP.NET MVC等多个平台,帮助在缩减成本的同时,提前交付丰富的桌面、Web和移动企业应用。