WPF 中的高级自定义 TreeView 布局






4.96/5 (68投票s)
回顾 WPF TreeView 的高级布局自定义。
引言
在 CodeProject 上的一篇先前文章中,我演示了一种重定义 WPF TreeView
显示方式的方法。在那篇文章中,我们研究了如何使用一些 XAML 魔术来让 TreeView
看起来像一个组织结构图。在本文中,我们将探讨一种更复杂、更具交互性的自定义,它使 TreeView
将其项目呈现为一组“嵌套的存储桶”。
让我看看实际效果
让我们来看看自定义的 TreeView
的样子。稍后我们将了解这种自定义布局是如何实现的。下面看到的截图是一个演示应用程序,可以通过页面顶部的链接下载。
用户界面的顶部是自定义的 TreeView
。最里面的项目提供了指向维基百科页面的链接,这些页面包含有关这些项目中命名的城市的信息。UI 的底部是一个 Frame
元素,它加载并显示在 TreeView
中选择的维基百科页面。
如果我们不对上面看到的 TreeView
应用布局自定义,它看起来会是这样的
什么是布局自定义?
再次快速浏览一下上面未自定义的 TreeView
截图。请注意,即使没有应用布局自定义,叶子项目也呈现为超链接。TreeViewItem
内容的渲染方式受数据模板的影响。“布局自定义”我指的是不处理项目内容的渲染,而是解释如何渲染包含项目内容的容器以及这些容器如何相互定位。项目内容对我们来说无关紧要。
TreeView
和 TreeViewItem
都派生自 ItemsControl。ItemsControl
包含项目容器,可以将其视为容纳任意内容的“盒子”。这些盒子的内容是用户消费的数据(即用户关心并最关注的东西)。在 TreeView
中,这些盒子由 TreeViewItem
对象表示。布局自定义会组织 TreeViewItem
——解释它们应该如何定位、如何渲染、是否应该显示或隐藏等。
工作原理
数据格式
在深入研究实现我们自定义布局的代码之前,让我们花点时间回顾一下正在显示的数据。在演示应用程序中,TreeView
绑定到 XML 数据,其格式非常简单
<?xml version="1.0" encoding="utf-8" ?>
<Countries>
<Country CountryName="USA">
<Region RegionName="California">
<City
CityName="Los Angeles"
Uri="http://en.wikipedia.org/wiki/Los_Angeles" />
<!-- More City elements... -->
</Region>
<!-- More Region elements... -->
</Country>
<!-- More Country elements... -->
</Countries>
TreeView
将 Country、Region 和 City 元素显示为 TreeViewItem
。它将 Country 和 Region 项目渲染为可折叠组,其标题是 CountryName 或 RegionName 属性值,内部项目列表来自元素中的嵌套子元素集合(一个国家包含地区,一个地区包含城市)。
TreeViewItem 样式
下面是包含大部分自定义布局实现的 Style
的节选版本
<Style TargetType="TreeViewItem">
<Style.Resources>
<!-- Resources omitted for clarity... -->
</Style.Resources>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="TreeViewItem">
<Grid Margin="8,4">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<!-- This Border contains elements which display
the content and child items of the TreeViewItem. -->
<Border Name="Bd"
Background="{StaticResource ItemAreaBrush}"
BorderBrush="{StaticResource ItemBorderBrush}"
BorderThickness="0.6"
CornerRadius="8"
Padding="6"
SnapsToDevicePixels="True"
>
<Grid>
<!-- Items with children are shown in an Expander. -->
<Expander Name="Exp"
IsExpanded="{TemplateBinding TreeViewItem.IsExpanded}">
<Expander.Header>
<!-- Displays the item's header in the Expander. -->
<ContentPresenter ContentSource="Header" />
</Expander.Header>
<!-- Displays the item's children. -->
<ItemsPresenter />
</Expander>
<!--Items without children are shown in a ContentPresenter.-->
<ContentPresenter Name="CntPres"
ContentSource="Header"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Visibility="Collapsed"
/>
</Grid>
</Border>
</Grid>
<ControlTemplate.Triggers>
<!-- If the TreeViewItem has child items,
show it in an Expander. Otherwise
hide the Expander and show the hidden
ContentPresenter. -->
<Trigger Property="TreeViewItem.HasItems" Value="false">
<Setter
TargetName="Exp"
Property="Visibility"
Value="Collapsed" />
<Setter
TargetName="CntPres"
Property="Visibility"
Value="Visible" />
</Trigger>
<!--When the item is selected in the TreeView, use the
"selected" colors and give it a drop shadow. -->
<Trigger Property="IsSelected" Value="true">
<!-- Setters omitted for clarity... -->
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
<!-- Make each TreeViewItem show it's children
in a StackPanel. If it is a root item then
the Orientation will be 'Horizontal', else
'Vertical'. -->
<Setter Property="ItemsPanel">
<Setter.Value>
<ItemsPanelTemplate>
<ItemsPanelTemplate.Resources>
<local:ItemsPanelOrientationConverter x:Key="conv" />
</ItemsPanelTemplate.Resources>
<StackPanel
IsItemsHost="True"
Orientation="{Binding
RelativeSource={x:Static RelativeSource.TemplatedParent},
Converter={StaticResource conv}}"
/>
</ItemsPanelTemplate>
</Setter.Value>
</Setter>
</Style>
选择正确的视觉效果
上面 XAML 中有几个方面值得指出。Style
将 TreeViewItem
的 Template
属性设置为 ControlTemplate
。该模板负责解释 TreeViewItem
实例应如何渲染。代表 Country 或 Region XML 元素的项目必须渲染为可折叠组,但 City 元素则不需要。让我们仔细看看如何实现这一点。我省略了一些不重要的设置,以便我们能专注于关键信息
<Border>
<Grid>
<!-- Items with children are shown in an Expander. -->
<Expander Name="Exp">
<Expander.Header>
<!-- Displays the item's header in the Expander. -->
<ContentPresenter ContentSource="Header" />
</Expander.Header>
<!-- Displays the item's children. -->
<ItemsPresenter />
</Expander>
<!-- Items without children are shown in a ContentPresenter. -->
<ContentPresenter Name="CntPres"
ContentSource="Header"
Visibility="Collapsed" />
</Grid>
</Border>
上面的 XAML 创建了一个 Border
元素,其中包含一个 Grid
面板。该 Grid
有一行一列(即一个“单元格”)。该单元格包含一个 Expander
和一个 ContentPresenter
,但在这两个元素中的任何一个在任何给定时刻都只会可见。如果 TreeViewItem
具有子项,则会显示 Expander
。如果项目没有任何子项(在这种情况下,如果它代表 XML 数据中的 City 元素),则会显示 ContentPresenter
。
控件模板有一个 Trigger
来确定应使用哪个元素来渲染 TreeViewItem
。下面的代码显示了该 Trigger
<Trigger Property="TreeViewItem.HasItems" Value="false">
<Setter
TargetName="Exp"
Property="Visibility"
Value="Collapsed" />
<Setter
TargetName="CntPres"
Property="Visibility"
Value="Visible" />
</Trigger>
项目布局方向
本文开头截图中看到的布局的另一个棘手方面与 TreeViewItem
的排列方向有关。代表 Country 和 Region 元素的项目水平排列,但 City 项目垂直排列。
要将根项目(Country 项目)排列成水平行,需要将 TreeView
的 ItemsPanel
属性设置为具有水平方向的 StackPanel
。以下是一些来自演示应用程序主 Window
的 XAML,用于配置 TreeView
<TreeView Name="tree"
DataContext="{StaticResource countriesXml}"
ItemsSource="{Binding}"
>
<!-- Import the resource file with the
new TreeViewItem style. -->
<TreeView.Resources>
<ResourceDictionary
Source="GroupedTreeViewItemStyle.xaml" />
</TreeView.Resources>
<!-- Arrange the root items horizontally. -->
<TreeView.ItemsPanel>
<ItemsPanelTemplate>
<StackPanel
IsItemsHost="True"
Orientation="Horizontal" />
</ItemsPanelTemplate>
</TreeView.ItemsPanel>
</TreeView>
下一块拼图需要比仅仅设置一个属性更多的技巧。由于代表 Region 元素的 TreeViewItem
必须水平排列,而 City 项目必须垂直列出,因此我们需要使用值转换器在运行时确定 TreeViewItem
的 ItemsPanel
应使用何种方向。以下是来自前面看到的 Style
的 XAML,用于设置 TreeViewItem
的 ItemsPanel
<Setter Property="ItemsPanel">
<Setter.Value>
<ItemsPanelTemplate>
<ItemsPanelTemplate.Resources>
<local:ItemsPanelOrientationConverter x:Key="conv" />
</ItemsPanelTemplate.Resources>
<StackPanel
IsItemsHost="True"
Orientation="{Binding
RelativeSource={x:Static RelativeSource.TemplatedParent},
Converter={StaticResource conv}}"
/>
</ItemsPanelTemplate>
</Setter.Value>
</Setter>
用作 ItemsPanel
模板的 StackPanel
的 Orientation
属性已绑定。该绑定使用值转换器来确定 StackPanel
应具有水平还是垂直方向。以下是值转换器的代码
[ValueConversion( typeof( ItemsPresenter ), typeof( Orientation ) )]
public class ItemsPanelOrientationConverter : IValueConverter
{
// Returns 'Horizontal' for root TreeViewItems
// and 'Vertical' for all other items.
public object Convert(
object value, Type targetType, object parameter, CultureInfo culture )
{
// The 'value' argument should reference
// an ItemsPresenter.
ItemsPresenter itemsPresenter = value as ItemsPresenter;
if( itemsPresenter == null )
return Binding.DoNothing;
// The ItemsPresenter's templated parent
// should be a TreeViewItem.
TreeViewItem item = itemsPresenter.TemplatedParent as TreeViewItem;
if( item == null )
return Binding.DoNothing;
// If the item is contained in a TreeView then it is
// a root item. Otherwise it is contained in another
// TreeViewItem, in which case it is not a root.
bool isRoot =
ItemsControl.ItemsControlFromItemContainer( item ) is TreeView;
// The children of root items are layed out
// in a horizontal row. The grandchild items
// (i.e. cities) are layed out vertically.
return
isRoot ?
Orientation.Horizontal :
Orientation.Vertical;
}
public object ConvertBack(
object value, Type targetType, object parameter, CultureInfo culture )
{
throw new NotSupportedException( "Cannot convert back." );
}
}