WPF 的灵活面板






4.33/5 (4投票s)
受 wxWidgets 的 Sizers 启发的 WPF 自定义布局面板
引言
我非常喜欢 WPF。它是微软迄今为止我见过的最好的布局技术。但大多数时候我希望我的控件能占据所有可用空间,这给了我两种内置面板的选择。首先是 DockPanel
,如果我想在多个控件之间分配空间,它就无法正常工作(并且它要求拉伸的控件必须是最后一个子元素,这可能导致一些奇怪的 XAML)。然后是 Grid
,它可以做到一切,但它要求你预先定义所有行和列,这对我来说显得过于啰嗦,特别是如果我只需要一行或一列。
决定实现自己的 Panel 后,我回到了我的初恋:wxWidgets
。wxWidgets
使用 Sizers 进行布局,特别是其中有两个相对简单但允许你完成大部分想要做的事情:wxBoxSizer
和 wxFlexGridSizer
。
BoxSizer
BoxSizer
类(基于 wxBoxSizer
)的概念非常简单。它与 StackPanel
非常相似,因为它沿着 Orientation
方向排列子元素,并允许它们在另一个方向上任意大。但是,它还为其子元素提供了 Proportion
附加属性。Orientation
方向上所有剩余的空间将按比例分配给具有非零比例的子元素。为简化本文内容,我们将开发一个具有固定方向(水平)的 Sizer,但下载文件中使用的是一个 Orientation
属性,就像 StackPanel
类一样。
Proportion 附加属性
附加属性允许我们基本上为其他类添加属性。我们需要一个这样的属性,以便我们的子元素可以告诉我们它们想要的比例。第一步是注册附加属性。这需要告诉框架该属性的类型、我们的类型以及默认值。我们还可以告诉框架,当属性更改时需要重新评估布局。
public static readonly DependencyProperty ProportionProperty =
DependencyProperty.RegisterAttached
("Proportion", typeof(double), typeof(HorizontalBoxSizer),
new FrameworkPropertyMetadata
(0.0, FrameworkPropertyMetadataOptions.AffectsArrange |
FrameworkPropertyMetadataOptions.AffectsMeasure |
FrameworkPropertyMetadataOptions.AffectsRender |
FrameworkPropertyMetadataOptions.AffectsParentArrange |
FrameworkPropertyMetadataOptions.AffectsParentMeasure));
第二件我们需要做的事情是提供一些标准的便捷函数
public static double GetProportion(DependencyObject obj)
{
return (double)obj.GetValue(ProportionProperty);
}
public static void SetProportion(DependencyObject obj, double value)
{
obj.SetValue(ProportionProperty, value);
}
一次测量
测量过程是布局的第一部分。在这里,我们被告知我们允许有多大,并且必须返回我们想要有多大。我们还负责递归调用子元素的 Measure
。请注意,我们真正想要覆盖的函数是 MeasureOverride
,而不是 Measure
本身。这让基类的 MeasureCore
方法为我们处理诸如边距之类的东西,这是一个很大的优势。对于我们的水平 BoxSizer
,实现非常简单。我们只需要找到最高的子元素并使用它的高度;宽度将尽可能大。
此函数中的技巧在于处理无穷大。首先,由于我们将允许子元素任意宽度,因此我们需要将 double.PositiveInfinity
作为其可用宽度传递。如果我们只是简单地传递相同的可用宽度,一些具有自身拉伸逻辑(如 Image
)的控件将无法正确调整大小。这意味着我们的 availableSize
可能包含无穷大,但我们不允许在从函数返回的 Size
中包含无穷大,因此我们需要一种策略来处理这个问题。最后,我们还想避免返回大于可用尺寸的尺寸;如果这样做,我们的控件将超出其应占用的区域。
protected override Size MeasureOverride(Size availableSize)
{
Size SizeForChildren = new Size(double.PositiveInfinity, availableSize.Height);
double TotalWidth = 0;
double MaxHeight = 0;
foreach (UIElement Child in InternalChildren)
{
Child.Measure(SizeForChildren);
TotalWidth += Child.DesiredSize.Width;
MaxHeight = Math.Max(MaxHeight, Child.DesiredSize.Height);
}
TotalWidth = Math.Min(TotalWidth, availableSize.Width);
return new Size()
{
Width = TotalWidth,
Height = double.IsInfinity(availableSize.Height) ?
MaxHeight : availableSize.Height
};
}
这里要注意的另一件事是我们正在使用 InternalChildren
属性而不是 Children
属性,因为 MSDN 建议我们这样做。
整体排列
排列过程是布局的第二部分。现在,我们被告知我们的实际尺寸应该是多少,并且必须通过调用子元素的 Arrange
方法来告诉子元素它们实际将有多大以及它们的确切位置。我们的第一个实现并不复杂。我们只需要计算固定尺寸子元素需要多少空间,然后将剩余空间分配给可变尺寸子元素。
protected override Size ArrangeOverride(Size finalSize)
{
double TotalProportion = 0;
double FixedWidth = 0;
// The first time we iterate over the children
// we're looking for how much space needs to be
// allocated for fixed-size elements and what
// the total proportion is for sizing the variable-size
// elements.
foreach (UIElement Child in InternalChildren)
{
double ChildProportion = GetProportion(Child);
if (ChildProportion == 0)
{
FixedWidth += Child.DesiredSize.Width;
}
else
{
TotalProportion += ChildProportion;
}
}
double VariableWidth = Math.Max(finalSize.Width - FixedWidth, 0);
// The second time we iterate over the children
// we'll tell them the size they're getting.
double CurrentPos = 0;
foreach (UIElement Child in InternalChildren)
{
double ChildProportion = GetProportion(Child);
double ChildWidth;
if (ChildProportion == 0)
{
ChildWidth = Child.DesiredSize.Width;
}
else
{
ChildWidth = ChildProportion / TotalProportion * VariableWidth;
}
Child.Arrange(new Rect(CurrentPos, 0, ChildWidth, finalSize.Height));
CurrentPos += ChildWidth;
}
return finalSize;
}
但现在让我们让我们的 Sizer 更通用。与其只将所有内容拉伸到相同的高度,不如使用 VerticalAlignment
属性允许子元素像 StackPanel
一样指定其垂直处理方式。这没什么复杂的,但有一个小窍门:VerticalAlignment
是 FrameworkElement
类的一个属性,但我们的子元素包含在 UIElement
的集合中,因此需要进行类型转换。(FrameworkElement
继承自 UIElement
。)
double VerticalPos = 0;
double ChildHeight = finalSize.Height;
FrameworkElement ChildElement = Child as FrameworkElement;
if (ChildElement != null)
{
if (ChildElement.VerticalAlignment != VerticalAlignment.Stretch)
{
ChildHeight = Child.DesiredSize.Height;
}
switch (ChildElement.VerticalAlignment)
{
case VerticalAlignment.Top:
case VerticalAlignment.Stretch:
VerticalPos = 0;
break;
case VerticalAlignment.Bottom:
VerticalPos = finalSize.Height - ChildHeight;
break;
case VerticalAlignment.Center:
VerticalPos = (finalSize.Height - ChildHeight) / 2;
break;
}
}
Child.Arrange(new Rect(CurrentPos, VerticalPos, ChildWidth, ChildHeight));
FlexGridSizer
内置的 Grid 面板具有一些不错的调整大小功能,其星号语义(star semantics),但我们需要为其他列指定宽度,而不是让它们自动适应内容。如果不对大小进行定义,还必须定义 ColumnDefinition
和 RowDefinition
将显得过于冗长。因此,让我们创建一个 FlexGridSizer
类,使其能够适应其内容的大小。这是受 wxWidgets
中的 wxFlexGridSizer
的启发,但并不完全相同。
本文不详细介绍实现,因为概念与我们构建 BoxSizer
的方式非常相似。现在我们是在两个方向上进行布局。因此,这里有一个 FlexGridSizer
如何使用的例子

以及 XAML
<Window x:Class="wxl.FlexGridSizerDemo"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:wxl="clr-namespace:wxl"
Title="FlexGridSizer Demo" Width="200" SizeToContent="Height">
<wxl:FlexGridSizer Margin="2">
<Label wxl:FlexGridSizer.Row="0">First Name</Label>
<TextBox wxl:FlexGridSizer.Row="0"
wxl:FlexGridSizer.Column="1" wxl:FlexGridSizer.HorizontalProportion="1" />
<Label wxl:FlexGridSizer.Row="1">Last Name</Label>
<TextBox wxl:FlexGridSizer.Row="1" wxl:FlexGridSizer.Column="1" />
<Label wxl:FlexGridSizer.Row="2">Location</Label>
<TextBox wxl:FlexGridSizer.Row="2" wxl:FlexGridSizer.Column="1" />
<Label wxl:FlexGridSizer.Row="3">User Icon</Label>
<TextBox wxl:FlexGridSizer.Row="3" wxl:FlexGridSizer.Column="1" />
<Button wxl:FlexGridSizer.Row="3"
wxl:FlexGridSizer.Column="2" Padding="3">. . .</Button>
</wxl:FlexGridSizer>
</Window>
现在你可能会问自己,“我能用普通的 Grid
获得同样的效果吗?”是的,你可以。让我们看看 XAML
<Window x:Class="wxl.GridDemo"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="Grid Demo" Width="200" SizeToContent="Height">
<Grid Margin="2">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition />
<RowDefinition />
<RowDefinition />
<RowDefinition />
</Grid.RowDefinitions>
<Label Grid.Row="0">First Name</Label>
<TextBox Grid.Row="0" Grid.Column="1" />
<Label Grid.Row="1">Last Name</Label>
<TextBox Grid.Row="1" Grid.Column="1" />
<Label Grid.Row="2">Location</Label>
<TextBox Grid.Row="2" Grid.Column="1" />
<Label Grid.Row="3">User Icon</Label>
<TextBox Grid.Row="3" Grid.Column="1" />
<Button Grid.Row="3" Grid.Column="2" Padding="3">. . .</Button>
</Grid>
</Window>
基本上,我们有更多的 XAML 行,尽管它们更短。尽管预先定义行和列将帮助你理清复杂的网格,但我喜欢能够摆脱这些“样板”定义来创建简单的网格。
最终结果
此 zip 文件(10.07 KB) 包含上面讨论的 BoxSizer
和 FlexGridSizer
的代码。
功劳归于应得者
网上有很多关于实现自定义 Panel 的资源,但我发现其中有两个特别有用
- Switched On The Code 的 The Tallest 撰写的《WPF 教程 - 创建自定义 Panel 控件》
- Alec Bryte 在其网站上撰写的《WPF Proportional Panel》
我还要感谢 CodeProject 主办此活动并提供了许多我发现有帮助的资源,感谢 wxWidgets 项目为这些类提供了灵感,并通过其示例和社区教给了我很多关于编程的知识。
修订历史
- 7 月 2 日
- 初始版本
- 7 月 3 日
- 根据 Reinhard Ostermeier 的评论,已更新以更准确地描述内置
Grid
类的功能。