WPF Flexible StackPanel
能够拉伸其内容的堆叠布局面板。
引言
本文介绍了一种排列选项卡面板元素的方式,将其排成一行,提供在空间不足时适应元素的能力,以及填充额外空间的能力。行布局的元素对所有WPF开发者来说都很熟悉。有一个基本面板可以达到这个目标。那就是StackPanel
。但这个面板有限制,在某些情况下不适用。StackPanel
无法填充额外空间。它测量所有元素,仅提供其排列所需的空间。如果StackPanel
可用的空间大于其子元素所需的空间,则该空间将保持空白。而且,当空间不足以排列所有子元素时,StackPanel
只能提供滚动或裁剪,而不是任何形式的元素拉伸。所以,如果您对具有灵活元素排列可能性的行布局感兴趣,请继续阅读。
背景
我第一次遇到将元素堆叠成一行的难题,是在开发自定义TabControl
时。显而易见的决定是使用StackPanel
来排列选项卡头,因为它提供了将选项卡头堆叠成一行的所需行为。然而,当TabControl
太小而无法排列所有选项卡项时,我意识到了我的错误。如前所述,在StackPanel
排列子项之前,它会测量它们并仅提供所需的空间。没有收缩/展开操作来处理空间不足/额外空间。在空间不足的情况下,默认情况下TabControl
会将元素换行到几行。然而,有很多期望行为的例子。例如,浏览器可以在空间不足时收缩选项卡项。Visual Studio可以隐藏文档选项卡项(用户可以从下拉菜单中重新打开文档)。
FlexStackPanel
在浏览器的情况下,可以使用Grid
实现期望的行为。对于每个选项卡项,都保留一个具有星号宽度的列。这将允许在没有自由空间的情况下收缩所有选项卡项。但当一个或几个选项卡项填充整个区域时,它看起来会很难看。所以限制选项卡项的最大宽度是个好主意。但有一个小问题:在浏览器中,所有选项卡都具有相同的宽度。无论它们是否被收缩,它们总是具有相同的宽度。
但是,如果选项卡项的大小不规则,该怎么办?使用Grid
的解决方法在这种情况下没有帮助。一个很好的期望行为的例子可以在Visual Studio(例如2010版)中找到,其中几个工具窗口可以组合在一个选项卡控件中。
这里的“解决方案资源管理器”选项卡是最大的一个,也是收缩的首选。
“类视图”紧随其后,并与“解决方案资源管理器”一起收缩,因为此时它们具有相同的宽度,并且有较小的选项卡项。
在其最后一步,所有选项卡项都一起减小,共享相同的大小。
所以,这就是我想要的行为。
FlexStackPanel
有四种模式,由StretchDirection
依赖属性描述,其值为:None
、DownOnly
、UpOnly
和Both
。现在我将讨论所有这些模式。
无
从它的名字可以猜到,FlexStackPanel
在这种模式下不会做任何事情来处理空间不足/额外空间。这在很大程度上是正确的。在这种模式下,面板的行为类似于默认的StackPanel
。它测量每个子元素,并分配它所需的精确空间。然而,与StackPanel
相比,有一个区别:Overflow
功能。当空间不足以排列所有子元素时,FlexStackPanel
会从右边框开始隐藏子元素,直到没有空间排列其余元素为止。此外,面板会将每个溢出的子元素标记为FlexStackPanel.IsOverflowed
附加属性的值为“True”。这允许处理这些溢出的元素,例如将它们放入下拉列表中(稍后将介绍)。Visual Studio对文档选项卡有完全相同的行为。
DownOnly
在这种模式下,当空间不足时,FlexStackPanel
只允许减小其子元素。这个过程是选择性的:第一个收缩的是最大的元素。如果有几个大小相同的元素,那么它们都会按比例减小以适应可用空间。由于在这种模式下面板只允许收缩元素,如果空间比元素所需空间多,则不会发生任何事情。
UpOnly
此模式与前一种模式仅在内容拉伸方向上有所不同。当有额外空间时,允许拉伸元素。否则,它的行为与None
模式相同,如果元素溢出可用空间,则将其隐藏。与前一种模式一样,扩展过程是选择性的。第一个被扩展的元素是最小的一个。FlexStackPanel
扩展此元素(或多个元素,如果需要),直到其大小等于下一个最小的元素。
两者
在这种模式下,面板的空间由所有元素简单地共享。所有项都将具有相同的大小,无论是否存在额外空间或空间不足。这就像一个所有列都具有星号大小的Grid
面板。设置在元素上的最小/最大约束是影响元素大小的唯一条件。
行为
正如您现在可能已经猜到的,面板能够收缩和/或扩展元素。现在让我们看看这个过程是选择性的和迭代的原因。考虑以下示例。
有五个选项卡项,它们声明的尺寸如下(尺寸指选项卡项的宽度,因为在这种情况下面板的方向是水平的):10、20、20、40和30个单位。因此,排列这些项所需的空间是它们尺寸的总和——120个单位。但面板只有110个单位宽。因此,面板短缺10个单位,必须做些什么。这个“做些什么”取决于面板的模式(StretchDirection
属性)。如果它是None
,那么面板将从右边框开始隐藏元素。所以最后一个选项卡将被隐藏,一切都会好起来的。隐藏过程实际上是将元素排列到一个空矩形中。如果模式是DownOnly
,那么面板会查找最大的元素并开始收缩它们。最大的元素是宽度为40个单位的选项卡项,所以它会被收缩。收缩过程会持续到元素适合为止,或者正在收缩的元素不再是最大的。在这个例子中,我们需要将元素减少10个单位(120-110),所以最大的元素将变为30个单位(40-10)。然后过程会因为第一个条件停止。如果给面板的空间是100个单位,那么第一个条件不满足,但下一个条件满足——正在收缩的元素不再是最大的。取而代之的是,我们现在有两个最大的选项卡项,尺寸相同,都是30个单位。面板必须补偿10个单位(110-100),这两个元素将按比例减小,直到满足其中一个条件。结果如下:10、20、20、25、25,总和为100个单位,第一个条件满足。这就是为什么过程是选择性和迭代的原因。在每次迭代中,面板选择最大的元素并减小它们,直到满足任何一个条件。
在相反的情况下,当存在额外空间且面板的模式为UpOnly
时,扩展元素的過程非常相似,只是面板选择最小的元素并扩展它们,直到填满整个空间(第一个条件)或正在处理的元素不再是最小的(第二个条件)。
这个过程似乎不难,但有一个限制:最小/最大约束。在上面的例子中,如果最大的元素(40)有一个最小约束为35个单位,那么我们就不能将其缩小到较低的值。所以它将保持35个单位的大小,而没有大小约束的元素将被收缩以弥补空间不足。
演示应用程序
在示例应用程序中,我将展示如何使用FlexStackPanel
创建灵活的TabControl
。示例应用程序有两个TabControl
,第一个显示了Visual Studio工具窗口选项卡组的行为,第二个显示了文档窗口选项卡组的行为。后者TabControl
更有趣,因为它演示了Overflow
功能以及通过下拉菜单选择溢出选项卡项的方法。
要使FlexStackPanel
能够作为选项卡项的主面板,您需要创建一个控件模板。
<!-- TabControl Style -->
<Style TargetType="TabControl">
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="TabControl">
<Grid Background="#FF334667">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="*"/>
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<xmp:FlexStackPanel x:Name="TabHeaderPanel" Orientation="Horizontal" IsItemsHost="True"/>
<Border Grid.Row="1" Grid.ColumnSpan="2" Background="White">
<ContentPresenter ContentSource="SelectedContent" />
</Border>
<ToggleButton Grid.Column="1"
xme:FrameworkElementExtension.ContextMenuPlacementTarget=
"{Binding RelativeSource={RelativeSource Self}}"
ContextMenuService.IsEnabled="False"
IsChecked="{Binding Path=ContextMenu.IsOpen,
RelativeSource={RelativeSource Self}, Mode=TwoWay}"
x:Name="PART_MenuButton"
Style="{StaticResource ToggleMenuButton}"
Visibility="Collapsed">
<Path x:Name="MenuIcon"
Stroke="{Binding Path=Foreground, ElementName=PART_MenuButton}"
VerticalAlignment="Center" HorizontalAlignment="Center"
StrokeThickness="0"
Fill="{Binding Path=Foreground, ElementName=PART_MenuButton}"
Data="{StaticResource OverflowMenuIconGeometry}" />
<ToggleButton.Resources>
<CollectionViewSource x:Key="ViewSource"
Source="{Binding Path=ItemsSource,
RelativeSource={RelativeSource TemplatedParent}}">
<CollectionViewSource.SortDescriptions>
<scm:SortDescription/>
</CollectionViewSource.SortDescriptions>
</CollectionViewSource>
</ToggleButton.Resources>
<ToggleButton.ContextMenu>
<ContextMenu Placement="Bottom"
ItemsSource="{Binding Source={StaticResource ViewSource}}"
Style="{StaticResource ContextMenuBase}">
<ContextMenu.ItemContainerStyle>
<Style TargetType="MenuItem" BasedOn="{StaticResource MenuItemBase}">
<Setter Property="Header" Value="{Binding}" />
<Setter Property="Command" Value="{x:Static a:MainWindow.ActivateDocument}" />
<Setter Property="CommandParameter"
Value="{Binding Path=Header, RelativeSource={RelativeSource Self}}" />
</Style>
</ContextMenu.ItemContainerStyle>
</ContextMenu>
</ToggleButton.ContextMenu>
</ToggleButton>
</Grid>
<ControlTemplate.Triggers>
<Trigger Property="TabStripPlacement" Value="Bottom">
<Setter Property="Grid.Row" Value="2" TargetName="TabHeaderPanel"/>
</Trigger>
<DataTrigger Binding="{Binding Path=HasOverflowedChildren, ElementName=TabHeaderPanel}"
Value="false">
<Setter Property="Data" TargetName="MenuIcon" Value="{StaticResource MenuIconGeometry}"/>
</DataTrigger>
<Trigger Property="StretchDirection" SourceName="TabHeaderPanel" Value="None">
<Setter TargetName="PART_MenuButton" Property="Visibility" Value="Visible" />
</Trigger>
<Trigger Property="StretchDirection" SourceName="TabHeaderPanel" Value="UpOnly">
<Setter TargetName="PART_MenuButton" Property="Visibility" Value="Visible" />
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
这里最有趣的部分是下拉菜单。ToggleButton
用作打开菜单的触发器,ContextMenu
用于文档项的选择。ContextMenuService
在切换按钮上被禁用,因此菜单无法通过右键单击打开。取而代之的是,ToggleButton.IsChecked
属性绑定到ContextMenu.IsOpen
属性,因此单击按钮将打开上下文菜单。但是有一个问题:ContextMenu
的PlacementTarget
属性将不会被设置,菜单将在屏幕的左上角打开。菜单项的命令也无法到达目标(MainWindow
),并且项将被禁用。因此,必须显式设置PlacementTarget
,但这无法通过绑定来实现,因为ToggleButton
和ContextMenu
位于不同的视觉树上。为了解决这个问题,编写了一个帮助类。
public static class FrameworkElementExtension
{
#region Static Fields
public static readonly DependencyProperty ContextMenuPlacementTargetProperty =
DependencyProperty.RegisterAttached
("ContextMenuPlacementTarget", typeof(FrameworkElement),
typeof(FrameworkElementExtension),
new PropertyMetadata(default(FrameworkElement),
OnContextMenuPlacementTargetPropertyChanged));
#endregion
#region Methods
public static FrameworkElement GetContextMenuPlacementTarget(UIElement element)
{
return (FrameworkElement)element.GetValue(ContextMenuPlacementTargetProperty);
}
public static void SetContextMenuPlacementTarget(UIElement element, FrameworkElement value)
{
element.SetValue(ContextMenuPlacementTargetProperty, value);
}
private static void OnContextMenuChanged(object sender, EventArgs eventArgs)
{
UpdateContextMenuPlacementTarget((FrameworkElement)sender);
}
private static void OnContextMenuPlacementTargetPropertyChanged(DependencyObject d,
DependencyPropertyChangedEventArgs e)
{
var frameworkElement = (FrameworkElement)d;
var contextMenuPropertyDesc = DependencyPropertyDescriptor.
FromProperty(FrameworkElement.ContextMenuProperty, frameworkElement.GetType());
if (e.OldValue != null)
contextMenuPropertyDesc.RemoveValueChanged(frameworkElement, OnContextMenuChanged);
if (e.NewValue != null)
{
contextMenuPropertyDesc.AddValueChanged(frameworkElement, OnContextMenuChanged);
UpdateContextMenuPlacementTarget(frameworkElement);
}
}
private static void UpdateContextMenuPlacementTarget(FrameworkElement frameworkElement)
{
if (frameworkElement.ContextMenu != null)
frameworkElement.ContextMenu.PlacementTarget = GetContextMenuPlacementTarget(frameworkElement);
}
#endregion
}
此类引入了一个附加属性ContextMenuPlacementTarget
。当此属性设置为任何框架元素时,此扩展程序会观察该元素ContextMenu
属性的变化,如果发生更改,则扩展程序强制将上下文菜单的PlacementTarget
属性设置为该元素的ContextMenuPlacementTarget
附加属性的值。此解决方案可能不是最好的,并且可能随着自定义控件(例如DropDownButton
)的实现或通过其他解决方法而改变。但我决定选择这个来展示另一种解决问题的方法。
关于下拉菜单的下一件事是它的内容。在示例应用程序中,TabControl
的选项卡项不是显式添加的。而是使用ItemsSource
。这里使用字符串的ObservableCollection
来定义TabControl
的ItemsSource
,并且选项卡项由TabControl
的ItemContainerGenerator
生成。这使我们能够也将该源用于ContextMenu
。因此,对ObservableCollection
的更改将立即反映在选项卡控件和下拉菜单中。要使用ContextMenu
上的此源,您可以轻松地编写如下内容:
<ContextMenu ItemsSource="{Binding Path=ItemsSource, RelativeSource={RelativeSource TemplatedParent}}" />
一切都会很好。然而,在前一个控件模板中,您可能会注意到项源的设置方式不同。原因是Overflow
功能。假设我们有四个选项卡项:Doc1、Doc2、Doc3和Doc4。如果TabControl
没有足够的空间来显示它们,那么一些将被隐藏(例如Doc4)。但是,如果您想从下拉菜单中选择Doc4怎么办?TabControl
必须显示该选项卡项,因此一种可能的解决方案是将该选项卡项移动到集合中的第一个位置:Doc4、Doc1、Doc2、Doc3。一切看起来都还好,直到您再次打开DropDown
菜单。您会看到菜单项的顺序现在改变了,它不是按名称排序的。原因是我们将相同的源用于TabControl
和DropDown
菜单。但是期望的行为是让菜单中的项按名称排序,以便更好地搜索所需文档。这就是为什么控件模板使用中间集合的原因。这个集合是CollectionViewSource
,放置在ToggleButton
的资源中。实际上,它本身不是一个集合,而是作为其源的集合的视图。借助该视图,您可以为源集合的客户端提供排序能力,而我们这里的集合客户端是下拉菜单。
<CollectionViewSource x:Key="ViewSource"
Source="{Binding Path=ItemsSource,
RelativeSource={RelativeSource TemplatedParent}}">
<CollectionViewSource.SortDescriptions>
<scm:SortDescription/>
</CollectionViewSource.SortDescriptions>
</CollectionViewSource>
要提供排序能力,您可以定义SortDescription
。在示例应用程序中,使用简单的字符串来描述TabItem
。如果您想为项使用自定义数据类型,如下所示:
public class Document
{
public string Name { get; set; }
}
然后您可以为集合中的排序指定一个键,SortDescription
将如下所示:
<CollectionViewSource x:Key="ViewSource"
Source="{Binding Path=ItemsSource,
RelativeSource={RelativeSource TemplatedParent}}">
<CollectionViewSource.SortDescriptions>
<scm:SortDescription PropertyName="Name"/>
</CollectionViewSource.SortDescriptions>
</CollectionViewSource>
现在我们有了一个灵活的TabControl
和一个下拉菜单,但仍然存在一个问题。我们必须处理一个特殊情况,即选定的选项卡项变得隐藏。如果选定的选项卡项由于溢出而隐藏,我们必须做些什么来使其可见。可能的解决方案是将选定的项移到集合中的第一个位置。为了解决这个问题,FlexStackPanel
提供了一个附加属性IsOverflowed
,该属性由面板为每个溢出元素设置。因此,如果选定的选项卡项溢出,我们可以检测到这一点并执行以下步骤:
static class OverflowTabHeaderObserver
{
public static readonly DependencyProperty EnableTrackingProperty =
DependencyProperty.RegisterAttached("EnableTracking",
typeof (bool), typeof (OverflowTabHeaderObserver),
new PropertyMetadata(false, OnEnableTrackingPropertyChanged));
private static readonly DependencyPropertyDescriptor isOverflowedDesc =
DependencyPropertyDescriptor.FromProperty(
FlexStackPanel.IsOverflowedProperty, typeof(TabItem));
private static void OnEnableTrackingPropertyChanged(DependencyObject depObj,
DependencyPropertyChangedEventArgs args)
{
var tabItem = (TabItem) depObj;
if ((bool)args.OldValue)
isOverflowedDesc.RemoveValueChanged(tabItem, OnTabItemOverflowChanged);
if ((bool)args.NewValue)
isOverflowedDesc.AddValueChanged(tabItem, OnTabItemOverflowChanged);
}
private static void OnTabItemOverflowChanged(object sender, EventArgs e)
{
EnsureActiveTabVisible(((TabItem)sender).VisualAncestors().OfType<TabControl>().First());
}
public static void EnsureActiveTabVisible(TabControl tabControl)
{
if (tabControl.ItemsSource == null)
return;
var ilist = (IList)tabControl.ItemsSource;
var containerGenerator = tabControl.ItemContainerGenerator;
var tabHeader = (TabItem)containerGenerator.ContainerFromItem(tabControl.SelectedItem);
if (!FlexStackPanel.GetIsOverflowed(tabHeader) || !tabHeader.IsSelected) return;
var item = containerGenerator.ItemFromContainer(tabHeader);
ilist.Remove(item);
ilist.Insert(0, item);
tabControl.SelectedIndex = 0;
UpdateFirstItem(tabControl);
}
private static void UpdateFirstItem(TabControl tabControl)
{
var ilist = (IList) tabControl.ItemsSource;
if (ilist.Count == 0)
return;
var containerGenerator = tabControl.ItemContainerGenerator;
var tabItems = ilist.OfType<object>()
.Select(containerGenerator.ContainerFromItem)
.OfType<TabItem>()
.ToList();
foreach (var t in tabItems)
FlexStackPanel.SetShrinkOnOverflow(t, false);
FlexStackPanel.SetShrinkOnOverflow(tabItems.First(), true);
}
public static void SetEnableTracking(UIElement element, bool value)
{
element.SetValue(EnableTrackingProperty, value);
}
public static bool GetEnableTracking(UIElement element)
{
return (bool) element.GetValue(EnableTrackingProperty);
}
}
这是带有EnableTracking
附加属性的帮助类。一旦为选项卡项设置了此属性,帮助类就会开始监视该选项卡项上的FlexStackPanel.IsOverflowed
附加属性的变化。如果选定的选项卡项被隐藏,该帮助类会将该项移动到集合中的第一个位置。另外,如果用户从下拉菜单中选择一个当前隐藏的选项卡项,这就可以调用帮助类的静态方法EnsureActiveTabVisible
来完成工作。如果选项卡控件没有足够的空间显示单个选项卡项,那么该帮助类会采取最后的手段:FlexStackPanel.ShrinkOnOverflow
附加属性。此属性会覆盖面板的行为,限制隐藏具有此属性值为“True”的元素。有了这个属性,您可以确保每次,无论条件如何,至少有一个选项卡项可见。好奇的头脑可能会注意到FlexStackPanel
的MeasureOverride
方法中的一些特殊之处。测量过程有三次迭代来处理这种情况。FlexStackPanel.IsOverflowed
附加属性直接从MeasureOverride
方法设置给元素,因此当客户端在测量步骤中捕获到该变化并更改任何影响测量过程的属性时,测量将失效(或脏)。在这种情况下,影响测量过程的属性是由帮助程序在最后一个可见选项卡项试图隐藏时更改的ShrinkOnOverflow
。如果没有这些测量迭代,我们将在此尝试过程中出现脏测量。结果是,在折叠选项卡控件时,我们会看到一个闪烁的选项卡项。
下面的屏幕截图是最终应用程序。
通过拖动GridSplitter
,您可以观察到FlexStackPanel
的行为。如果空间不足且面板的模式设置为None
或UpOnly
,您将看到选项卡项如何在右边框消失。对于Both
和DownOnly
模式,所有选项卡项都将可见,但尺寸会减小。如果窗口的宽度足够大以显示所有选项卡项并且有一些额外空间,那么在Both
和UpOnly
模式下,选项卡项将被扩展,而在None
和DownOnly
模式下,它们将保持不变。