プログラム系統備忘録ブログ

記事中のコードは自己責任の下でご自由にどうぞ。

Recyclingを指定したWPFのTreeViewでShowLinesな表示にする

Recycling指定でも正常に動作する版の、完全なサンプルコードは最後に載せています。

確認環境

  • Visual Studio: Community 2013
  • プロジェクトの対象のフレームワーク: .NET Framework 4.5
  • PCにインストールしているフレームワーク: .NET Framework 4.5.51209

概要

  1. WinFormsのTreeView.ShowLinesプロパティをtrueにした時と同じような表示を、WPFでも行いたい
  2. stackoverflowでコード片を見つけた
  3. 紹介されている内容で一見上手く動くけれど、VirtualizingPanel.VirtualizationModeをRecyclingに指定すると、表示がおかしくなる場合がある
  4. Headerプロパティ更新時にも表示を更新するようにして解決した

表示がおかしくなるコード(要所のみ)

現象を分かりやすくするために、TreeViewItemの表示内容を"index / count"にしています。

Converter

public class TreeViewItemToIsLastItemConverter : IValueConverter
{
    public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
    {
        var item = (TreeViewItem)value;
        var ic = ItemsControl.ItemsControlFromItemContainer(item);
        if (ic == null) { return DependencyProperty.UnsetValue; }

        int index = ic.ItemContainerGenerator.IndexFromContainer(item);
        return index == ic.Items.Count - 1;
    }
    // ConverterBackはthrowするだけ
}
public class TreeViewItemToTitleConverter : IValueConverter
{
    public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
    {
        var item = (TreeViewItem)value;
        var ic = ItemsControl.ItemsControlFromItemContainer(item);
        if (ic == null) { return DependencyProperty.UnsetValue; }

        int index = ic.ItemContainerGenerator.IndexFromContainer(item);
        return string.Format("{0}/{1}", index + 1, ic.Items.Count);
    }
    // ConverterBackはthrowするだけ
}

Viewで使用するTreeViewItemのControlTemplate

<ControlTemplate x:Key="TreeViewItemTemplate" TargetType="{x:Type TreeViewItem}">
    <Grid>
        <Grid.ColumnDefinitions ... />
        <Grid.RowDefinitions ... />

        <!--ShowLines用の線-->
        <Rectangle Margin="9,0,0,0" Height="1" Stroke="#DCDCDC" />
        <Rectangle x:Name="VerticalLine"
                   Width="1" Stroke="#DCDCDC"
                   Grid.RowSpan="2" />

        <ToggleButton x:Name="Expander" ... />
        <TextBlock Text="{Binding RelativeSource={RelativeSource AncestorType={x:Type TreeViewItem}},
                            Converter={StaticResource TreeViewItemToTitleConverter}}"
                   Grid.Column="1" />

        <ItemsPresenter x:Name="ItemsHost"
                        Grid.Row="1" Grid.Column="1" />
    </Grid>
    <ControlTemplate.Triggers>
        <!--ToggleButtonの表示非表示や、IsExpandedの制御用のTriggerがここに入る-->

        <!--兄弟間で最後の要素は縦線を半分までにする-->
        <DataTrigger Binding="{Binding RelativeSource={RelativeSource Self},
                        Converter={StaticResource TreeViewItemToIsLastItemConverter}}"
                     Value="true">
            <Setter TargetName="VerticalLine"
                    Property="Height"
                    Value="9" />
            <Setter TargetName="VerticalLine"
                    Property="VerticalAlignment"
                    Value="Top" />
        </DataTrigger>
    </ControlTemplate.Triggers>
</ControlTemplate>

<!--TreeViewに
    VirtualizingPanel.IsVirtualizing="True"
    VirtualizingPanel.VirtualizationMode="Recycling"
    を指定している-->

どのように表示がおかしくなるか

起動直後はごく正常に見えます。

しかし適当にスクロールしていると、実際の兄弟位置とTextBlockの表示内容が食い違うようになります。
Trigger条件が同じであるShowLinesの表示の制御も、同様におかしくなります。

原因および対処方法

・TextBlockのBindingも、DataTriggerのBindingも、binding target objectsはTreeViewItem
・VirtualizingPanel.VirtualizationModeにRecyclingを指定しているため、TreeViewItemインスタンスが使い回される
→TreeViewItemの位置が移動し、新しい内容を表示するようになっても、Bindingが更新されない

というわけで、TreeViewItemの内容が変化した場合でもBindingが更新されるようにすれば、この現象は解決できます。
具体的には Headerプロパティ が変化した場合でも更新されるようにします。
Converterの処理でTreeViewItemにアクセスする必要があるため、MultiBindingにTreeViewItemそのものとHeaderプロパティを指定してやります。

正常にShowLinesされるコード(抜粋)

Conterver
values[1]にHeaderプロパティの内容が渡されていますが、Bindingの更新タイミングの制御だけに必要であり、Converter内部では使用しません。

public class TreeViewItemToHeightConverter : IMultiValueConverter
{
    public object Convert(object[] values, Type targetType, object parameter, CultureInfo culture)
    {
        var item = (TreeViewItem)values[0];
        var ic = ItemsControl.ItemsControlFromItemContainer(item);
        if (ic == null) { return DependencyProperty.UnsetValue; }

        int index = ic.ItemContainerGenerator.IndexFromContainer(item);
        return index == ic.Items.Count - 1 ? 9.0 : double.NaN;
    }
    // ConverterBackはthrowするだけ
}
public class TreeViewItemToVerticalAlignmentConverter : IMultiValueConverter
{
    public object Convert(object[] values, Type targetType, object parameter, CultureInfo culture)
    {
        var item = (TreeViewItem)values[0];
        var ic = ItemsControl.ItemsControlFromItemContainer(item);
        if (ic == null) { return DependencyProperty.UnsetValue; }

        int index = ic.ItemContainerGenerator.IndexFromContainer(item);
        return index == ic.Items.Count - 1 ? VerticalAlignment.Top : VerticalAlignment.Stretch;
    }
    // ConverterBackはthrowするだけ
}
public class TreeViewItemToTitleConverter : IMultiValueConverter
{
    public object Convert(object[] values, Type targetType, object parameter, CultureInfo culture)
    {
        var item = (TreeViewItem)values[0];
        var ic = ItemsControl.ItemsControlFromItemContainer(item);
        if (ic == null) { return DependencyProperty.UnsetValue; }

        int index = ic.ItemContainerGenerator.IndexFromContainer(item);
        return string.Format("{0}/{1}", index + 1, ic.Items.Count);
    }
    // ConverterBackはthrowするだけ
}

Viewで使用するTreeViewItemのControlTemplate
MultiBindingを使用し、TreeViewItemそのものと、TreeViewItem.Headerの両方を指定します。
Bindingで全てを済ませるようになったため、縦線用Rectangleのx:Nameは不要になります。

<ControlTemplate x:Key="TreeViewItemTemplate" TargetType="{x:Type TreeViewItem}">
    <Grid>
        <Grid.ColumnDefinitions ... />
        <Grid.RowDefinitions ... />

        <!--ShowLines用の線-->
        <Rectangle Margin="9,0,0,0" Height="1" Stroke="#DCDCDC" />
        <Rectangle Width="1" Stroke="#DCDCDC"
                   Grid.RowSpan="2">
            <Rectangle.Height>
                <MultiBinding Converter="{StaticResource TreeViewItemToHeightConverter}">
                    <Binding RelativeSource="{RelativeSource AncestorType={x:Type TreeViewItem}}"
                             Path="." />
                    <Binding RelativeSource="{RelativeSource AncestorType={x:Type TreeViewItem}}"
                             Path="Header" />
                </MultiBinding>
            </Rectangle.Height>
            <Rectangle.VerticalAlignment>
                <MultiBinding Converter="{StaticResource TreeViewItemToVerticalAlignmentConverter}">
                    <Binding RelativeSource="{RelativeSource AncestorType={x:Type TreeViewItem}}"
                             Path="." />
                    <Binding RelativeSource="{RelativeSource AncestorType={x:Type TreeViewItem}}"
                             Path="Header" />
                </MultiBinding>
            </Rectangle.VerticalAlignment>
        </Rectangle>

        <ToggleButton x:Name="Expander" ... />
        <TextBlock Grid.Column="1">
            <TextBlock.Text>
                <MultiBinding Converter="{StaticResource TreeViewItemToTitleConverter}">
                    <Binding RelativeSource="{RelativeSource AncestorType={x:Type TreeViewItem}}"
                             Path="." />
                    <Binding RelativeSource="{RelativeSource AncestorType={x:Type TreeViewItem}}"
                             Path="Header" />
                </MultiBinding>
            </TextBlock.Text>
        </TextBlock>

        <ItemsPresenter x:Name="ItemsHost"
                        Grid.Row="1" Grid.Column="1" />
    </Grid>
    <ControlTemplate.Triggers>
        <!--ToggleButtonの表示非表示や、IsExpandedの制御用のTriggerがここに入る-->
    </ControlTemplate.Triggers>
</ControlTemplate>
<!--TreeViewに
    VirtualizingPanel.IsVirtualizing="True"
    VirtualizingPanel.VirtualizationMode="Recycling"
    を指定している-->

Recycling指定でも正常に動作する版の、完全なサンプルコード

ViewModel

using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;

namespace WpfTreeViewShowLines
{
    public class ViewModel
    {
        public ViewModel()
        {
            var root = new Item
            {
                Children = Enumerable.Range(0, 100)
                    .Select(i => new Item
                    {
                        Children = Enumerable.Range(0, 200)
                            .Select(j => new Item())
                            .ToList(),
                    }).ToList()
            };
            this.Items = new[] { root }.ToList();
        }

        public List<Item> Items { get; private set; }
    }

    public class Item : INotifyPropertyChanged
    {
        public List<Item> Children { get; set; }

        private bool _isExpanded = true;
        public bool IsExpanded
        {
            get { return this._isExpanded; }
            set
            {
                if (this._isExpanded == value) { return; }

                this._isExpanded = value;
                var temp = this.PropertyChanged;
                if (temp != null)
                {
                    temp(this, new PropertyChangedEventArgs("IsExpanded"));
                }
            }
        }

        public event PropertyChangedEventHandler PropertyChanged;
    }
}

Converter

using System;
using System.Globalization;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Data;

namespace WpfTreeViewShowLines
{
    public class TreeViewItemToHeightConverter : IMultiValueConverter
    {
        public object Convert(object[] values, Type targetType, object parameter, CultureInfo culture)
        {
            var item = (TreeViewItem)values[0];
            var ic = ItemsControl.ItemsControlFromItemContainer(item);
            if (ic == null) { return DependencyProperty.UnsetValue; }

            int index = ic.ItemContainerGenerator.IndexFromContainer(item);
            return index == ic.Items.Count - 1 ? 9.0 : double.NaN;
        }
        public object[] ConvertBack(object value, Type[] targetTypes, object parameter, CultureInfo culture)
        {
            throw new NotSupportedException();
        }
    }
    public class TreeViewItemToVerticalAlignmentConverter : IMultiValueConverter
    {
        public object Convert(object[] values, Type targetType, object parameter, CultureInfo culture)
        {
            var item = (TreeViewItem)values[0];
            var ic = ItemsControl.ItemsControlFromItemContainer(item);
            if (ic == null) { return DependencyProperty.UnsetValue; }

            int index = ic.ItemContainerGenerator.IndexFromContainer(item);
            return index == ic.Items.Count - 1 ? VerticalAlignment.Top : VerticalAlignment.Stretch;
        }
        public object[] ConvertBack(object value, Type[] targetTypes, object parameter, CultureInfo culture)
        {
            throw new NotSupportedException();
        }
    }
    public class TreeViewItemToTitleConverter : IMultiValueConverter
    {
        public object Convert(object[] values, Type targetType, object parameter, CultureInfo culture)
        {
            var item = (TreeViewItem)values[0];
            var ic = ItemsControl.ItemsControlFromItemContainer(item);
            if (ic == null) { return DependencyProperty.UnsetValue; }

            int index = ic.ItemContainerGenerator.IndexFromContainer(item);
            return string.Format("{0}/{1}", index + 1, ic.Items.Count);
        }
        public object[] ConvertBack(object value, Type[] targetTypes, object parameter, CultureInfo culture)
        {
            throw new NotSupportedException();
        }
    }
}

View

<Window x:Class="WpfTreeViewShowLines.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:local="clr-namespace:WpfTreeViewShowLines"
        Title="MainWindow" Height="300" Width="300">
    <Window.DataContext>
        <local:ViewModel />
    </Window.DataContext>

    <Window.Resources>
        <local:TreeViewItemToHeightConverter x:Key="TreeViewItemToHeightConverter" />
        <local:TreeViewItemToVerticalAlignmentConverter x:Key="TreeViewItemToVerticalAlignmentConverter" />
        <local:TreeViewItemToTitleConverter x:Key="TreeViewItemToTitleConverter" />

        <Style x:Key="ExpandCollapseToggleStyle" TargetType="{x:Type ToggleButton}">
            <Setter Property="Focusable" Value="False" />
            <Setter Property="Template">
                <Setter.Value>
                    <ControlTemplate TargetType="{x:Type ToggleButton}">
                        <Grid Width="15" Height="13">
                            <Rectangle Width="9" Height="9" Stroke="#919191">
                                <Rectangle.Fill>
                                    <LinearGradientBrush EndPoint="0.5,2" StartPoint="0.5,0">
                                        <GradientStop Color="White" Offset="0" />
                                        <GradientStop Color="Silver" Offset="0.5" />
                                        <GradientStop Color="LightGray" Offset="1" />
                                    </LinearGradientBrush>
                                </Rectangle.Fill>
                            </Rectangle>

                            <Rectangle x:Name="ExpandPath" Width="1" Height="5" Stroke="Black" />
                            <Rectangle Width="5" Height="1" Stroke="Black" />
                        </Grid>
                        <ControlTemplate.Triggers>
                            <Trigger Property="IsChecked" Value="True">
                                <Setter TargetName="ExpandPath"
                                        Property="Visibility"
                                        Value="Collapsed" />
                            </Trigger>
                        </ControlTemplate.Triggers>
                    </ControlTemplate>
                </Setter.Value>
            </Setter>
        </Style>

        <ControlTemplate x:Key="TreeViewItemTemplate" TargetType="{x:Type TreeViewItem}">
            <Grid>
                <Grid.ColumnDefinitions>
                    <ColumnDefinition MinWidth="19" Width="Auto" />
                    <ColumnDefinition />
                </Grid.ColumnDefinitions>
                <Grid.RowDefinitions>
                    <RowDefinition />
                    <RowDefinition />
                </Grid.RowDefinitions>

                <!--ShowLines用の線-->
                <Rectangle Margin="9,0,0,0" Height="1" Stroke="#DCDCDC" />
                <Rectangle Width="1" Stroke="#DCDCDC"
                           Grid.RowSpan="2">
                    <Rectangle.Height>
                        <MultiBinding Converter="{StaticResource TreeViewItemToHeightConverter}">
                            <Binding RelativeSource="{RelativeSource AncestorType={x:Type TreeViewItem}}"
                                     Path="." />
                            <Binding RelativeSource="{RelativeSource AncestorType={x:Type TreeViewItem}}"
                                     Path="Header" />
                        </MultiBinding>
                    </Rectangle.Height>
                    <Rectangle.VerticalAlignment>
                        <MultiBinding Converter="{StaticResource TreeViewItemToVerticalAlignmentConverter}">
                            <Binding RelativeSource="{RelativeSource AncestorType={x:Type TreeViewItem}}"
                                     Path="." />
                            <Binding RelativeSource="{RelativeSource AncestorType={x:Type TreeViewItem}}"
                                     Path="Header" />
                        </MultiBinding>
                    </Rectangle.VerticalAlignment>
                </Rectangle>

                <ToggleButton x:Name="Expander"
                              Style="{StaticResource ExpandCollapseToggleStyle}"
                              IsChecked="{Binding Path=IsExpanded,
                                  RelativeSource={RelativeSource AncestorType={x:Type TreeViewItem}}}"
                              ClickMode="Press" />
                <TextBlock Grid.Column="1">
                    <TextBlock.Text>
                        <MultiBinding Converter="{StaticResource TreeViewItemToTitleConverter}">
                            <Binding RelativeSource="{RelativeSource AncestorType={x:Type TreeViewItem}}"
                                     Path="." />
                            <Binding RelativeSource="{RelativeSource AncestorType={x:Type TreeViewItem}}"
                                     Path="Header" />
                        </MultiBinding>
                    </TextBlock.Text>
                </TextBlock>

                <ItemsPresenter x:Name="ItemsHost"
                                Grid.Row="1" Grid.Column="1" />
            </Grid>
            <ControlTemplate.Triggers>
                <Trigger Property="HasItems" Value="False">
                    <Setter TargetName="Expander"
                            Property="Visibility"
                            Value="Collapsed" />
                </Trigger>
                <Trigger Property="IsExpanded" Value="False">
                    <Setter TargetName="ItemsHost"
                            Property="Visibility"
                            Value="Collapsed" />
                </Trigger>
            </ControlTemplate.Triggers>
        </ControlTemplate>
        <Style x:Key="TreeViewItemStyle" TargetType="{x:Type TreeViewItem}">
            <Setter Property="IsExpanded" Value="{Binding IsExpanded, Mode=TwoWay}" />
            <Setter Property="Template" Value="{StaticResource TreeViewItemTemplate}" />
        </Style>
    </Window.Resources>

    <TreeView ItemsSource="{Binding Items}"
              ItemContainerStyle="{StaticResource TreeViewItemStyle}"
              VirtualizingPanel.IsVirtualizing="True"
              VirtualizingPanel.VirtualizationMode="Recycling">
        <TreeView.ItemTemplate>
            <HierarchicalDataTemplate ItemsSource="{Binding Children}" />
        </TreeView.ItemTemplate>
    </TreeView>
</Window>