65.9K
CodeProject 正在变化。 阅读更多。
Home

WPF TreeView 控件的图形树自定义布局样式

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.86/5 (22投票s)

2011年5月23日

CPOL

9分钟阅读

viewsIcon

82506

downloadIcon

3533

展示了如何使用 WPF TreeView 控件绘制带有连接线的图形风格的层次结构。

Sample Image

引言

本文展示了如何使用 WPF Style 和少量的 C# 来实现几个转换器,从而可以使用 WPF TreeView 控件来显示一个连接的图形树样式图。

有趣之处在于,虽然重新样式化 TreeView 及其显示的内容相对容易,但尝试装饰树结构却更加困难。

背景

我有一个数据层次结构,我希望将其显示为连接的图形,即一组节点,父子节点之间有连接线,并且同一深度的节点 adjacent 显示。

默认的 TreeView 控件样式不提供此功能。实际上,它不提供任何形式的装饰,即连接线。

然而,TreeView 控件的一个重要方面是它与 HierarchicalDataTemplate 一起工作。这实际上是一个适配器,它向 TreeView 控件描述了如何遍历分层数据结构。这意味着任何此类数据结构都可以由 TreeView 控件可视化。

我第一次尝试按预期绘制层次结构是基于创建一个自定义面板,并将数据结构分配给面板的 ItemSource 属性,然后使用 MeasureOverride()ArrangeOverride()OnRender() 来执行布局并绘制连接线。这效果很好,但 Panel 只能与标准的 DataTemplate 一起工作,这意味着需要一种替代机制来描述分层数据结构。这似乎是在重复造轮子,尽管它确实有效。

我的下一个方法是将其扩展为一个自定义控件,而用于绑定到 ItemSource 的实现要求该实现支持 HierarchicalDataTemplate,并同时考虑 PathXPath。然而,尽管实现起来很有趣,但它也似乎是在重复造轮子,尤其是因为控件模板机制明确设计用于允许对现有控件进行完全不同的渲染。这重新激发了我以 WPF 的方式解决这个问题的尝试。特别是,一个 帖子展示了如何向默认的 TreeView 控件样式添加连接线,这进一步产生了动力。

工作原理

<ResourceDictionary 
  xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
  xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
  xmlns:local="clr-namespace:BlogSample">
    
  <local:HorzLineConv x:Key="horzLineConv"/>
  <local:VertLineConv x:Key="vertLineConv"/>

  <Style TargetType="TreeViewItem" x:Key="GraphStyle">
    <Setter Property="Template">
      <Setter.Value>
        <ControlTemplate TargetType="TreeViewItem">

          <Grid> <!-- Main Grid-->
            <Grid.RowDefinitions>
              <RowDefinition Height="Auto"/>
              <!-- Horizontal line-->
              <RowDefinition Height="Auto"/>
              <!--The top row contains the item's content.-->
              <RowDefinition Height="Auto" />
              <!-- Item presenter(children) -->
            </Grid.RowDefinitions>

            ...
            
          </Grid> <!-- End of Main grid -->
        </ControlTemplate>
      </Setter.Value>
    </Setter>

    <Setter Property="ItemsPanel">
      <Setter.Value>
        <ItemsPanelTemplate>
          <StackPanel
            HorizontalAlignment="Center" 
            IsItemsHost="True" 
            Orientation="Horizontal"/>
        </ItemsPanelTemplate>
      </Setter.Value>
    </Setter>
  </Style>
</ResourceDictionary>

上面的代码片段摘自 GraphStyle.xaml

样式定义位于资源字典中。这样做的目的是对代码进行分区。首先要注意的是,将两个转换器添加为静态资源。该实现无法完全在 XAML 中完成。我们稍后会回到这些。

该控件由一个主网格组成,该网格包含 1 列和 3 行。所有行的 Height 都设置为 Auto,因此它们有效地根据内容调整大小。

第 0 行包含父节点和子节点垂直连接线连接到的水平线。实际上,这一行包含一个由两列和单行组成的网格,并在其中包含线条,但我们稍后会讨论这一点。

第 1 行包含另一个网格,这次是单列三行,其中顶部和底部行包含垂直连接线,中间行包含实际的节点内容。

最后,第 2 行不包含网格。相反,它是 ItemPresenter 控件,其中包含托管当前节点子节点的 Panel

以下图可能有所帮助

Sample Image

绘制项标题

<Grid Grid.Row="1"> <!-- Header grid -->
  <Grid.RowDefinitions>
      <RowDefinition Height="Auto"/><!-- Vert. line above node    -->
      <RowDefinition Height="*"/>   <!-- Header -->
      <RowDefinition Height="Auto"/><!-- Vert line below node    -->
  </Grid.RowDefinitions>
  <!-- Vertical line above node -->
  <Rectangle Grid.Row="0" 
              Height="10" Stroke="Black" SnapsToDevicePixels="true">
    <Rectangle.Width>
      <Binding Mode="OneWay" 
        Converter="{StaticResource vertLineConv}" 
        ConverterParameter="top" 
        RelativeSource=
          "{RelativeSource AncestorLevel=1, 
                            AncestorType={x:Type TreeViewItem}}"/>
    </Rectangle.Width>
  </Rectangle>

  <!-- Header -->
  <ContentPresenter Grid.Row="1" ContentSource="Header" 
    HorizontalAlignment="Center" VerticalAlignment="Center"/>

  <!-- Vertical line below node -->
  <Rectangle Grid.Row="2"  Height="10" Stroke="Black" 
    SnapsToDevicePixels="true">
    <Rectangle.Width>
      <Binding Mode="OneWay" 
        Converter="{StaticResource vertLineConv}" 
        ConverterParameter="bottom" 
        RelativeSource=
          "{RelativeSource  AncestorLevel=1, 
                            AncestorType={x:Type TreeViewItem}}"/>
    </Rectangle.Width>
  </Rectangle>
</Grid> <!-- End of Header grid -->

上面的代码片段显示了主网格的第二行。这需要显示实际的 TreeViewItem Header,即当前级别的节点内容,以及内容上方和下方的垂直连接线。这些需要水平居中于内容。

这自然会分成一个由单列和三行组成的网格。中间行的 Height 设置为 '*',因此在绘制垂直连接线后,它会占用所有可用空间。垂直线的行占用的空间由线的高度决定,该高度是硬编码的。实际内容是通过在中行使用 ContentPresenter 来呈现的。

对于线条,指定了一个高度为 10 的矩形,尽管不一定总是需要线条。如果节点是根节点,则它应该没有顶部的连接线;或者,如果节点没有子节点,则它应该没有底部的连接线。这就是绑定所处理的。它基本上将矩形的 Width 属性绑定到一个转换器,该转换器确定是否需要指定的连接线。如果需要,则返回 1,否则返回 0。宽度为 0 会导致矩形没有尺寸,因此不会被绘制。

绘制水平线

<Grid Grid.Row="0"> <!-- Horizontal line grid -->
    <Grid.ColumnDefinitions>
    <ColumnDefinition Width="*"/>
    <ColumnDefinition Width="*"/>
    </Grid.ColumnDefinitions>

    <!-- Horizontal line to the left -->
    <Rectangle  Grid.Column="0" HorizontalAlignment="Stretch"
                Stroke="Black" SnapsToDevicePixels="true">
    <Rectangle.Height>
        <Binding  Mode="OneWay"
                Converter="{StaticResource horzLineConv}"
                ConverterParameter="left" 
                RelativeSource="{RelativeSource
                    AncestorLevel=1,
                    AncestorType={x:Type TreeViewItem}}"/>
    </Rectangle.Height>
    </Rectangle>

    <!-- Horizontal line to the right -->
    <Rectangle Grid.Column="1" HorizontalAlignment="Stretch" 
                Stroke="Black"
                SnapsToDevicePixels="true">
    <Rectangle.Height>
        <Binding Mode="OneWay" 
        Converter="{StaticResource horzLineConv}" 
        ConverterParameter="right" 
        RelativeSource="{RelativeSource 
            AncestorLevel=1, 
            AncestorType={x:Type TreeViewItem}}"/>
    </Rectangle.Height>
    </Rectangle>
</Grid> <!-- End of Horizontal line grid -->

绘制一条跨越子节点容器宽度的水平线相对容易。所需线的宽度由包含子节点的面板的宽度决定。实际上,黄色列(包含内容网格的列)的宽度由红色面板的宽度决定。这是因为红色列包含容纳子节点的面板。因此,可以通过添加另一行并在其中绘制一条线来绘制水平线。

然而,这条线会跨越所有子节点的宽度,并且在三种情况下这是不理想的。首先,如果一个节点只有一个子节点,那么根本不需要水平线。其次,最左边的节点只需要在其右半部分上方有一条水平线,从中心延伸到右边缘。反之,最右边的节点也有同样的问题,但需要一条线从中心延伸到左边缘。

解决方案是让父节点不绘制水平线。相反,子节点在自身上方绘制其线条的一部分,这些部分连接起来形成一条连续的线。

这一行包含一个由单行和两列组成的网格,而不是一条线。这显示为浅蓝色。两列均分空间,这意味着每列代表中心项的一半线条,这正是所需要的!如果节点两边都有同级节点,则两条线都在两列中绘制,从而形成一条跨越整个节点的单线。

对于每个节点,使用一个转换器来确定节点是左侧节点还是右侧节点。如果是,则不绘制左侧或右侧的线条部分。用于 **不** 绘制顶部和底部连接线的相同技术也在这里使用,即使用 Rectangle,但这次不是将其 Width 设置为 0,而是将其 Height 设置为 0。重要的是不要干扰 Width 计算,因为 RectangleWidth 设置为其上方项大小所决定的列的宽度,依此类推。

Rectangle Shape 确实是一个非常有用的 Shape,用于渲染与控件的隐式动态大小相关的可选线条。

为什么需要转换器?

public class HorzLineConv : IValueConverter
{
  public object Convert(object value, Type targetType, object parameter, 
                        System.Globalization.CultureInfo culture)
  {
    TreeViewItem item = (TreeViewItem)value;
    ItemsControl ic = ItemsControl.ItemsControlFromItemContainer(item);
    int index = ic.ItemContainerGenerator.IndexFromContainer(item);

    if ((string)parameter == "left")
    {
      // Either left most or single item
      if (index == 0)    
        return (int)0;
      else
        return (int)1;
    }
    else // assume "right"
    {
      // Either right most or single item
      if (index == ic.Items.Count - 1)    
        return (int)0;
      else
        return (int)1;
    }
  }
}

public class VertLineConv : IValueConverter
{
  public object Convert(object value, Type targetType, object parameter, 
                            System.Globalization.CultureInfo culture)
  {
    TreeViewItem item = (TreeViewItem)value;
    ItemsControl ic = ItemsControl.ItemsControlFromItemContainer(item);
    int index = ic.ItemContainerGenerator.IndexFromContainer(item);

    if ((string)parameter == "top")
    {
      if (ic is TreeView)
        return 0;
      else
      return 1;
    }
    else // assume "bottom"
    {
      if (item.HasItems == false)
        return 0;
      else
        return 1;
    }
  }
}

不幸的是,该实现并非仅凭 XAML 完成。为了确定是否应绘制顶部和底部连接线,以及节点是左侧节点还是右侧节点,需要了解树结构。这需要求助于转换器。这些在 GraphStyle.xaml.cs 中定义。

这种方法的基础在很大程度上归功于这个 帖子,它展示了如何获取正在显示的 TreeViewItem

有两个转换器类:HorzLineConv,它确定节点水平线的每个半段是否应绘制;VertLineConv,它确定顶部或底部连接线是否应绘制。

它们的工作方式和使用方式基本相同。在 RectangleHeight 和水平线的转换器之间建立绑定,以及在 RectangleWidth 和垂直线之间建立绑定。每个绑定都将一个字符串作为 ConverterParamater 传递,指示它感兴趣的是哪个连接线,即顶部、底部、左侧或右侧。绑定的源是正在显示的 TreeViewItem

在确定是否应显示顶部连接线方面,VertLineConv 有一个有趣的案例。这适用于除根节点之外的所有节点。最简单的方法是检查当前项的父控件,因为只有根项由 TreeView 拥有,而不是由另一个 TreeViewItem 拥有。

整合所有内容

<Window x:Class="BlogSample.MainWindow"
  xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
  xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
  Title="MainWindow" Height="350" Width="525">
  <Window.Resources>
    <ResourceDictionary>
      <ResourceDictionary.MergedDictionaries>
      <ResourceDictionary Source="GraphStyle.xaml"/>
    </ResourceDictionary.MergedDictionaries>

    <XmlDataProvider x:Key="nodes" Source=".\nodes.xml" XPath="Node"/>

    <HierarchicalDataTemplate DataType="Node" 
      ItemsSource="{Binding XPath=Children/Node}">

      <Border Name="bdr" CornerRadius="360" BorderThickness="3" 
        BorderBrush="Blue" Width="Auto" Height="50" MinWidth="50">
        <TextBlock Text="{Binding XPath=@Name}" 
          HorizontalAlignment="Center" 
          VerticalAlignment="Center"/>
      </Border>
    </HierarchicalDataTemplate>
  </ResourceDictionary>
</Window.Resources>
    
<TreeView ItemContainerStyle="{StaticResource GraphStyle}" 
  ItemsSource="{Binding Source={StaticResource nodes}}"/>
</Window>

演示该样式的程序很简单。它只是将包含样式的 ResourceDicitionary 合并到 Window 的资源中。此外,它将一个简单的分层 XML 结构转换为可供 TreeView 控件使用的源。还定义了一个 HierarchicalDataTemplate,它描述了如何展开它,并为每个节点提供基本的椭圆渲染,节点名称居中显示在其中。

最后,Window 有一个类型为 TreeView 的单个子控件,其源绑定到 XML 数据提供程序。其样式设置为我们一直在查看的样式。这需要指定,因为样式本身已指定了键,这意味着它不会根据其样式控件的类型自动应用。

运行时,它会显示顶部的窗口。

参考文献

这绝对不是我一个人完成的。特别是,以下文章和帖子对我能够取得如此进展起到了重要作用。

首先,Josh Smith 的文章提供了如何重新样式化 TreeView 控件的示例。

其次,这个 帖子 是使用 Rectangle 进行连接线以及使用网格使 Rectangle 绘制到正确尺寸的想法的来源。这也是绑定到实际 TreeViewItem 的机制的来源。

下一步?

这个样式至少有几个我还没有研究过的问题。我认为 TreeView 可以处理多个根节点,而样式实际上只支持一个。有时最左边或最右边节点的垂直线与水平线不完全匹配。对于同一个图形,这可能会因窗口大小而异,所以我怀疑这与 SnapsToDevicePixels 有关。

我已经更改了 HierarchicalDataTemplate,使其将节点渲染为按钮,并更改了数据源以遍历文件系统,所有这些都有效。

另一个有趣的增强功能是为每个节点放置一个 Expander,以便可以像传统的 TreeView 控件一样控制层次结构的遍历。

WPF TreeView 控件的图形树自定义布局样式 - CodeProject - 代码之家
© . All rights reserved.