用于用户自定义界面的可递归分割的 WPF 内容控件样式






4.93/5 (4投票s)
一个可递归拆分的用户控件,用于设计可序列化以供共享的自定义界面
引言
本文介绍了一种样式,该样式将内容控件转换为可递归拆分的面板,可在WPF应用程序中使用,使用户能够为视觉界面设计自定义布局。
背景
具有固定菜单和布局的视觉界面对大多数用户来说可能显得有限。然而,一个具有用户可拆分面板的界面可以提供设计界面自定义布局的自由。下面描述的开发过程将展示如何使用初学者级别的样式和触发器知识来实现这一点。还可以让用户保存他们自定义的布局并在以后加载。
最初的想法是开发一个用户控件,它可以拆分成两个自身的实例,中间由一个GridSplitter
分隔。这是通过开发一个WPF用户控件来实现的,因为WPF允许将控件布局保存在XAML文件中,但从未拆分状态切换到拆分状态完全是在代码中完成的,这不符合WPF的正确技术。此外,保存视觉布局会产生一个充斥着不相关细节的文件,例如与每个用户控件实例关联的资源。
在初步尝试中开发的用户控件本身没有任何外观或行为,因为它不需要任何。它唯一的作用就是当用户执行拆分操作时,能够改变其外观为一个拆分面板。当然,在未拆分状态下,该控件必须能够显示一些内容,也许是通过ContentPresenter
。该控件本身与ContentControl
没有区别,只不过它能够显示不同的内容,具体取决于用户确定的拆分状态。
解决方案是使用一个ContentControl
,为不同的拆分状态提供单独的内容模板。模板的切换是通过样式触发器完成的,这些触发器只是检查一个名为SplitState
的枚举属性的当前值,其值分别为Unsplit
、HorizontalSplit
和VerticalSplit
。保存视觉布局只需要保存此枚举的当前值。
可拆分视图样式
具有可切换内容模板的ContentControl样式
在这篇更新的文章中,开发过程以教程风格进行解释,从选择样式作为合适解决方案的起点开始。
如前所述,一个未拆分的面板只需要一个ContentPresenter
,它可以显示任何类型的内容,也许周围有一个边框。
<DataTemplate x:Key="UnsplitTemplate">
<Border>
<ContentPresenter/>
</Border>
</DataTemplate>
此模板定义可以放在应用程序窗口XAML文件的资源部分,或资源字典的单独XAML文件中。如果开发人员希望在其他应用程序中重用模板,则后者方法更可取。这正是本文代码示例中所做的;资源字典位于一个单独的类库项目中,可以在WPF应用程序项目中引用。
对于水平拆分的面板,需要一个具有三行的网格。
<DataTemplate x:Key="HorizontalSplitTemplate">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="*" />
<RowDefinition Height="2" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<ContentControl Grid.Row="0" Style="{DynamicResource RecursiveSplitViewStyle}" />
<GridSplitter Grid.Row="1" Height="Auto" HorizontalAlignment="Stretch"
VerticalAlignment="Stretch" ResizeDirection="Rows" />
<ContentControl Grid.Row="2" Style="{DynamicResource RecursiveSplitViewStyle}" />
</Grid>
</DataTemplate>
垂直拆分面板的模板将具有ColumnDefinitions
,其GridSplitter
的ResizeDirection
将设置为“Columns”。要创建可递归拆分的面板,拆分面板的子面板也应该是可拆分的。
未拆分和已拆分面板的模板可以放在ContentControl
样式中,并带有触发器,以便根据当前的拆分状态切换模板。
<Style x:Key="SplittablePanelStyle" TargetType="ContentControl">
<Style.Triggers>
<DataTrigger Binding="{Binding Path=SplitState}" Value="Unsplit">
<Setter Property="ContentTemplate" Value="{StaticResource UnsplitTemplate}"/>
</DataTrigger>
<DataTrigger Binding="{Binding Path=SplitState}" Value="HorizontalSplit">
<Setter Property="ContentTemplate" Value="{StaticResource HorizontalSplitTemplate}"/>
</DataTrigger>
<DataTrigger Binding="{Binding Path=SplitState}" Value="VerticalSplit">
<Setter Property="ContentTemplate" Value="{StaticResource VerticalSplitTemplate}"/>
</DataTrigger>
</Style.Triggers>
</Style>
总之,当应用于ContentControl
对象时,此样式使该对象根据名为SplitState
的枚举属性更改其内容。此样式已应用于水平或垂直拆分的面板中定义的内容模板内的拆分面板的子项。当然,由于样式必须引用拆分面板模板,因此拆分面板模板将样式作为DynamicResource
引用。这种循环引用使得使用此样式的ContentControl
对象成为可递归拆分的,因为它的子项也是可拆分的。
现在有人可能会问,这个SplitState
属性应该在哪里定义。如果从ContentControl
类派生一个新的用户控件类,它可以包含该属性,但那样的话,保存视觉控件就不是正确的方法了。定义为ContentControl
数据上下文的视图模型对象更符合WPF的MVVM方法。保存视觉布局意味着只保存视图模型,它包含定义视觉外观的属性,例如SplitState
属性。
可拆分面板的视图模型类
我们可以将SplitState
属性,或任何其他定义可递归拆分的ContentControl
对象视觉外观的属性,封装在一个视图模型类中,我们将其命名为RecursiveSplitViewModel
。
SplitState
,指定当前的拆分状态。SplitPosition
,指定第一个子面板的宽度或高度。InnerContent
,指定在未拆分状态下描述面板视觉内容的ructor对象,以及Child1
和Child2
,它们是与子视图关联的同一类的实例。
这些是最基本的ructor,一个人可以想到的。此视图模型类的一个实例将代表一个与可递归拆分面板的并行二叉树相关联的视图模型对象的二叉树。保存此二叉树的顶层成员的当前属性值将保存可递归拆分面板的最终布局。任何需要可递归拆分面板的应用程序只需将具有此样式的ContentControl
对象放在所需位置,并创建一个RecursiveSplitViewModel
对象作为其DataContext
。
带有附加属性的可拆分视图模板
视图模型的InnerContent
属性应作为未拆分面板模板中ContentPresenter
的Content
属性的绑定源。
<DataTemplate x:Key="UnsplitTemplate">
<Border>
<ContentPresenter Content="{Binding Path=InnerContent}"
ContentTemplateSelector=
"{x:Static local:RecursiveSplitViewModel.InnerContentTemplateSelector}"/>
</Border>
</DataTemplate>
ContentPresenter
元素负责显示InnerContent
对象,该对象可以是视觉控件,但那样的话,保存视图模型的二叉树又意味着保存那些视觉控件及其所有杂乱的结构。回到可切换模板的想法,使用简单的对象作为InnerContent
会更容易,然后让客户端应用程序在运行时决定实际显示什么视觉内容。这可以通过向视图模型类添加一个DataTemplateSelector
类型的属性来实现。但是,不太可能每个视图模型实例都需要自己的单独模板选择器。由所有视图模型对象共享的static
属性应该能够满足需求。使用此样式和视图模型的客户端应用程序将为该static
属性指定一个模板选择器对象,并控制为哪种内容类型显示什么。
拆分面板中子面板的大小也是一个问题。用户可以移动分隔器来改变子面板的相对大小,加载保存的布局应该保留子面板的最终大小。视图模型的SplitPosition
属性负责这一点。我们只需要将第一行或第一列的大小绑定到该属性,但以双向模式进行。作者倾向于以像素为单位指定大小,在双精度类型属性中,因此使用了一个转换器类来处理double
和GridLength
类型之间的切换。
<RowDefinition Height="{Binding Path=SplitPosition, Mode=TwoWay,
Converter={StaticResource splitPositionConverter}}"/>
简而言之,SplitPosition
是拆分面板从左边缘或顶部边缘开始的距离(以像素为单位),具体取决于拆分方向。将分隔器位置指定为拆分面板大小的比例将是更好的选择,但这被证明非常困难,因为它需要使用当前父级大小作为参数或在多值转换器中作为另一个绑定属性。
通过命令执行拆分操作
如上所述,早期的尝试涉及在代码中拆分用户控件,这些控件放置在鼠标单击或菜单项单击的事件处理程序中。在使用样式之前,代码通过创建具有三行和三列的网格,并在两个用户控件实例之间放置一个网格分隔器来执行拆分操作。使用带有触发器的样式可以通过简单地设置视图模型对象的SplitState
属性来实现拆分。
private void OnHorizontalSplit(object sender, RoutedEventArgs e)
{
MenuItem clickedItem = sender as MenuItem;
ContentControl splittableView =
(clickedItem.Parent as ContextMenu).PlacementTarget as ContentControl;
if (splittableView == null)
{
// Do something to gracefully declare error.
return;
}
RecursiveSplitViewModel splittableViewModel =
splittableView.DataContext as RecursiveSplitViewModel;
if (splittableViewModel != null)
{
splittableViewModel.SplitState = RecursiveSplitViewState.HorizontalSplit;
}
}
// .. For vertical split ...
if (splittableViewModel != null)
{
splittableViewModel.SplitState = RecursiveSplitViewState.HorizontalSplit;
}
// .. For unsplitting the parent view ...
if (splittableViewModel != null)
{
splittableViewModel.Parent.SplitState = RecursiveSplitViewState.Unsplit;
}
这就是菜单项处理程序中的代码,它被放置在与包含样式的资源字典文件相关的代码文件中。在某种意义上,ContentControl
样式被转换为一个类,这与拥有一个用户控件没有什么不同。更重要的是,作为该项目中视图对象的ContentControl
,它告诉其视图模型更改拆分状态,以便视图模型可以导致视图拆分。
一个视图对象告诉其视图模型事情是这个项目仍然不符合WPF正确技术的一个点。在这篇更新文章附带的代码示例中,包含样式的资源字典使用了命令绑定而不是事件处理程序。
<ContextMenu x:Key="splitViewContextMenu" x:Shared="True" x:Name="splitViewContextMenu">
<MenuItem Header="Horizontal Split" Command="{Binding Path=SplitCommand}"
CommandParameter="{x:Static hwpf:RecursiveSplitState.HorizontalSplit}"/>
<MenuItem Header="Vertical Split" Command="{Binding Path=SplitCommand}"
CommandParameter="{x:Static hwpf:RecursiveSplitState.VerticalSplit}"/>
</ContextMenu>
用户可以通过上下文菜单拆分面板,如下所示。
此上下文菜单定义与可拆分视图样式位于同一个资源字典中,并且它们绑定到视图模型类中的同一个命令定义。
public ICommand SplitCommand
{
get
{
if (mySplitCommand == null)
{
mySplitCommand = new HurWpfCommand(ExecuteSplitCommand, IsEnabled);
}
return mySplitCommand;
}
}
指向以下操作方法。
private void ExecuteSplitCommand(object newSplitState)
{
if (newSplitState is RecursiveSplitState)
{ SplitState = (RecursiveSplitState)newSplitState; }
}
从上面的代码片段可以看出,视图模型对象将通过简单地检查菜单项作为参数发送到SplitCommand
属性的执行方法的新的枚举值来执行自己的拆分。命令属性是一个自定义命令类,是通过采用网上找到的最简单的示例创建的。
选择命令
用于执行拆分的命令实现达到了最终目的,并使使用该样式的ContentControl
成为一个可递归拆分的面板。但是,开发人员(例如作者)可能希望使用户能够更改面板内容,例如,通过拖放操作。在不知道客户端应用程序会期望什么的情况下,几个简单的命令(如上面的命令)不足以满足这些高级需求。相反,可拆分视图样式被设计为仅通知其视图模型它已被选中,以防鼠标按钮按下或释放事件。当然,它只是在其未拆分的模板中预览这些事件,而没有实际处理它们。
<DataTemplate x:Key="UnsplitTemplate">
<Border x:Name="viewBorder" Style="{StaticResource DefaultUnsplitBorderStyle}">
<ContentPresenter x:Name="innerContentPresenter" Content="{Binding Path=InnerContent}"
ContentTemplateSelector=
"{x:Static hwpf:RecursiveSplitViewModel.InnerContentTemplateSelector}">
</ContentPresenter>
<winInter:Interaction.Triggers>
<winInter:EventTrigger EventName="PreviewMouseDown">
<winInter:InvokeCommandAction Command="{Binding Path=SelectCommand, Mode=OneWay}"
CommandParameter="{StaticResource trueValue}"/>
</winInter:EventTrigger>
<winInter:EventTrigger EventName="PreviewDrop">
<winInter:InvokeCommandAction Command="{Binding Path=SelectCommand, Mode=OneWay}"
CommandParameter="{StaticResource trueValue}"/>
</winInter:EventTrigger>
</winInter:Interaction.Triggers>
</Border>
</DataTemplate>
上面的XAML代码片段中,wininter
是对System.Windows.Interactivity
命名空间的引用。由于用户交互事件的预览不容易(至少对作者来说)被ICommand
实现处理,因此使用了事件触发器作为替代。
视图模型类中SelectCommand
属性的操作方法只是将选择状态设置为指定的参数,或者只是切换选择状态。
private void ExecuteSelectCommand(object selectMe)
{
// No selection is allowed on disabled viewmodel objects.
if (!IsEnabled) { return; }
if (selectMe is bool)
{
IsSelected = (bool)selectMe;
}
else { IsSelected = !IsSelected; }
}
然而,这并不是全部。为了通知客户端应用程序,当前选定的视图模型对象存储在RecursiveViewModel
类的static
属性中。
总之,可递归拆分面板的视图模型将拆分之外的其他所有内容留给了使用该样式的客户端应用程序。如果开始了一个拖动操作,则由应用程序决定操作是从哪个视图模型对象开始的。应用程序开发人员只需查找当前选定的视图模型对象。类似地,在拖放操作后检查当前选定的视图模型将有助于确定被拖动项的目标视图模型。
视图模型类的代码
除了上面描述的命令实现之外,视图模型类包含的代码非常少;大部分工作由绑定完成。最初, intended作为绑定源的属性被实现为依赖项属性。在这篇更新文章的代码示例中,视图模型类实现了INotifyPropertyChanged
接口,而不是依赖项属性。这种偏好简化了在某些属性更改时需要执行的附加代码。
例如,当拆分命令的执行操作方法更改SplitState
属性时,使用该样式的ContentControl
将切换到一个拆分模板,该模板包含两个自身的实例,中间有一个网格分隔器。但是,拆分模板中的两个实例也需要两个视图模型实例作为它们的数据上下文。因此,在执行拆分命令后,视图模型类必须创建两个自身的实例作为Child1
和Child2
。
public RecursiveSplitState SplitState
{
get { return mySplitState; }
set
{
mySplitState = value;
// A split viewmodel must have two children.
myChild1 = new RecursiveSplitViewModel(this);
myChild2 = new RecursiveSplitViewModel(this);
// Notify any bound object.
OnPropertyChanged("SplitState");
}
}
Using the Code
在这篇更新文章的代码示例中,包含可拆分视图样式和相关视图模型类的资源字典被放入了一个名为HurWpfLib
的单独WPF类库项目中。这样做只是为了使它们更容易分发和重用。类库还包含一些其他类,例如double
-GridLength
转换器类。
如果类库项目要按原样使用,开发人员只需合并包含样式的资源字典。
<ResourceDictionary.MergedDictionaries>
<ResourceDictionary Source="pack://application:,,,/HurWpfLib;
component/RecursiveSplitView.xaml"/>
</ResourceDictionary.MergedDictionaries>
之后,任何地方需要一个带有递归拆分视图样式的ContentControl
都可以放置。通过类型为RecursiveSplitViewModel
的数据上下文,它将成为一个可递归拆分的面板。
<ContentControl x:Name="MainSplitView" Style="{StaticResource RecursiveSplitViewStyle}">
<ContentControl.DataContext>
<hwpf:RecursiveSplitViewModel/>
</ContentControl.DataContext>
</ContentControl>
一旦放置了带有RecursiveSplitViewStyle
的ContentControl
,其余的工作就可以通过修改定义为其数据上下文的视图模型对象的属性来完成。例如,下面的XAML代码片段将创建一个垂直拆分的面板,其右侧面板被水平拆分,从而总共产生三个窗格。
<ContentControl x:Name="MainSplitView" Style="{StaticResource RecursiveSplitViewStyle}">
<ContentControl.DataContext>
<hwpf:RecursiveSplitViewModel SplitState="Vertical" SplitPosition="250">
<hwpf:RecursiveSplitViewModel.Child2>
<hwpf:RecursiveSplitViewModel SplitState="Horizontal" SplitPosition="150"/>
</hwpf:RecursiveSplitViewModel.Child2>
</hwpf:RecursiveSplitViewModel>
</ContentControl.DataContext>
</ContentControl>
任何特定的UI元素都可以声明在视图模型定义中作为它们的内部内容。如果需要在上面的代码片段的三个窗格中放入一个treeview
、一个textbox
和一个listbox
,则可以这样详细声明:
<ContentControl x:Name="MainSplitView" Style="{StaticResource RecursiveSplitViewStyle}">
<ContentControl.DataContext>
<hwpf:RecursiveSplitViewModel SplitState="Vertical" SplitPosition="250">
<hwpf:RecursiveSplitViewModel.Child1>
<hwpf:RecursiveSplitViewModel>
<hwpf:RecursiveSplitViewModel.InnerContent>
<TreeView/>
</hwpf:RecursiveSplitViewModel.InnerContent>
</hwpf:RecursiveSplitViewModel>
</hwpf:RecursiveSplitViewModel.Child1>
<hwpf:RecursiveSplitViewModel.Child2>
<hwpf:RecursiveSplitViewModel SplitState="Horizontal" SplitPosition="150">
<hwpf:RecursiveSplitViewModel.Child1>
<hwpf:RecursiveSplitViewModel>
<hwpf:RecursiveSplitViewModel.InnerContent>
<TextBox/>
</hwpf:RecursiveSplitViewModel.InnerContent>
</hwpf:RecursiveSplitViewModel>
</hwpf:RecursiveSplitViewModel.Child1>
<hwpf:RecursiveSplitViewModel.Child2>
<hwpf:RecursiveSplitViewModel>
<hwpf:RecursiveSplitViewModel.InnerContent>
<ListBox/>
</hwpf:RecursiveSplitViewModel.InnerContent>
</hwpf:RecursiveSplitViewModel>
</hwpf:RecursiveSplitViewModel.Child2>
</hwpf:RecursiveSplitViewModel>
</hwpf:RecursiveSplitViewModel.Child2>
</hwpf:RecursiveSplitViewModel>
</ContentControl.DataContext>
</ContentControl>
为具有预定义内部内容的视图模型定义设置IsEnabled
属性为False
是一个好主意。如果这样做,它们将不会接受新的内部内容,也不会允许用户进行拆分。您还可以将单个窗格的视图模型对象定义为资源,从而缩短上述XAML声明,但随后可能难以建立内部内容元素之间的绑定。
带模板选择器的示例应用程序
与本文更新的文章一起提供的代码示例包含一个示例数据库查看应用程序,该应用程序允许用户在由拆分面板创建的任何窗格中显示任何数据表或列,这些窗格作为选项卡控件的页面出现。用户只需将一个项从代表数据库架构的treeview
拖到一个拆分面板的窗格上,datatable
就会显示在那里。
<ContentControl x:Name="MainSplitView" Style="{StaticResource RecursiveSplitViewStyle}">
<ContentControl.DataContext>
<hwpf:RecursiveSplitViewModel x:Name="MainSplitViewModel"
SplitState="VerticalSplit" SplitPosition="250">
<hwpf:RecursiveSplitViewModel.Child1>
<hwpf:RecursiveSplitViewModel x:Name="DbTreeViewPanel" IsEnabled="False">
<hwpf:RecursiveSplitViewModel.InnerContent>
<localdbs:DbSource DatabaseType="AccessMdb"
DatabaseName="D:\Projects\Nwind.mdb"/>
</hwpf:RecursiveSplitViewModel.InnerContent>
</hwpf:RecursiveSplitViewModel>
</hwpf:RecursiveSplitViewModel.Child1>
<hwpf:RecursiveSplitViewModel.Child2>
<hwpf:RecursiveSplitViewModel x:Name="DbSplitViewsPanel" IsEnabled="False">
<hwpf:RecursiveSplitViewModel.InnerContent>
<TabControl x:Name="SplitViewsTab"
ItemTemplate="{StaticResource SplitViewsTab_PageHeaderTemplate}"
ContentTemplate="{StaticResource SplitViewsTab_PageContentTemplate}">
<TabControl.ItemsSource>
<hwpf:RecursiveSplitViewModelCollection x:Name="SplitViewModelCollection">
<hwpf:RecursiveSplitViewModel/>
</hwpf:RecursiveSplitViewModelCollection>
</TabControl.ItemsSource>
</TabControl>
</hwpf:RecursiveSplitViewModel.InnerContent>
</hwpf:RecursiveSplitViewModel>
</hwpf:RecursiveSplitViewModel.Child2>
</hwpf:RecursiveSplitViewModel>
</ContentControl.DataContext>
</ContentControl>
上面的代码片段创建了一个垂直拆分的面板,并将一个DbSource
对象作为左侧面板的内部内容。DbSource
类是一个自定义类,它可以扫描Access数据库(或SQL Server数据库,如果定义了数据库类型)的架构,并创建与数据库中的数据表和列对应的DbTableSource
和DbColumnSource
对象。关于这些自定义类的解释超出了本文的范围,可能会出现在一篇关于灵活数据库显示应用程序的单独文章中。
想要测试示例应用程序的开发人员需要在DbSource
定义中指定服务器名称(如果要使用SQL Server数据库)、数据库名称以及用户名和密码(如果需要)。
这里重要的是,应用程序为RecursiveSplitViewModel
类指定了一个模板选择器,该模板选择器选择一个包含TreeView
对象的模板,用于定义为左侧面板内部内容的DbSource
对象。结果是,用户将在左侧面板中看到数据库架构的树状视图显示。
另一方面,右侧面板包含一个预定义的UI元素,它是一个选项卡控件,其项目源是一个RecursiveViewModel
对象集合。该集合最初包含一个视图模型对象。由于选项卡控件定义了一个具有可拆分视图样式的ContentControl
作为其内容模板,用户将看到一个可拆分的面板作为唯一的选项卡页面。该选项卡页面面板可以被递归拆分。
选项卡控件的标题模板包含一个按钮,该按钮帮助用户通过在按下按钮的同时按特定键来添加、插入或删除选项卡页面。这是一个非常粗糙的解决方案,它演示了一种帮助用户自定义数据库显示应用程序布局的方法。这里重要的是,该应用程序允许用户将集合保存在XAML文件中,然后在以后加载它以恢复所有可拆分选项卡页面的最终布局。
更新
- 2013年1月3日:首次发布
- 2013年1月26日:第一次更新
- 代表可拆分视图嵌套集合的用户控件及其包含的用户控件库已被移除。
- 可拆分视图样式的外观模板已得到简化。
- 为简单起见,已删除与分隔器相关的视图模型类属性。
IsSplitterEnabled
属性是唯一保留的属性。 - 用于拆分视图和设置分隔器属性的按钮编辑器栏已被放弃,取而代之的是一个更简单的上下文菜单。
- 为保持一致性,文章和示例代码中的对象名称和术语已修改。
- 2013年2月26日:第二次更新
- 包含可拆分视图样式的资源字典的代码隐藏文件已被移除。
- 代码隐藏文件中出现的事件处理程序已被命令绑定取代。
- 提供了一个更复杂但有意义的示例应用程序,尽管几乎没有对其功能进行解释。
- 通过在采用其他开发人员解决方案的地方添加引用来给予应有的致敬。希望这项工作的分享能够弥补所有剩余的未偿债务。
反馈
作者将非常感谢所有正面或负面的评论,并希望了解它是如何被使用或修改的,以及在使用过程中遇到了哪些问题。